release: bump version to 0.74.0
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
93
frontend/src/i18n/api-errors.ts
Normal file
93
frontend/src/i18n/api-errors.ts
Normal 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)
|
||||
}
|
||||
@@ -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.',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user