release: bump version to 0.74.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-06-30 13:52:52 +08:00
parent fbecf30513
commit 5bdb55f3f1
61 changed files with 4788 additions and 753 deletions

View File

@@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useWebSocket } from '../../hooks/useWebSocket'
import { describeApiError, describeApiValue } from '../../i18n/api-errors'
import { useAuthStore } from '../../stores/auth'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { AdminLayout } from '../components/layout/AdminLayout'
@@ -148,14 +149,14 @@ function DashboardContent() {
setRestartTaskId(res.data.task_id)
setRestartStartedAt(Date.now())
setRestartStage('waiting_for_shutdown')
setRestartMessage(res.data.message || '已发送重启指令,正在等待服务进入重启流程。')
setRestartMessage(describeApiValue(res.data.message, '已发送重启指令,正在等待服务进入重启流程。'))
setRestartLogs((current) => [...current, `任务已创建: ${res.data.task_id}`])
} catch (restartError: unknown) {
const err = restartError as { response?: { data?: { detail?: string } } }
const restartFailureMessage = describeApiError(restartError, '提交重启任务失败')
setRestartStage('failed')
setRestartMessage(err.response?.data?.detail || '提交重启任务失败')
setRestartMessage(restartFailureMessage)
setRestartLogs((current) => [...current, '提交重启任务失败'])
toast({ tone: 'error', title: '提交失败', description: err.response?.data?.detail || '提交重启任务失败' })
toast({ tone: 'error', title: '提交失败', description: restartFailureMessage })
} finally {
setRestartSubmitting(false)
}
@@ -191,7 +192,7 @@ function DashboardContent() {
try {
const taskRes = await axios.get<RestartTask>(`/api/v1/system/restart-tasks/${restartTaskId}`, { timeout: 1500 })
const task = taskRes.data
if (!cancelled && task?.message) setRestartMessage(task.message)
if (!cancelled && task?.message) setRestartMessage(describeApiValue(task.message, '重启任务正在执行'))
if (!cancelled && restartAction !== 'restart-system') {
const logsRes = await axios.get<RestartTaskLogs>(`/api/v1/system/restart-tasks/${restartTaskId}/logs`, { timeout: 1500 })
if (logsRes.data.lines.length > 0) setRestartLogs(logsRes.data.lines.slice(-8))
@@ -204,7 +205,7 @@ function DashboardContent() {
}
if (!cancelled && (task.status === 'failed' || task.status === 'timeout')) {
setRestartStage(task.status === 'timeout' ? 'timeout' : 'failed')
setRestartMessage(task.message || '重启任务失败')
setRestartMessage(describeApiValue(task.message, '重启任务失败'))
return
}
} catch {

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useWebSocket } from '../../hooks/useWebSocket'
import { describeApiError } from '../../i18n/api-errors'
import { useAuthStore } from '../../stores/auth'
import { AdminLayout } from '../components/layout/AdminLayout'
import { Badge } from '../components/ui/badge'
@@ -168,9 +169,7 @@ function statusLabel(status: string) {
}
function getErrorMessage(error: unknown, fallback: string) {
if (!axios.isAxiosError(error)) return fallback
const detail = error.response?.data?.detail
return typeof detail === 'string' ? detail : fallback
return describeApiError(error, fallback)
}
function formatDateTime(value?: string | null) {

View File

@@ -32,6 +32,9 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useWebSocket } from '../../hooks/useWebSocket'
import { describeApiError, localizeApiMessage } from '../../i18n/api-errors'
import { legacyUiTextEnUS } from '../../i18n/legacy-ui'
import { useLocale, type SupportedLocale } from '../../i18n/locale'
import { AdminLayout } from '../components/layout/AdminLayout'
import { DataTable } from '../components/data-table/DataTable'
import { Button, type ButtonProps } from '../components/ui/button'
@@ -215,6 +218,55 @@ function text(value: unknown, fallback = '-') {
return fallback
}
const legacyUiTextZhCN = Object.fromEntries(
Object.entries(legacyUiTextEnUS).map(([source, target]) => [target, source]),
)
const editableValueTextEnUS: Record<string, string> = {
'智能星球': 'Intelligent Planet',
'智能星球计划': 'Intelligent Planet Program',
'现实层宇宙全息感知系统': 'Reality Layer Situational Awareness System',
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Subsea Cables · Compute Infrastructure',
'智能星球计划品牌标识': 'Intelligent Planet Program brand banner',
'/earth/assets/brand/title-zh.png': '/earth/assets/brand/title-en.png',
}
const editableValueTextZhCN = Object.fromEntries(
Object.entries(editableValueTextEnUS).map(([source, target]) => [target, source]),
)
function localizeAdminText(value: string, locale: SupportedLocale) {
const trimmed = value.trim()
if (!trimmed) return value
const dictionary = locale === 'en-US' ? legacyUiTextEnUS : legacyUiTextZhCN
const translated = dictionary[trimmed]
if (translated) return value.replace(trimmed, translated)
if (trimmed.length > 240) return value
const entries = Object.entries(dictionary)
.filter(([source]) => source && trimmed.includes(source))
.sort(([left], [right]) => right.length - left.length)
if (!entries.length) return value
return entries.reduce((next, [source, target]) => next.split(source).join(target), value)
}
function localizeEditableValue(_key: string, value: unknown, locale: SupportedLocale) {
if (typeof value !== 'string') return value
const dictionary = locale === 'en-US' ? editableValueTextEnUS : editableValueTextZhCN
return dictionary[value] || localizeAdminText(value, locale)
}
function localizeDisplayValue(value: unknown, locale: SupportedLocale) {
if (value === null || value === undefined) return value
return localizeAdminText(text(localizeEditableValue('', value, locale), ''), locale)
}
function localizeSearchParts(parts: unknown[], locale: SupportedLocale) {
return parts
.map((part) => text(localizeDisplayValue(part, locale), ''))
.filter(Boolean)
.join(' ')
}
function pick(record: AnyRecord, keys: string[], fallback = '-') {
for (const key of keys) {
const value = record[key]
@@ -546,6 +598,7 @@ function EditableRecordForm({
onDraftChange: (value: string) => void
hiddenKeys?: string[]
}) {
const { locale } = useLocale()
const parsed = parseJsonDraft(draft, {})
if (!isObjectRecord(parsed)) {
return (
@@ -574,14 +627,14 @@ function EditableRecordForm({
checked={value}
onChange={(event) => onDraftChange(setDraftField(draft, key, event.target.checked))}
/>
{fieldLabelElement(key)}
{fieldLabelElement(key, locale)}
</label>
)
}
if (typeof value === 'number') {
return (
<label key={key} className="an-field" htmlFor={inputId}>
{fieldLabelElement(key)}
{fieldLabelElement(key, locale)}
<input
id={inputId}
className="an-input"
@@ -593,13 +646,14 @@ function EditableRecordForm({
)
}
if (typeof value === 'string' || value === null || value === undefined) {
const displayValue = localizeEditableValue(key, text(value, ''), locale)
return (
<label key={key} className="an-field" htmlFor={inputId}>
{fieldLabelElement(key)}
{fieldLabelElement(key, locale)}
<input
id={inputId}
className="an-input"
value={text(value, '')}
value={text(displayValue, '')}
onChange={(event) => onDraftChange(setDraftField(draft, key, event.target.value))}
/>
</label>
@@ -607,7 +661,7 @@ function EditableRecordForm({
}
return (
<label key={key} className="an-field an-field--wide" htmlFor={inputId}>
{fieldLabelElement(key)}
{fieldLabelElement(key, locale)}
<Textarea
id={inputId}
className="an-json-editor an-json-editor--compact"
@@ -636,6 +690,7 @@ function sectionSummary(states: SectionState[]) {
}
function DetailFields({ record }: { record: AnyRecord }) {
const { locale } = useLocale()
const entries = Object.entries(cleanRecord(record))
.filter(([key]) => !MARKDOWN_CONTENT_KEYS.has(key))
.filter(([, value]) => value === null || ['string', 'number', 'boolean'].includes(typeof value))
@@ -646,8 +701,8 @@ function DetailFields({ record }: { record: AnyRecord }) {
<dl className="an-detail-list">
{entries.map(([key, value]) => (
<div key={key}>
<dt>{fieldLabel(key)}</dt>
<dd>{semanticLabel(value)}</dd>
<dt>{fieldLabel(key, locale)}</dt>
<dd>{localizeDisplayValue(semanticLabel(value), locale)}</dd>
</div>
))}
</dl>
@@ -674,30 +729,30 @@ function DetailMarkdownDocument({ record }: { record: AnyRecord }) {
)
}
function defaultColumns(onSelect: (record: TableRecord) => void): Array<ColumnDef<TableRecord>> {
function defaultColumns(onSelect: (record: TableRecord) => void, locale: SupportedLocale): Array<ColumnDef<TableRecord>> {
return [
{
id: 'name',
header: '名称',
header: localizeAdminText('名称', locale),
size: 260,
cell: ({ row }) => (
<button type="button" className="an-table-link" onClick={() => onSelect(row.original)}>
{recordTitle(row.original)}
{localizeDisplayValue(recordTitle(row.original), locale)}
</button>
),
},
{ id: 'module', header: '模块', size: 150, cell: ({ row }) => row.original.__module },
{ id: 'module', header: localizeAdminText('模块', locale), size: 150, cell: ({ row }) => localizeDisplayValue(row.original.__module, locale) },
{
id: 'status',
header: '状态',
header: localizeAdminText('状态', locale),
size: 130,
cell: ({ row }) => {
const status = recordDisplayStatus(row.original)
return <StatusText tone={statusTone(status)}>{status}</StatusText>
return <StatusText tone={statusTone(status)}>{localizeDisplayValue(status, locale)}</StatusText>
},
},
{ id: 'metric', header: '指标', size: 220, cell: ({ row }) => <span className="an-muted-text">{semanticLabel(recordMetric(row.original))}</span> },
{ id: 'updated', header: '更新时间', size: 180, cell: ({ row }) => row.original.__time },
{ id: 'metric', header: localizeAdminText('指标', locale), size: 220, cell: ({ row }) => <span className="an-muted-text">{localizeDisplayValue(semanticLabel(recordMetric(row.original)), locale)}</span> },
{ id: 'updated', header: localizeAdminText('更新时间', locale), size: 180, cell: ({ row }) => localizeDisplayValue(row.original.__time, locale) },
]
}
@@ -722,13 +777,14 @@ function ModuleTable({
}
loading?: boolean
}) {
const tableColumns = useMemo(() => columns || defaultColumns(onSelect), [columns, onSelect])
const { locale } = useLocale()
const tableColumns = useMemo(() => columns || defaultColumns(onSelect, locale), [columns, locale, onSelect])
return (
<DataTable
className="an-resource-table"
columns={tableColumns}
data={rows}
emptyText="当前模块暂无数据"
emptyText={localizeAdminText('当前模块暂无数据', locale)}
getRowClassName={(row) => row.__rowId === selected?.__rowId ? 'is-selected' : undefined}
getRowId={(row) => row.__rowId}
loading={loading}
@@ -749,11 +805,13 @@ function SectionTabs({
activeKey: string
onChange: (key: string) => void
}) {
const { locale } = useLocale()
return (
<div className="an-section-tabs" role="tablist" aria-label="模块分区">
<div className="an-section-tabs" role="tablist" aria-label={localizeAdminText('模块分区', locale)}>
{sections.map((section) => {
const state = states.find((item) => item.section.key === section.key)
const active = activeKey === section.key
const label = localizeAdminText(section.label, locale)
return (
<button
key={section.key}
@@ -762,11 +820,11 @@ function SectionTabs({
aria-selected={active}
className={active ? 'an-section-tab is-active' : 'an-section-tab'}
data-admin-search-target={`section:${section.key}`}
data-admin-search-text={section.label}
data-admin-search-text={label}
onClick={() => onChange(section.key)}
>
<span className={state ? state.ok ? 'an-section-tab__dot an-section-tab__dot--success' : 'an-section-tab__dot an-section-tab__dot--danger' : 'an-section-tab__dot an-section-tab__dot--neutral'} />
<span>{section.label}</span>
<span>{label}</span>
<strong>{state ? state.rows.length : '-'}</strong>
</button>
)
@@ -1002,16 +1060,17 @@ const fieldHelp: Record<string, string> = {
demo_mode: '开启后访问智能星球会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。',
}
function fieldLabel(key: string) {
return fieldLabels[key] || key.replace(/_/g, ' ')
function fieldLabel(key: string, locale?: SupportedLocale) {
const label = fieldLabels[key] || key.replace(/_/g, ' ')
return locale ? localizeAdminText(label, locale) : label
}
function fieldLabelElement(key: string) {
function fieldLabelElement(key: string, locale?: SupportedLocale) {
const help = fieldHelp[key]
if (!help) return fieldLabel(key)
if (!help) return fieldLabel(key, locale)
return (
<span className="an-field-label-help" title={help}>
<span>{fieldLabel(key)}</span>
<span className="an-field-label-help" title={locale ? localizeAdminText(help, locale) : help}>
<span>{fieldLabel(key, locale)}</span>
<CircleHelp size={13} aria-hidden="true" />
</span>
)
@@ -1890,24 +1949,27 @@ function makeNewMappingGroup(index: number, datasourceConfigId = ''): HierarchyG
}
function EarthBrandPreview({ record }: { record: AnyRecord }) {
const { locale } = useLocale()
const logoSrc = text(record.logo_src, '/earth/assets/brand/earth-logo.png')
const titleSrc = text(record.title_src, '')
const titleText = text(record.title_text, '智能星球计划')
const titleSrc = text(localizeEditableValue('title_src', text(record.title_src, ''), locale), '')
const titleText = text(localizeEditableValue('title_text', text(record.title_text, '智能星球计划'), locale), '智能星球计划')
const titleAlt = text(record.title_alt, titleText)
const subtitle = text(record.subtitle, '现实层宇宙全息感知系统')
const description = text(record.description, '卫星 · 海底光缆 · 算力基础设施')
const ariaLabel = text(record.aria_label, '智能星球计划品牌标识')
const localizedTitleAlt = text(localizeEditableValue('title_alt', titleAlt, locale), titleText)
const subtitle = text(localizeEditableValue('subtitle', text(record.subtitle, '现实层宇宙全息感知系统'), locale), '')
const description = text(localizeEditableValue('description', text(record.description, '卫星 · 海底光缆 · 算力基础设施'), locale), '')
const ariaLabel = text(localizeEditableValue('aria_label', text(record.aria_label, '智能星球计划品牌标识'), locale), titleText)
const brandVariant = locale === 'en-US' ? 'en' : 'zh'
return (
<div className="an-earth-brand-preview" aria-label="Earth 左上角品牌实际渲染预览">
<div className="an-earth-brand-preview" aria-label={localizeAdminText('Earth 左上角品牌实际渲染预览', locale)}>
<div className="an-earth-brand-preview__space">
<div className="earth-left-column">
<div className="hud-panel hud-panel-brand">
<div className="earth-brand earth-brand--zh" aria-label={ariaLabel}>
<div className={`earth-brand earth-brand--${brandVariant}`} aria-label={ariaLabel}>
<img className="earth-brand__logo" src={logoSrc} alt="" aria-hidden="true" />
<div className="earth-brand__copy">
{titleSrc ? (
<img className="earth-brand__title" src={titleSrc} alt={titleAlt} />
<img className="earth-brand__title" src={titleSrc} alt={localizedTitleAlt} />
) : (
<div className="earth-brand__title-text">{titleText}</div>
)}
@@ -2094,11 +2156,7 @@ function maskSecretPreview(value: string, fallbackPrefix = '') {
}
function actionErrorMessage(error: unknown, fallback = '接口请求失败') {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.response?.data?.message
return describeResponseValue(detail, error.message || fallback)
}
return error instanceof Error ? error.message : fallback
return describeApiError(error, fallback)
}
function describeResponseValue(value: unknown, fallback = '接口请求失败') {
@@ -2113,18 +2171,18 @@ function describeResponseValue(value: unknown, fallback = '接口请求失败')
}
function connectionResult(data: unknown): { ok: boolean; message: string } {
if (!isObjectRecord(data)) return { ok: true, message: '当前配置可以连通。' }
if (!isObjectRecord(data)) return { ok: true, message: localizeApiMessage('', '当前配置可以连通。') }
const explicit = data.success ?? data.connected ?? data.ok ?? data.valid
const detail = describeResponseValue(data.message || data.detail || data.error || data.status, '')
const lower = detail.toLowerCase()
const hasFailureText = /(failed|fail|error|invalid|unauthorized|forbidden|auth|api key|apikey|not configured|missing|失败|错误|无效|未配置|鉴权|认证)/.test(lower)
if (explicit === false || hasFailureText) {
return { ok: false, message: detail || '当前配置连通性检查失败。' }
return { ok: false, message: localizeApiMessage(detail, '当前配置连通性检查失败。') }
}
if (explicit === true) {
return { ok: true, message: detail || '当前配置可以连通。' }
return { ok: true, message: localizeApiMessage(detail, '当前配置可以连通。') }
}
return { ok: !hasFailureText, message: detail || '当前配置可以连通。' }
return { ok: !hasFailureText, message: localizeApiMessage(detail, '当前配置可以连通。') }
}
function isSecretDraftUnchanged(nextSecret: unknown, savedPreview?: unknown, revealedSecret?: unknown) {
@@ -2230,14 +2288,19 @@ function FieldGrid({
fields: FieldConfig[]
searchGroupKey?: string
}) {
const { locale } = useLocale()
const current = draftRecord(draft, record)
return (
<div className="an-field-grid">
{fields.map((field) => {
const value = current[field.key]
const displayValue = localizeEditableValue(field.key, value, locale)
const label = localizeAdminText(field.label, locale)
const help = field.help ? localizeAdminText(field.help, locale) : ''
const placeholder = field.placeholder ? localizeAdminText(field.placeholder, locale) : undefined
const className = field.wide ? 'an-field an-field--wide' : 'an-field'
const searchTarget = searchGroupKey ? `${searchGroupKey}:field:${field.key}` : `field:${field.key}`
const searchText = [field.label, field.key, text(value, '')].filter(Boolean).join(' ')
const searchText = [label, field.key, text(displayValue, '')].filter(Boolean).join(' ')
if (field.type === 'boolean') {
return (
<label key={field.key} className="an-checkbox-row an-field--wide" data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
@@ -2247,34 +2310,38 @@ function FieldGrid({
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.checked))}
/>
{field.label}
{field.help ? <small>{field.help}</small> : null}
{label}
{help ? <small>{help}</small> : null}
</label>
)
}
if (field.type === 'select') {
return (
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{field.label}</span>
<span>{label}</span>
<select
className="an-input"
value={text(value, '')}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.value))}
>
{(field.options || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
{(field.options || []).map((option) => (
<option key={option.value} value={option.value}>
{localizeAdminText(option.label, locale)}
</option>
))}
</select>
{field.help ? <small>{field.help}</small> : null}
{help ? <small>{help}</small> : null}
</label>
)
}
if (field.type === 'textarea') {
return (
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{field.label}</span>
<span>{label}</span>
<Textarea
value={isObjectRecord(value) || Array.isArray(value) ? formatRaw(value) : text(value, '')}
placeholder={field.placeholder}
value={isObjectRecord(value) || Array.isArray(value) ? formatRaw(value) : text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => {
const nextValue = event.target.value
@@ -2286,7 +2353,7 @@ function FieldGrid({
}}
spellCheck={false}
/>
{field.help ? <small>{field.help}</small> : null}
{help ? <small>{help}</small> : null}
</label>
)
}
@@ -2296,47 +2363,63 @@ function FieldGrid({
const displayValue = field.secretVisible && isMaskedSecretDraft(secretValue)
? `${secretValue} 已保存密钥不回传明文,输入新值可替换`
: secretValue
const savedSecretSuffix = localizeAdminText('已保存密钥不回传明文,输入新值可替换', locale)
return (
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{field.label}</span>
<span>{label}</span>
<div className="an-secret-input">
<input
className="an-input an-secret-input__control"
type={secretInputType}
value={displayValue}
placeholder={field.placeholder}
value={field.secretVisible && isMaskedSecretDraft(secretValue) ? `${secretValue} ${savedSecretSuffix}` : displayValue}
placeholder={placeholder}
disabled={field.disabled}
autoComplete="new-password"
onChange={(event) => {
const nextValue = event.target.value
const suffix = ' 已保存密钥不回传明文,输入新值可替换'
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue.endsWith(suffix) ? secretValue : nextValue))
const sourceSuffix = ' 已保存密钥不回传明文,输入新值可替换'
const localizedSuffix = ` ${savedSecretSuffix}`
onDraftChange(setNestedDraftField(
draft,
record,
field.key,
nextValue.endsWith(sourceSuffix) || nextValue.endsWith(localizedSuffix) ? secretValue : nextValue,
))
}}
/>
<button
type="button"
className="an-secret-input__toggle"
title={field.secretVisible ? `隐藏${field.label}` : `显示${field.label}`}
aria-label={field.secretVisible ? `隐藏${field.label}` : `显示${field.label}`}
title={field.secretVisible ? localizeAdminText(`隐藏${field.label}`, locale) : localizeAdminText(`显示${field.label}`, locale)}
aria-label={field.secretVisible ? localizeAdminText(`隐藏${field.label}`, locale) : localizeAdminText(`显示${field.label}`, locale)}
onClick={() => field.onToggleSecret?.(!field.secretVisible)}
>
{field.secretVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{field.help ? <small>{field.help}</small> : null}
{help ? <small>{help}</small> : null}
</label>
)
}
const inputAction = field.inputAction
? {
...field.inputAction,
ariaLabel: field.inputAction.ariaLabel
? localizeAdminText(field.inputAction.ariaLabel, locale)
: undefined,
title: localizeAdminText(field.inputAction.title, locale),
}
: null
return (
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{field.label}</span>
{field.inputAction ? (
<ConnectionTestInput action={{ ...field.inputAction, disabled: field.disabled || field.inputAction.disabled }}>
<span>{label}</span>
{inputAction ? (
<ConnectionTestInput action={{ ...inputAction, disabled: field.disabled || inputAction.disabled }}>
<input
className="an-input an-connection-test-input__control"
type={field.type === 'number' ? 'number' : 'text'}
value={text(value, '')}
placeholder={field.placeholder}
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(
draft,
@@ -2350,8 +2433,8 @@ function FieldGrid({
<input
className="an-input"
type={field.type === 'number' ? 'number' : 'text'}
value={text(value, '')}
placeholder={field.placeholder}
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(
draft,
@@ -2361,7 +2444,7 @@ function FieldGrid({
))}
/>
)}
{field.help ? <small>{field.help}</small> : null}
{help ? <small>{help}</small> : null}
</label>
)
})}
@@ -2384,6 +2467,7 @@ function GroupList({
header?: ReactNode
renderGroupControl?: (group: HierarchyGroup) => ReactNode
}) {
const { locale } = useLocale()
return (
<Panel className="an-hierarchy-list">
{header ? <div className="an-hierarchy-list__header">{header}</div> : null}
@@ -2391,35 +2475,43 @@ function GroupList({
<div className="an-hierarchy-list__items">
{groups.map((group) => {
const nested = group.children || []
const groupLabel = localizeDisplayValue(group.label, locale)
const groupDescription = group.description ? localizeDisplayValue(group.description, locale) : ''
const groupStatus = group.status ? localizeDisplayValue(group.status, locale) : ''
if (nested.length) {
return (
<div key={group.key} className="an-hierarchy-group-set">
<div className="an-hierarchy-group-set__title">
<span>
<strong>{group.label}</strong>
{group.description ? <small>{group.description}</small> : null}
<strong>{groupLabel}</strong>
{groupDescription ? <small>{groupDescription}</small> : null}
</span>
{renderGroupControl?.(group)}
</div>
{nested.map((child) => (
<button
key={child.key}
type="button"
className={activeKey === child.key ? 'an-hierarchy-group is-active is-child' : 'an-hierarchy-group is-child'}
data-admin-search-target={child.key}
data-admin-search-text={[child.label, child.description, child.status].filter(Boolean).join(' ')}
onClick={() => onSelect(child)}
>
<span>
<strong>{child.label}</strong>
{child.description ? <small>{child.description}</small> : null}
</span>
<span className="an-hierarchy-group__meta">
{child.status ? <StatusText tone={statusTone(child.status)}>{child.status}</StatusText> : null}
{typeof child.count === 'number' ? <em>{child.count}</em> : null}
</span>
</button>
))}
{nested.map((child) => {
const childLabel = localizeDisplayValue(child.label, locale)
const childDescription = child.description ? localizeDisplayValue(child.description, locale) : ''
const childStatus = child.status ? localizeDisplayValue(child.status, locale) : ''
return (
<button
key={child.key}
type="button"
className={activeKey === child.key ? 'an-hierarchy-group is-active is-child' : 'an-hierarchy-group is-child'}
data-admin-search-target={child.key}
data-admin-search-text={localizeSearchParts([child.label, child.description, child.status], locale)}
onClick={() => onSelect(child)}
>
<span>
<strong>{childLabel}</strong>
{childDescription ? <small>{childDescription}</small> : null}
</span>
<span className="an-hierarchy-group__meta">
{child.status ? <StatusText tone={statusTone(child.status)}>{childStatus}</StatusText> : null}
{typeof child.count === 'number' ? <em>{child.count}</em> : null}
</span>
</button>
)
})}
</div>
)
}
@@ -2429,15 +2521,15 @@ function GroupList({
type="button"
className={activeKey === group.key ? 'an-hierarchy-group is-active' : 'an-hierarchy-group'}
data-admin-search-target={group.key}
data-admin-search-text={[group.label, group.description, group.status].filter(Boolean).join(' ')}
data-admin-search-text={localizeSearchParts([group.label, group.description, group.status], locale)}
onClick={() => onSelect(group)}
>
<span>
<strong>{group.label}</strong>
{group.description ? <small>{group.description}</small> : null}
<strong>{groupLabel}</strong>
{groupDescription ? <small>{groupDescription}</small> : null}
</span>
<span className="an-hierarchy-group__meta">
{group.status ? <StatusText tone={statusTone(group.status)}>{group.status}</StatusText> : null}
{group.status ? <StatusText tone={statusTone(group.status)}>{groupStatus}</StatusText> : null}
{typeof group.count === 'number' ? <em>{group.count}</em> : null}
</span>
</button>
@@ -2482,6 +2574,7 @@ const PLAYGROUND_PRESETS = [
function PlaygroundLite() {
const { toast } = useToast()
const { locale } = useLocale()
const [messages, setMessages] = useState<PlaygroundApiMessage[]>([])
const [selectedPresetKey, setSelectedPresetKey] = useState(PLAYGROUND_PRESETS[0].key)
const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].title)
@@ -2630,20 +2723,20 @@ function PlaygroundLite() {
<label className="an-field">
<span></span>
<select className="an-input" value={selectedPresetKey} onChange={(event) => applyPreset(event.target.value)}>
{PLAYGROUND_PRESETS.map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
{PLAYGROUND_PRESETS.map((preset) => <option key={preset.key} value={preset.key}>{localizeAdminText(preset.label, locale)}</option>)}
</select>
</label>
<label className="an-field">
<span></span>
<input className="an-input" value={title} onChange={(event) => setTitle(event.target.value)} />
<input className="an-input" value={text(localizeDisplayValue(title, locale), '')} onChange={(event) => setTitle(event.target.value)} />
</label>
<label className="an-field">
<span></span>
<Textarea value={objective} onChange={(event) => setObjective(event.target.value)} />
<Textarea value={text(localizeDisplayValue(objective, locale), '')} onChange={(event) => setObjective(event.target.value)} />
</label>
<label className="an-field">
<span></span>
<Textarea value={constraints} onChange={(event) => setConstraints(event.target.value)} />
<Textarea value={text(localizeDisplayValue(constraints, locale), '')} onChange={(event) => setConstraints(event.target.value)} />
</label>
</div>
</Panel>
@@ -2704,9 +2797,9 @@ function PlaygroundLite() {
)) : <EmptyState title="暂无会话" description="发送一条消息开始测试 AI 链路。" />}
</Scrollbar>
<div className="an-playground-composer">
<Textarea value={inputValue} onChange={(event) => setInputValue(event.target.value)} placeholder="输入要发送给 AI 的内容" />
<Textarea value={text(localizeDisplayValue(inputValue, locale), '')} onChange={(event) => setInputValue(event.target.value)} placeholder={localizeAdminText('输入要发送给 AI 的内容', locale)} />
<Button variant="primary" onClick={() => void sendMessage()} loading={sending || Boolean(activeAssistant)}>
<Send size={15} />
<Send size={15} />{localizeAdminText('发送', locale)}
</Button>
</div>
</div>
@@ -2717,6 +2810,7 @@ function PlaygroundLite() {
function ModuleConsole({ config }: { config: ModuleConfig }) {
const { toast } = useToast()
const { locale } = useLocale()
const location = useLocation()
const navigate = useNavigate()
const [states, setStates] = useState<SectionState[]>([])
@@ -5026,13 +5120,13 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} renderGroupControl={activeSection.key === 'tools' ? renderToolSwitch : undefined} />
<Panel className="an-hierarchy-detail">
<div className="an-mobile-detail-bar">
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{activeGroup?.label || activeSection.label}</strong>
<Button size="icon" variant="subtle" title={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</strong>
</div>
<div className="an-panel-heading">
<div>
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={[activeGroup?.label, activeGroup?.description].filter(Boolean).join(' ')}>{activeGroup?.label || activeSection.label}</h2>
<p>{activeGroup?.description || '选择左侧父级后编辑它的子配置。'}</p>
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={localizeSearchParts([activeGroup?.label, activeGroup?.description], locale)}>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</h2>
<p>{localizeDisplayValue(activeGroup?.description || '选择左侧父级后编辑它的子配置。', locale)}</p>
</div>
<div className="an-toolbar">
{activeSection.key === 'integrations' && activeGroup ? (
@@ -5064,7 +5158,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
run: () => requestAction('重置 Prompt', 'post', `/settings/ai-prompts/${encodeURIComponent(text(record.key, ''))}/reset`),
})}><RefreshCw size={15} /></Button>
) : null}
<Button variant="primary" onClick={() => void saveCurrent()} loading={actionLoading}><Save size={15} /></Button>
<Button variant="primary" onClick={() => void saveCurrent()} loading={actionLoading}><Save size={15} />{localizeAdminText('保存', locale)}</Button>
</div>
</div>
<div className="an-panel-body">
@@ -5074,12 +5168,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={fields} searchGroupKey={activeGroup.key} />
{activeSection.key === 'integrations' && Array.isArray(record.models) && record.models.length ? (
<div className="an-sub-list">
<strong></strong>
<strong>{localizeAdminText('可选模型', locale)}</strong>
<div>{(record.models as unknown[]).map((model) => <button key={String(model)} type="button" data-admin-search-target={`${activeGroup.key}:model:${String(model)}`} data-admin-search-text={String(model)} onClick={() => setHierarchyDraft(setNestedDraftField(hierarchyDraft, activeGroup.record, 'model', String(model)))}>{String(model)}</button>)}</div>
</div>
) : null}
</div>
) : <EmptyState title="暂无分组" description="当前分区没有可配置项。" />}
) : <EmptyState title={localizeAdminText('暂无分组', locale)} description={localizeAdminText('当前分区没有可配置项。', locale)} />}
</Scrollbar>
</div>
</Panel>
@@ -5361,7 +5455,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</label>
<label className="an-field">
<span>Feed </span>
<input className="an-input" value={text(feed.name, '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'name', event.target.value))} />
<input className="an-input" value={text(localizeDisplayValue(feed.name, locale), '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'name', event.target.value))} />
</label>
<label className="an-field an-field--wide">
<span>Feed </span>
@@ -5370,13 +5464,13 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<label className="an-field">
<span>Feed </span>
<select className="an-input" value={text(feed.type, 'rss')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'type', event.target.value))}>
{NEWS_SOURCE_TYPE_OPTIONS.filter((option) => option.value !== 'reference').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
{NEWS_SOURCE_TYPE_OPTIONS.filter((option) => option.value !== 'reference').map((option) => <option key={option.value} value={option.value}>{localizeAdminText(option.label, locale)}</option>)}
</select>
</label>
<label className="an-field">
<span></span>
<select className="an-input" value={text(feed.default_category, text(currentSource.default_category, 'business'))} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'default_category', event.target.value))}>
{categories.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
{categories.map((option) => <option key={option.value} value={option.value}>{localizeAdminText(option.label, locale)}</option>)}
</select>
</label>
<label className="an-field">
@@ -5846,13 +5940,13 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} footer={hierarchyFooter} />
<Panel className="an-hierarchy-detail">
<div className="an-mobile-detail-bar">
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{activeGroup?.label || activeSection.label}</strong>
<Button size="icon" variant="subtle" title={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</strong>
</div>
<div className="an-panel-heading">
<div>
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={[activeGroup?.label, activeGroup?.description].filter(Boolean).join(' ')}>{activeGroup?.label || activeSection.label}</h2>
<p>{activeGroup?.description || '选择左侧父级后编辑它的子配置。'}</p>
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={localizeSearchParts([activeGroup?.label, activeGroup?.description], locale)}>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</h2>
<p>{localizeDisplayValue(activeGroup?.description || '选择左侧父级后编辑它的子配置。', locale)}</p>
</div>
<div className="an-toolbar">
{config === configs.earthContent && activeSection.key === 'brand' ? (
@@ -6076,7 +6170,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>Time Capsule</h3>
<p>{snapshotTimeline.length} </p>
<p>{localizeAdminText(`${snapshotTimeline.length} 个历史快照,选择后查看该版本详情。`, locale)}</p>
</div>
<div className="an-time-capsule__controls">
<label className="an-field an-field--wide">
@@ -6858,16 +6952,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
return (
<AdminLayout>
<PageFrame
title={config.title}
description={config.description}
title={localizeAdminText(config.title, locale)}
description={localizeAdminText(config.description, locale)}
className="an-resource-page"
actions={(
<>
<Button size="icon" variant="subtle" onClick={() => void load()} loading={loading} title="刷新" aria-label="刷新"><RefreshCw size={15} /></Button>
<Button size="icon" variant="subtle" onClick={() => void load()} loading={loading} title={localizeAdminText('刷新', locale)} aria-label={localizeAdminText('刷新', locale)}><RefreshCw size={15} /></Button>
{renderCollectionQueueAction()}
{moduleActions}
{config.actions.map((action) => (
<Button key={action.label} asChild size="icon" variant="subtle" title={action.label} aria-label={action.label}>
<Button key={action.label} asChild size="icon" variant="subtle" title={localizeAdminText(action.label, locale)} aria-label={localizeAdminText(action.label, locale)}>
<Link to={action.to}>{action.icon}</Link>
</Button>
))}
@@ -6876,9 +6970,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
>
<div className="an-page-grid">
<SummaryStrip>
<StatCell label="接口在线" value={`${summary.online}/${states.length || 1}`} hint="已加载分区" />
<StatCell label="记录数" value={summary.rows} hint="已加载分区汇总" />
<StatCell label="异常接口" value={summary.failing} hint="失败时页面仍可操作" />
<StatCell label={localizeAdminText('接口在线', locale)} value={`${summary.online}/${states.length || 1}`} hint={localizeAdminText('已加载分区', locale)} />
<StatCell label={localizeAdminText('记录数', locale)} value={summary.rows} hint={localizeAdminText('已加载分区汇总', locale)} />
<StatCell label={localizeAdminText('异常接口', locale)} value={summary.failing} hint={localizeAdminText('失败时页面仍可操作', locale)} />
</SummaryStrip>
<SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} />
@@ -6894,10 +6988,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<Panel className="an-resource-main">
<div className="an-panel-heading">
<div>
<h2>{activeSection?.label || config.listTitle}</h2>
<p>{config.listDescription}</p>
<h2>{localizeAdminText(activeSection?.label || config.listTitle, locale)}</h2>
<p>{localizeAdminText(config.listDescription, locale)}</p>
</div>
<StatusText tone={loading ? 'running' : activeState?.ok === false ? 'danger' : 'success'}>{loading ? '同步中' : activeState?.ok === false ? '接口失败' : '已就绪'}</StatusText>
<StatusText tone={loading ? 'running' : activeState?.ok === false ? 'danger' : 'success'}>{localizeAdminText(loading ? '同步中' : activeState?.ok === false ? '接口失败' : '已就绪', locale)}</StatusText>
</div>
<div className="an-panel-body">
{loading || rows.length > 0 ? (
@@ -6911,11 +7005,11 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
selectedRowIds: datasourceSelectedRowIds,
onToggleAllVisible: toggleAllVisibleDatasourceSelection,
onToggleRow: toggleDatasourceSelection,
getCheckboxLabel: (row) => `选择${recordTitle(row)}`,
getCheckboxLabel: (row) => localizeAdminText(`选择${recordTitle(row)}`, locale),
} : undefined}
/>
) : (
<EmptyState title={loading ? '正在加载数据' : '当前分区暂无记录'} description="切换上方分区可精准查看不同配置和接口。" />
<EmptyState title={localizeAdminText(loading ? '正在加载数据' : '当前分区暂无记录', locale)} description={localizeAdminText('切换上方分区可精准查看不同配置和接口。', locale)} />
)}
</div>
</Panel>
@@ -6930,20 +7024,20 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<div className="an-resource-side">
<div className="an-mobile-detail-bar">
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileResourceDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{selected ? recordTitle(selected) : config.detailTitle}</strong>
<Button size="icon" variant="subtle" title={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileResourceDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{selected ? localizeDisplayValue(recordTitle(selected), locale) : localizeAdminText(config.detailTitle, locale)}</strong>
</div>
<DetailPanel title={config.detailTitle} description={selected ? selected.__endpointLabel : '未选择记录'}>
<DetailPanel title={localizeAdminText(config.detailTitle, locale)} description={localizeDisplayValue(selected ? selected.__endpointLabel : '未选择记录', locale)}>
{selected ? (
<div className="an-detail-content">
<header className="an-detail-title">
<div className="an-detail-title__main">
{selectedHistory.length ? (
<Button size="icon" variant="subtle" icon="back" title="返回上一级详情" aria-label="返回上一级详情" onClick={goBackResourceDetail} />
<Button size="icon" variant="subtle" icon="back" title={localizeAdminText('返回上一级详情', locale)} aria-label={localizeAdminText('返回上一级详情', locale)} onClick={goBackResourceDetail} />
) : null}
<h3>{recordTitle(selected)}</h3>
<h3>{localizeDisplayValue(recordTitle(selected), locale)}</h3>
</div>
<StatusText tone={statusTone(recordDisplayStatus(selected))}>{recordDisplayStatus(selected)}</StatusText>
<StatusText tone={statusTone(recordDisplayStatus(selected))}>{localizeDisplayValue(recordDisplayStatus(selected), locale)}</StatusText>
</header>
{renderRecordActions()}
{renderDatasourceTaskSummary()}
@@ -6951,8 +7045,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{renderStructuredEditor() || <DetailFields record={selected} />}
<div className="an-code-section">
<div className="an-code-section__header">
<strong>{selected.__endpointKey === 'logSnapshot' ? '日志正文' : '原始数据'}</strong>
<Button size="icon" variant="subtle" onClick={copySelected} title="复制原始数据" aria-label="复制原始数据"><Copy size={14} /></Button>
<strong>{localizeAdminText(selected.__endpointKey === 'logSnapshot' ? '日志正文' : '原始数据', locale)}</strong>
<Button size="icon" variant="subtle" onClick={copySelected} title={localizeAdminText('复制原始数据', locale)} aria-label={localizeAdminText('复制原始数据', locale)}><Copy size={14} /></Button>
</div>
<Scrollbar className="an-code-scroll">
<pre className="an-json-view">{selected.__endpointKey === 'logSnapshot' && Array.isArray(selected.lines) ? (selected.lines as unknown[]).join('\n') : formatRaw(cleanRecord(selected))}</pre>
@@ -6960,7 +7054,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</div>
</div>
) : (
<EmptyState title="选择一条记录" description="详情会在右侧完整滚动显示,不会挤压主表区域。" />
<EmptyState title={localizeAdminText('选择一条记录', locale)} description={localizeAdminText('详情会在右侧完整滚动显示,不会挤压主表区域。', locale)} />
)}
</DetailPanel>
</div>

View File

@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { describeApiError } from '../../i18n/api-errors'
import { useAuthStore } from '../../stores/auth'
import { DataTable } from '../components/data-table/DataTable'
import { AdminLayout } from '../components/layout/AdminLayout'
@@ -134,8 +135,7 @@ export default function Users() {
setModalVisible(false)
void fetchUsers()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
toast({ tone: 'error', title: t('common.operationFailed'), description: err.response?.data?.detail || t('users.retryLater') })
toast({ tone: 'error', title: t('common.operationFailed'), description: describeApiError(error, t('users.retryLater')) })
}
}
@@ -147,8 +147,7 @@ export default function Users() {
setDeleteTarget(null)
void fetchUsers()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
toast({ tone: 'error', title: t('users.deleteFailed'), description: err.response?.data?.detail || t('users.retryLater') })
toast({ tone: 'error', title: t('users.deleteFailed'), description: describeApiError(error, t('users.retryLater')) })
}
}

View File

@@ -123,7 +123,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
grid-template-columns: 264px minmax(0, 1fr);
background: var(--an-bg);
overflow: hidden;
}
@@ -151,19 +151,33 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
height: 58px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 14px;
border-bottom: 1px solid var(--an-border);
text-align: left;
}
.admin__brand-copy {
display: grid;
justify-items: start;
min-width: 0;
overflow: hidden;
text-align: left;
}
.admin__brand-text {
font-weight: 700;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin__brand-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin__brand-subtitle,
@@ -183,19 +197,29 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
}
.admin__nav {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px;
display: grid;
justify-items: stretch;
gap: 6px;
text-align: left;
}
.admin__nav-group {
width: 100%;
min-width: 0;
display: grid;
justify-items: stretch;
gap: 4px;
}
.admin__nav-group-button,
.admin__nav-link {
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--an-text);
@@ -204,11 +228,28 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
padding: 0 10px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.admin__nav-group-button svg,
.admin__nav-link svg {
flex: 0 0 auto;
}
.admin__nav-group-button span,
.admin__nav-link span {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin__nav-group-button:hover,
.admin__nav-link:hover,
.admin__nav-link.is-active {
@@ -220,7 +261,11 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
}
.admin__nav-children {
box-sizing: border-box;
width: 100%;
min-width: 0;
display: grid;
justify-items: stretch;
gap: 3px;
padding-left: 22px;
}
@@ -236,6 +281,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.admin__nav-chevron {
margin-left: auto;
flex: 0 0 auto;
transition: transform 0.15s ease;
}
@@ -250,14 +296,16 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
background: color-mix(in srgb, var(--an-bg) 42%, var(--an-surface));
display: grid;
gap: 0;
text-align: left;
}
.admin__account-row {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 12px;
font-size: 12px;
text-align: left;
}
.admin__account-row--primary {
@@ -274,19 +322,23 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
display: flex !important;
grid-template-columns: none;
align-items: center;
justify-content: flex-start;
min-width: 0;
flex: 1 1 auto;
margin-right: 6px;
text-align: left;
}
.admin__account-row .admin__account-profile {
gap: 18px;
gap: 12px;
}
.admin__account-profile > div {
min-width: 0;
display: grid;
justify-items: start;
gap: 1px;
text-align: left;
}
.admin__account-avatar {
@@ -314,6 +366,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
white-space: nowrap;
}
.admin__account-profile span:not(.admin__account-avatar) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin__account-actions {
display: flex !important;
grid-template-columns: none;
@@ -321,6 +379,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
justify-content: flex-end;
flex: 0 0 auto;
gap: 4px;
margin-left: auto;
}
.admin__account-logout,
@@ -390,7 +449,9 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
border-radius: 8px;
background: color-mix(in srgb, var(--an-bg) 62%, var(--an-surface));
display: grid;
justify-items: stretch;
gap: 8px;
text-align: left;
}
.admin__theme-control--sider {
@@ -438,6 +499,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
font-size: calc(10px * var(--segmented-control-scale, 1));
font-weight: 800;
line-height: 1;
padding: 0 4px;
}
.admin__logout {
@@ -2210,6 +2272,31 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
text-overflow: ellipsis;
}
.an-earth-brand-preview .hud-panel-brand .earth-brand--en {
--brand-copy-width: 172px;
}
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__subtitle,
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__description {
font-family: "Roboto Condensed", "Arial Narrow", "Trebuchet MS", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
overflow: visible;
text-overflow: clip;
white-space: normal;
word-break: normal;
overflow-wrap: normal;
letter-spacing: 0;
}
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__subtitle {
font-size: calc(0.58rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.15;
}
.an-earth-brand-preview .hud-panel-brand .earth-brand--en .earth-brand__description {
font-size: calc(0.5rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.15;
}
.an-tv-earth-preview {
--hud-scale: 0.82;
--hud-gap-xs: calc(6px * var(--hud-scale));
@@ -4006,12 +4093,16 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
}
.an-segmented button {
min-width: 0;
border: 0;
background: transparent;
color: var(--an-muted);
border-radius: 4px;
padding: 0 10px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.an-segmented button.is-active {

View File

@@ -41,6 +41,7 @@ const legacyTextPatternsEnUS: LegacyTextPattern[] = [
{ match: /^已导出\s+(.+)$/, replace: (match) => `Exported ${match[1]}` },
{ match: /^(.+)\s+采集失败$/, replace: (match) => `${match[1]} collection failed` },
{ match: /^(.+)\s+采集已取消$/, replace: (match) => `${match[1]} collection cancelled` },
{ match: /^选择(.+)$/, replace: (match) => `Select ${match[1]}` },
]
const legacyTextPatternsZhCN: LegacyTextPattern[] = [
@@ -69,6 +70,7 @@ const legacyTextPatternsZhCN: LegacyTextPattern[] = [
{ match: /^Exported\s+(.+)$/, replace: (match) => `已导出 ${match[1]}` },
{ match: /^(.+)\s+collection failed$/, replace: (match) => `${match[1]} 采集失败` },
{ match: /^(.+)\s+collection cancelled$/, replace: (match) => `${match[1]} 采集已取消` },
{ match: /^Select\s+(.+)$/, replace: (match) => `选择${match[1]}` },
]
function preserveOuterWhitespace(source: string, replacement: string) {
@@ -94,7 +96,14 @@ function translateText(value: string, locale: string) {
const dictionary = normalizeLocale(locale) === 'en-US' ? legacyUiTextEnUS : reverseLegacyUiText
const replacement = dictionary[text]
if (replacement) return preserveOuterWhitespace(value, replacement)
return translatePatternText(value, locale)
const patternTranslated = translatePatternText(value, locale)
if (patternTranslated !== value) return patternTranslated
if (text.length > 240) return value
const entries = Object.entries(dictionary)
.filter(([source]) => source && text.includes(source))
.sort(([left], [right]) => right.length - left.length)
if (!entries.length) return value
return entries.reduce((next, [source, target]) => next.split(source).join(target), value)
}
function translateElementAttributes(element: Element, locale: string) {

View File

@@ -0,0 +1,93 @@
import i18n from './index'
import { legacyUiTextEnUS } from './legacy-ui'
import { normalizeLocale } from './locale'
type ApiErrorPayload = {
detail?: unknown
error?: unknown
message?: unknown
}
type ApiErrorLike = {
message?: unknown
response?: {
data?: ApiErrorPayload
}
}
const cjkPattern = /[\u3400-\u9fff]/
const genericEnglishFallback = 'Operation failed'
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function currentLocale() {
return normalizeLocale(i18n.resolvedLanguage || i18n.language)
}
export function hasCjkText(value: string) {
return cjkPattern.test(value)
}
function englishFallbackFor(fallback: string) {
const normalized = fallback.trim()
if (!normalized) return genericEnglishFallback
if (!hasCjkText(normalized)) return normalized
return legacyUiTextEnUS[normalized] || genericEnglishFallback
}
function stringFromApiValue(value: unknown): string {
if (value === null || value === undefined || value === '') return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (Array.isArray(value)) {
const firstMessage = value
.map((item) => stringFromApiValue(item))
.find(Boolean)
return firstMessage || ''
}
if (isRecord(value)) {
const directMessage = stringFromApiValue(value.message || value.detail || value.error)
if (directMessage) return directMessage
const code = stringFromApiValue(value.code)
if (code) return code
try {
return JSON.stringify(value)
} catch {
return ''
}
}
return ''
}
export function localizeApiMessage(message: string, fallback = genericEnglishFallback) {
const normalized = message.trim()
if (currentLocale() !== 'en-US') return normalized || fallback
const englishFallback = englishFallbackFor(fallback)
if (!normalized) return englishFallback
const exactLegacy = legacyUiTextEnUS[normalized]
if (exactLegacy) return exactLegacy
if (hasCjkText(normalized)) return englishFallback
return normalized
}
export function describeApiValue(value: unknown, fallback = genericEnglishFallback) {
return localizeApiMessage(stringFromApiValue(value), fallback)
}
export function describeApiError(error: unknown, fallback = genericEnglishFallback) {
if (isRecord(error)) {
const apiError = error as ApiErrorLike
const payload = apiError.response?.data
if (payload) {
const responseValue = payload.detail ?? payload.message ?? payload.error
const responseMessage = describeApiValue(responseValue, fallback)
if (responseMessage) return responseMessage
}
const errorMessage = stringFromApiValue(apiError.message)
if (errorMessage) return localizeApiMessage(errorMessage, fallback)
}
if (error instanceof Error) return localizeApiMessage(error.message, fallback)
return localizeApiMessage('', fallback)
}

View File

@@ -18,7 +18,6 @@ export const legacyUiTextEnUS: Record<string, string> = {
'BGP观测': 'BGP Observatory',
'Feed 信息页': 'Feed info page',
'Feed 地址': 'Feed URL',
'Feed 子项': 'Feed entries',
'Feed 标签': 'Feed tags',
'Feed 类型': 'Feed type',
'Feed 名称': 'Feed name',
@@ -46,6 +45,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'供应商': 'Provider',
'供应商状态': 'Provider status',
'供应商配置': 'Provider configuration',
'事件': 'Event',
'保存': 'Save',
'保存并重试': 'Save and retry',
'保存后会进入清洗、翻译、分类和定位队列。': 'After saving, the item enters the cleaning, translation, classification, and geocoding queue.',
@@ -74,10 +74,15 @@ export const legacyUiTextEnUS: Record<string, string> = {
'其他': 'Other',
'刷新': 'Refresh',
'刷新当前 Provider 的模型配置': 'Refresh current provider model configuration',
'刷新间隔(秒)': 'Refresh interval (seconds)',
'刷新模型': 'Refresh models',
'刷新模型列表': 'Refresh model list',
'刷新线程': 'Refresh thread',
'分类': 'Category',
'副标题': 'Subtitle',
'标题图地址': 'Title image URL',
'标题图替代文本': 'Title image alt text',
'标题文字': 'Title text',
'删除 Feed 子项': 'Delete feed entry',
'删除': 'Delete',
'删除失败': 'Delete failed',
@@ -99,6 +104,9 @@ export const legacyUiTextEnUS: Record<string, string> = {
'启动实时源': 'Start realtime source',
'启动边界构建': 'Start boundary build',
'启用': 'Enabled',
'启用 WebSearch': 'Enable WebSearch',
'启用 OCR': 'Enable OCR',
'启用邮件通知': 'Enable email notifications',
'启用抓取': 'Enable fetching',
'启用映射': 'Enable mapping',
'启用筛选': 'Active filters',
@@ -106,6 +114,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'告警记录': 'Alert records',
'告警统计': 'Alert stats',
'名称': 'Name',
'认证方式': 'Auth method',
'后台账号': 'Console account',
'回到首页': 'Back to overview',
'国界精度': 'Boundary accuracy',
@@ -123,6 +132,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'已加载分区汇总': 'Loaded section total',
'工具调用': 'Tool calls',
'已保存': 'Saved',
'已保存密钥不回传明文,输入新值可替换': 'Saved secrets are not returned; enter a new value to replace it',
'已取消': 'Cancelled',
'已处理': 'Resolved',
'已提交': 'Submitted',
@@ -134,6 +144,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'已停用': 'Disabled',
'已就绪': 'Ready',
'已跳过': 'Skipped',
'有效': 'Valid',
'已有新闻': 'Existing news',
'已有任务运行': 'Task already running',
'已有账号,去登录': 'Already have an account? Log in',
@@ -161,7 +172,9 @@ export const legacyUiTextEnUS: Record<string, string> = {
'接口在线': 'Endpoints online',
'接口失败': 'Endpoint failed',
'接口请求失败': 'Endpoint request failed',
'接口路径': 'Endpoint path',
'提示': 'Info',
'操作失败': 'Operation failed',
'控制台不混入其他配置或假数据。': 'The console does not mix in unrelated configuration or mock data.',
'控制台发生错误,请刷新页面重试。': 'The console encountered an error. Please refresh and try again.',
'控制台渲染错误': 'Console render error',
@@ -172,8 +185,8 @@ export const legacyUiTextEnUS: Record<string, string> = {
'提示词': 'Prompts',
'搜索日志正文': 'Search log text',
'搜索名称、描述、元数据等': 'Search name, description, metadata, and more',
'搜索': 'Search',
'搜索供应商': 'Search provider',
'搜索': 'Search',
'搜索深度': 'Search depth',
'搜索用户、邮箱、角色': 'Search users, email, or role',
'数据源': 'Datasources',
@@ -196,6 +209,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'采集时间': 'Collected at',
'采集已取消': 'Collection cancelled',
'采集失败': 'Collection failed',
'采集成功': 'Collection succeeded',
'采集完成': 'Collection complete',
'采集中': 'Collecting',
'采集管理': 'Collection Management',
@@ -256,6 +270,8 @@ export const legacyUiTextEnUS: Record<string, string> = {
'显示 LLM API Key / Service Token': 'Show LLM API Key / Service Token',
'显示': 'Display',
'智能星球': 'Intelligent Planet',
'智能星球计划': 'Intelligent Planet Program',
'智能星球计划品牌标识': 'Intelligent Planet Program brand banner',
'智能星球内容配置': 'Intelligent Planet content configuration',
'智能星球配置详情': 'Intelligent Planet configuration details',
'智能星球内容': 'Planet Content',
@@ -301,9 +317,10 @@ export const legacyUiTextEnUS: Record<string, string> = {
'源属性': 'Source attributes',
'源详情': 'Source details',
'源健康': 'Source health',
'来源': 'Source',
'区域': 'Region',
'按数据源': 'By datasource',
'按类型': 'By type',
'按数据源': 'Source',
'按类型': 'Type',
'排序': 'Sort order',
'单条添加': 'Add one item',
'单源配置': 'Single-source configuration',
@@ -328,8 +345,10 @@ export const legacyUiTextEnUS: Record<string, string> = {
'结果': 'Results',
'系统告警列表': 'System alert list',
'系统告警': 'System Alerts',
'系统数据库日志': 'System database logs',
'系统日志': 'System Logs',
'系统显示': 'System display',
'系统名称': 'System name',
'系统设置': 'System Settings',
'系统总览与实时态势': 'System overview and realtime status',
'设置': 'Settings',
@@ -343,6 +362,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'选择左侧父级后编辑它的子配置。': 'Select a parent item on the left to edit its child configuration.',
'选择日志源后读取快照。': 'Select a log source to read its snapshot.',
'选择新闻直播源': 'Select news stream source',
'层级': 'Level',
'纬度': 'Latitude',
'经度': 'Longitude',
'统计': 'Stats',
@@ -354,6 +374,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'网络': 'Network',
'自定义': 'Custom',
'自定义源': 'Custom sources',
'演示模式': 'Demo mode',
'自治系统统计': 'Autonomous system stats',
'自动回退': 'Auto fallback',
'英文标题': 'English title',
@@ -362,6 +383,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'英文分类': 'English category',
'草稿': 'Draft',
'警告': 'Warning',
'警告告警通知': 'Warning alerts',
'设备统计': 'Device stats',
'触发全部': 'Trigger all',
'触发采集': 'Trigger collection',
@@ -375,6 +397,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'请稍后重试': 'Please try again later',
'连接失败': 'Connection failed',
'连接中': 'Connecting',
'正在连接': 'Connecting',
'连接测试': 'Connection test',
'连通性': 'Connectivity',
'连通正常': 'Connectivity normal',
@@ -390,6 +413,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'返回上一级详情': 'Back to parent details',
'返回列表': 'Back to list',
'通知策略': 'Notification policy',
'通知邮箱': 'Notification email',
'配置错误': 'Configuration error',
'配置源': 'Configuration source',
'重要度与健康策略': 'Importance and health policy',
@@ -435,11 +459,17 @@ export const legacyUiTextEnUS: Record<string, string> = {
'Docker 不可用': 'Docker unavailable',
'GPU 集群': 'GPU clusters',
'HTTP 失败': 'HTTP failed',
'Anthropic 版本': 'Anthropic version',
'Earth 左上角品牌实际渲染预览': 'Rendered preview of the top-left Earth brand',
'LLM 基础地址': 'LLM base URL',
'Logo 替代文本': 'Logo alt text',
'当前分区没有可用后端能力,控制台不混入其他配置或假数据。': 'This section has no backend capability yet; the console does not mix in unrelated configuration or fake data.',
'当前分区没有可配置项。': 'This section has no configurable items.',
'当前模块暂无数据': 'No data in this module',
'当前已是默认': 'Already default',
'当前已是默认频道': 'Already the default channel',
'当前配置可以连通。': 'Current configuration can connect.',
'当前配置连通性检查失败。': 'Configuration connectivity check failed.',
'当前采集源没有可查看的历史版本。': 'This collection source has no historical versions.',
'待定位': 'Pending location',
'后端能力未提供': 'Backend capability unavailable',
@@ -454,6 +484,11 @@ export const legacyUiTextEnUS: Record<string, string> = {
'只重启前端开发服务,页面会短暂不可用,恢复后自动刷新。': 'Restart only the frontend dev service. The page will be briefly unavailable and refresh after recovery.',
'失败时页面仍可操作': 'Page remains usable when requests fail',
'打开': 'Open',
'观测台': 'Observatory',
'概览': 'Overview',
'概览摘要': 'Overview summary',
'告警详情': 'Alert details',
'查看智能星球图层缓存': 'View Intelligent Planet layer cache',
'描述来源属性,不是媒体来源名;多个标签用逗号分隔,例如 business_news, ecommerce, china。': 'Describe source attributes, not media source names. Separate multiple tags with commas, for example business_news, ecommerce, china.',
'浏览采集结果、筛选数据源和查看原始元数据。': 'Browse collected results, filter datasources, and inspect raw metadata.',
'管理采集器、采集调度和采集历史 / 快照。': 'Manage collectors, collection schedules, and collection history / snapshots.',
@@ -462,6 +497,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'管理系统显示、通知策略、安全策略和 SMTP 邮件。': 'Manage system display, notification policy, security policy, and SMTP email.',
'统一查看内置源、自定义源、实时源与任务状态,保留触发、启停和连接状态入口。': 'View built-in, custom, and realtime sources plus task status in one place, with trigger, start/stop, and connectivity entries.',
'严重告警': 'Critical alerts',
'严重告警通知': 'Critical alerts',
'查看日志源、读取快照、复制原始输出,按控制台阅读方式组织。': 'View log sources, read snapshots, and copy raw output in a console-friendly layout.',
'查看日志源、按级别/日期/搜索条件读取快照,并复制原始输出。': 'View log sources, read snapshots by level, date, and search filters, then copy raw output.',
'显示系统告警记录和统计,不混入 BGP 概览以外的数据。': 'Shows system alert records and stats without mixing in data outside the BGP overview.',
@@ -476,6 +512,39 @@ export const legacyUiTextEnUS: Record<string, string> = {
'仅展示数据源相关接口,不混入其他设置对象。': 'Only datasource endpoints are shown; unrelated settings objects are not mixed in.',
'仅展示智能星球品牌、边界构建和电视内容配置。': 'Only Intelligent Planet branding, boundary builds, and TV content configuration are shown.',
'读取快照': 'Read snapshot',
'触发全部数据源': 'Trigger all datasources',
'数据源任务,暂无任务': 'Datasource tasks, no tasks',
'生成 BGP AI 简报': 'Generate BGP AI brief',
'生成系统告警 AI 简报': 'Generate system alert AI brief',
'生成态势告警 AI 简报': 'Generate situational alert AI brief',
'生成简报': 'Generate brief',
'模块': 'Module',
'模块分区': 'Module sections',
'指标': 'Metric',
'支持 png, jpg, jpeg, webp, svg': 'Supports png, jpg, jpeg, webp, svg',
'协议适配': 'Protocol adapter',
'代理 Token': 'Proxy token',
'代理地址': 'Proxy URL',
'设为默认': 'Set as default',
'输入新的 LLM API Key': 'Enter new LLM API key',
'输入新的代理 Token': 'Enter new proxy token',
'显示LLM API Key': 'Show LLM API key',
'隐藏LLM API Key': 'Hide LLM API key',
'显示代理 Token': 'Show proxy token',
'隐藏代理 Token': 'Hide proxy token',
'显示API Key': 'Show API key',
'隐藏API Key': 'Hide API key',
'最大输出 Tokens': 'Max output tokens',
'超时(秒)': 'Timeout (seconds)',
'拖动调整详情宽度': 'Drag to resize details',
'异常': 'Anomaly',
'播放类型': 'Playback type',
'上次状态': 'Last status',
'上次执行': 'Last run',
'需要凭证': 'Requires credentials',
'凭证提供方': 'Credential provider',
'凭证教程': 'Credential guide',
'接口地址': 'Endpoint URL',
'输入要发送给 AI 的内容': 'Enter content to send to AI',
'发送': 'Send',
'会话': 'Conversation',
@@ -485,6 +554,22 @@ export const legacyUiTextEnUS: Record<string, string> = {
'目标': 'Objective',
'约束': 'Constraints',
'描述': 'Description',
'每日摘要': 'Daily digest',
'Logo 地址': 'Logo URL',
'API 基础地址': 'API base URL',
'Firecrawl 抓取路径': 'Firecrawl scrape path',
'Firecrawl 搜索路径': 'Firecrawl search path',
'SearXNG 分类': 'SearXNG categories',
'SerpAPI 引擎': 'SerpAPI engine',
'OCR 基础地址': 'OCR base URL',
'OCR 供应商': 'OCR provider',
'包含答案': 'Include answer',
'包含原始内容': 'Include raw content',
'包含正文': 'Include text',
'输入新的 OCR API Key': 'Enter new OCR API key',
'输入新的 WebSearch API Key': 'Enter new WebSearch API key',
'每个搜索 provider 有独立 API 与高级参数': 'Each search provider has its own API and advanced parameters',
'OCR provider、模型、语言和文件限制': 'OCR provider, model, language, and file limits',
'优先级': 'Priority',
'边界状态': 'Boundary status',
'边界构建': 'Boundary build',
@@ -518,6 +603,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'重启 PostgreSQL 和 Redis 容器,前端页面保持在线。': 'Restart the PostgreSQL and Redis containers while the frontend stays online.',
'重启前后端和相关服务,页面会短暂不可用,恢复后自动刷新。': 'Restart frontend, backend, and related services. The page will be briefly unavailable and refresh after recovery.',
'已发送重启指令,正在等待服务进入重启流程。': 'Restart command sent. Waiting for services to enter the restart flow.',
'重启任务正在执行': 'Restart task is running',
'服务已恢复,正在刷新页面。': 'Service recovered. Refreshing the page.',
'前端已恢复,正在刷新页面。': 'Frontend recovered. Refreshing the page.',
'前端正在重启,正在等待页面入口恢复访问。': 'Frontend is restarting. Waiting for the page entry to recover.',
@@ -530,6 +616,7 @@ export const legacyUiTextEnUS: Record<string, string> = {
'导出 JSON': 'Export JSON',
'导出 CSV': 'Export CSV',
'数据详情': 'Data details',
'数据保留天数': 'Data retention days',
'按级别/日期/搜索条件读取快照': 'Read snapshots by level, date, and search filters',
'点击左侧聚合项查看每次发生时间。': 'Click an aggregation on the left to view each occurrence time.',
'管理员敏感操作和安全审计记录。': 'Sensitive admin operations and security audit records.',
@@ -564,8 +651,6 @@ export const legacyUiTextEnUS: Record<string, string> = {
'条结果': 'results',
'筛选': 'Filters',
'共': 'Total',
'智能星球计划': 'Intelligent Planet Plan',
'智能星球计划品牌标识': 'Intelligent Planet Plan branding',
'现实层宇宙全息感知系统': 'Reality-layer holographic awareness system',
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Submarine cables · Computing infrastructure',
'选择/拖入资产': 'Select / drop asset',
@@ -586,4 +671,172 @@ export const legacyUiTextEnUS: Record<string, string> = {
'卫星': 'Satellite',
'算力': 'Compute',
'媒体': 'Media',
'参考': 'Reference',
'参考链接': 'Reference link',
'参考链接不参与抓取': 'Reference links are not fetched',
'亚太': 'Asia-Pacific',
'中国': 'China',
'中东与非洲': 'Middle East and Africa',
'全球': 'Global',
'欧洲': 'Europe',
'美国': 'United States',
'美洲': 'Americas',
'36氪': '36Kr',
'亿邦动力': 'Ebrun',
'商务数据中心': 'MOFCOM Data Center',
'商务部电商动态': 'MOFCOM E-Commerce',
'国家统计局数据发布': 'National Bureau of Statistics',
'电商物流指数': 'China E-Commerce Logistics Index',
'综合资讯': 'General',
'文章资讯': 'Articles',
'最新快讯': 'Newsflash',
'动态内容': 'Updates',
'零售': 'Retail',
'服务': 'Services',
'商业': 'Business',
'政治': 'Politics',
'金融': 'Finance',
'科技': 'Technology',
'无条目': 'No entries',
'格式错误': 'Format error',
'超时': 'Timeout',
'尚未测试当前源。': 'This source has not been tested yet.',
'来源官网、栏目页或报告页,不作为抓取入口。': 'Official site, section page, or report page. It is not used as the fetch entry.',
'默认类型': 'Default category',
'重要度权重': 'Importance weight',
'抓取间隔(分钟)': 'Fetch interval (minutes)',
'失败阈值': 'Failure threshold',
'熔断冷却(分钟)': 'Circuit-breaker cooldown (minutes)',
'熔断开关': 'Circuit breaker',
'组名 / 来源名': 'Group / source name',
'组类型': 'Group type',
'新闻数量': 'News count',
'内容来源': 'Content source',
'原文链接': 'Original URL',
'缺省区域': 'Default region',
'Feed 子项': 'Feed entries',
'每个子项都是一个真实 RSS/Atom/Aggregated 抓取入口,可单独启停和设置默认新闻类型。': 'Each entry is a real RSS/Atom/Aggregated fetch entry and can be enabled, disabled, and assigned its own default category.',
'添加一个真实 RSS/Atom/Aggregated 地址后才能抓取。': 'Add a real RSS/Atom/Aggregated URL before fetching.',
'启用 Feed': 'Enable feed',
'停用 Feed': 'Disable feed',
'用于新闻排序、抓取频率、超时和熔断控制。': 'Used for news ranking, fetch frequency, timeout, and circuit-breaker control.',
'频道身份、状态和排序。': 'Channel identity, status, and sort order.',
'播放地址、封面、YouTube 信息和其他高级字段。': 'Playback URL, cover image, YouTube metadata, and other advanced fields.',
'配置表单': 'Configuration form',
'常用字段直接编辑;复杂对象保留结构化子字段,不再把整条记录只丢进 JSON。': 'Common fields are edited directly; complex objects keep structured child fields instead of sending the whole record into JSON only.',
'查看完整 JSON': 'View full JSON',
'原始数据': 'Raw data',
'日志正文': 'Log text',
'正在加载数据': 'Loading data',
'同步中': 'Syncing',
'就绪': 'Ready',
'进度': 'Progress',
'取消任务': 'Cancel task',
'清空已结束队列项': 'Clear completed queue items',
'暂无数据源任务': 'No datasource tasks',
'进行': 'Running',
'跳过': 'Skipped',
'可选模型': 'Available models',
'快照版本': 'Snapshot version',
'快照摘要': 'Snapshot summary',
'采集任务': 'Collection task',
'当前没有运行中的任务。': 'No task is currently running.',
'没有可用 WebSearch 证据,已保留默认教程。': 'No WebSearch evidence is available, so the default guide was kept.',
'首版只支持顶层为数组的 JSON 文件;取消不会清空当前新闻组详情。': 'The first version only supports JSON files whose top-level value is an array. Canceling does not clear the current news group details.',
'选择文件': 'Select file',
'实时启停': 'Start / stop realtime source',
'采集位置': 'Collect location',
'拉取详情': 'Fetch details',
'预览': 'Preview',
'预览映射': 'Preview mapping',
'到采集管理编辑': 'Edit in Collection Management',
'测试启用源': 'Test enabled sources',
'重置默认新闻源': 'Reset default news sources',
'重置新闻源': 'Reset news sources',
'删除数据': 'Delete data',
'清理数据源缓存': 'Clear datasource cache',
'停止删除': 'Stop deletion',
'停止已选': 'Stop selected',
'触发已选': 'Trigger selected',
'已取消新增直播源': 'New stream source cancelled',
'已取消新增新闻源': 'New news source cancelled',
'已取消新增配置': 'New configuration cancelled',
'已恢复当前项': 'Current item restored',
'表单已恢复到加载时状态。': 'The form has been restored to its loaded state.',
'当前分区未找到该数据源': 'Datasource not found in the current section',
'可切回内置源或刷新后再查看。': 'Switch back to built-in sources or refresh before viewing it again.',
'无法重试': 'Cannot retry',
'当前列表中没有找到对应数据源。': 'The matching datasource was not found in the current list.',
'读取凭证失败': 'Failed to read credentials',
'JSON 格式错误': 'Invalid JSON',
'请修正高级编辑内容后再保存。': 'Fix the advanced editor content before saving.',
'请修正映射内容后再保存。': 'Fix the mapping content before saving.',
'请修正映射内容后再预览。': 'Fix the mapping content before previewing.',
'新闻源配置不完整': 'News source configuration is incomplete',
'参考链接不能启用抓取,请改为 RSS/Atom/Aggregated 或关闭启用。': 'Reference links cannot be fetched. Use RSS/Atom/Aggregated or disable fetching.',
'RSS/Atom/Aggregated 新闻源需要至少一个 Feed 子项。': 'RSS/Atom/Aggregated news sources need at least one feed entry.',
'新闻标题不能为空。': 'News title is required.',
'坐标必须是数字。': 'Coordinates must be numeric.',
'坐标超出范围。': 'Coordinates are out of range.',
'新闻源缺少已启用的 Feed 子项。': 'The news source has no enabled feed entries.',
'OCR 配置': 'OCR configuration',
'个入口': 'entries',
'条消息': 'messages',
'发送一条消息开始测试 AI 链路。': 'Send a message to test the AI link.',
'数据源健康': 'Datasource health',
'链路探测': 'Link smoke test',
'AI 链路探测': 'AI link smoke test',
'BGP 告警态势简报': 'BGP alert situational brief',
'总结当前告警的主要风险、优先级与建议动作。': 'Summarize the main risks, priorities, and recommended actions for the current alerts.',
'结论要简洁\n优先给出操作建议': 'Keep conclusions concise\nPrioritize operational recommendations',
'出现新的高危告警\n部分观测站最近 24h 事件增多\n请先给出风险摘要再列出建议动作。': 'New high-risk alerts appeared\nSome observatories have more events in the last 24h\nStart with a risk summary, then list recommended actions.',
'采集器健康检查说明': 'Collector health check guide',
'判断当前采集器失败是否属于上游接口失效、限流、结构变更或临时波动。': 'Determine whether the current collector failure is caused by upstream outage, rate limiting, schema drift, or temporary fluctuation.',
'区分事实与推断\n先给排障优先级': 'Separate facts from inference\nPrioritize troubleshooting first',
'最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定\n请帮我先做排障优先级排序。': 'The last 3 tasks failed\nSome datasources have slower responses\nA few endpoints return unstable structures\nPlease prioritize troubleshooting.',
'验证 backend -> aiprovider -> model provider 调用链路是否正常。': 'Verify whether the backend -> aiprovider -> model provider call chain works.',
'输出简洁\n包含一段明确结论': 'Keep output concise\nInclude a clear conclusion',
'当前从 Playground 发起测试,希望确认 provider 配置和返回结构正常,请直接给我链路结论。': 'This Playground test should confirm provider configuration and response structure. Give the link conclusion directly.',
'无障碍标签': 'Accessibility label',
'版本': 'Version',
'版本号来自当前系统版本,只读展示,不会随关于信息提交。': 'The version comes from the current system version. It is read-only and is not submitted with About information.',
'边界': 'Boundary',
'构建': 'Build',
'内置': 'Built-in',
'Live 新闻': 'Live News',
'直播加载中': 'Live stream loading',
'个频道': 'channels',
'尚未同步': 'Not synced yet',
'提供方': 'Provider',
'地区': 'Region',
'语言': 'Language',
'可回退': 'Fallback',
'备注': 'Notes',
'播放流地址': 'Stream URL',
'嵌入地址': 'Embed URL',
'封面地址': 'Poster URL',
'YouTube 视频 ID': 'YouTube video ID',
'YouTube 频道': 'YouTube channel',
'Earth TV 实际渲染预览': 'Rendered Earth TV preview',
'商业新闻': 'Business news',
'连通不稳定': 'Unstable connectivity',
'官方数据': 'Official data',
'来源类型': 'Source type',
'条新闻。': 'news items.',
'单条标题只在这里展示,左侧保持来源/组聚合。': 'Only item titles are shown here; the left side keeps source/group aggregation.',
'可以单条添加或上传 JSON 数组导入。': 'You can add items one by one or upload a JSON array.',
'是': 'Yes',
'否': 'No',
'采集间隔(分钟)': 'Collection interval (minutes)',
'无时间': 'No timestamp',
'自动刷新': 'Auto refresh',
'会话超时(分钟)': 'Session timeout (minutes)',
'密码策略': 'Password policy',
'主机': 'Host',
'端口': 'Port',
'用户名': 'Username',
'使用 TLS': 'Use TLS',
'发件邮箱': 'Sender email',
'发件人名称': 'Sender name',
'个历史快照,选择后查看该版本详情。': 'historical snapshots. Select one to view that version.',
}

View File

@@ -141,6 +141,7 @@ export const zhCN = {
},
},
docs: {
brandMark: '智',
brandTitle: '智能星球文档',
brandSubtitle: '开发者和用户手册',
documentUnavailable: '文档不可用',
@@ -354,6 +355,7 @@ export const enUS = {
},
},
docs: {
brandMark: 'IP',
brandTitle: 'Intelligent Planet Docs',
brandSubtitle: 'Developer & User Guide',
documentUnavailable: 'Document unavailable',

View File

@@ -308,7 +308,7 @@ export default function Docs() {
<main className="docs-page" data-theme={effectiveTheme}>
<aside className="docs-sidebar" aria-label="Documentation navigation">
<Link className="docs-brand" to="/docs">
<span className="docs-brand__mark"></span>
<span className="docs-brand__mark">{t('docs.brandMark')}</span>
<span>
<span className="docs-brand__title">
{t('docs.brandTitle')}

View File

@@ -2,23 +2,10 @@ import axios from 'axios'
import { useEffect, useState, type FormEvent } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell'
import { describeApiError } from '../../i18n/api-errors'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
interface ErrorBody {
response?: {
data?: { detail?: string | { code?: string; message?: string; retry_after_seconds?: number } }
}
}
function extractDetail(error: unknown, fallback: string): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return fallback
}
function ForgotPassword() {
const { t } = useTranslation()
const [step, setStep] = useState<'request' | 'reset'>('request')
@@ -45,7 +32,7 @@ function ForgotPassword() {
setCooldown(60)
setFeedback({ tone: 'success', text: t('auth.recoveryCodeSent') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -62,7 +49,7 @@ function ForgotPassword() {
setNewPassword('')
setFeedback({ tone: 'success', text: t('auth.passwordResetSuccess') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -75,7 +62,7 @@ function ForgotPassword() {
setCooldown(60)
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
}
}

View File

@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router-dom'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
import { describeApiValue } from '../../i18n/api-errors'
import { useAuthStore } from '../../stores/auth'
interface LoginError {
@@ -36,8 +37,7 @@ function Login() {
navigate(`/verify-email${email}`)
return
}
const fallback = typeof detail === 'string' ? detail : detail?.message
setFeedback({ tone: 'error', text: fallback || t('auth.loginFailed') })
setFeedback({ tone: 'error', text: describeApiValue(detail, t('auth.loginFailed')) })
} finally {
setLoading(false)
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router-dom'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
import { describeApiError } from '../../i18n/api-errors'
import { useAuthStore } from '../../stores/auth'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -16,14 +17,6 @@ interface ErrorBody {
}
}
function extractDetail(error: unknown, fallback: string): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return fallback
}
function Register() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -53,7 +46,7 @@ function Register() {
setCooldown(RESEND_COOLDOWN_SECONDS)
setFeedback({ tone: 'success', text: t('auth.verificationSent') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -70,7 +63,7 @@ function Register() {
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
navigate('/admin', { replace: true })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -88,7 +81,7 @@ function Register() {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
} finally {
setResending(false)
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell'
import { describeApiError } from '../../i18n/api-errors'
import { useAuthStore } from '../../stores/auth'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -13,14 +14,6 @@ interface ErrorBody {
}
}
function extractDetail(error: unknown, fallback: string): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return fallback
}
function VerifyEmail() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -49,7 +42,7 @@ function VerifyEmail() {
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
navigate('/admin', { replace: true })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -67,7 +60,7 @@ function VerifyEmail() {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
setFeedback({ tone: 'error', text: describeApiError(error, t('common.operationFailed')) })
} finally {
setResending(false)
}