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

This commit is contained in:
linkong
2026-06-29 17:04:05 +08:00
parent 19d5ac0fee
commit fbecf30513
41 changed files with 2306 additions and 311 deletions

View File

@@ -1,10 +1,12 @@
import { Suspense, lazy } from 'react'
import { Suspense, lazy, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './stores/auth'
import Login from './pages/Login/Login'
import { AdminErrorBoundary } from './admin/components/AdminErrorBoundary'
import LegacyI18nBridge from './i18n/LegacyI18nBridge'
const Register = lazy(() => import('./pages/Register/Register'))
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
@@ -26,35 +28,43 @@ function isPublicPath(pathname: string) {
}
function App() {
const { t } = useTranslation()
const { token } = useAuthStore()
const { pathname } = useLocation()
const isPublicRoute = isPublicPath(pathname)
useEffect(() => {
document.title = t('app.title')
}, [t])
if (!token && !isPublicRoute) {
return <Login />
}
return (
<Suspense
fallback={(
<div className="app-route-loading">
<div className="app-route-loading__spinner" aria-label="正在加载" />
</div>
)}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
<Route path={EARTH_ROUTE} element={<Earth />} />
<Route path={DOCS_ROUTE} element={<Docs />} />
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
</Routes>
</Suspense>
<>
<LegacyI18nBridge />
<Suspense
fallback={(
<div className="app-route-loading">
<div className="app-route-loading__spinner" aria-label={t('app.routeLoading')} />
</div>
)}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
<Route path={EARTH_ROUTE} element={<Earth />} />
<Route path={DOCS_ROUTE} element={<Docs />} />
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
</Routes>
</Suspense>
</>
)
}

View File

@@ -8,6 +8,7 @@ import {
} from '@tanstack/react-table'
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TableScrollRegion from '../../../components/Scrollbar/TableScrollRegion'
import { Button } from '../ui/button'
@@ -37,11 +38,12 @@ export function DataTable<TData>({
getRowClassName,
selection,
loading = false,
emptyText = '暂无数据',
emptyText,
className = '',
footer,
onRowClick,
}: DataTableProps<TData>) {
const { t } = useTranslation()
const [sorting, setSorting] = useState<SortingState>([])
const memoizedColumns = useMemo(() => columns, [columns])
@@ -74,7 +76,7 @@ export function DataTable<TData>({
<th className="an-data-table__selection-cell">
<input
type="checkbox"
aria-label="选择当前可见数据"
aria-label={t('common.selectVisibleRows')}
checked={allVisibleSelected}
disabled={!visibleSelectableIds.length}
ref={(element) => {
@@ -114,7 +116,7 @@ export function DataTable<TData>({
<td colSpan={columnCount}>
<div className="an-data-table__state">
<span className="an-spinner" />
{t('common.loading')}
</div>
</td>
</tr>
@@ -131,7 +133,7 @@ export function DataTable<TData>({
<td className="an-data-table__selection-cell">
<input
type="checkbox"
aria-label={selection.getCheckboxLabel?.(row.original) || '选择行'}
aria-label={selection.getCheckboxLabel?.(row.original) || t('common.selectRow')}
checked={selection.selectedRowIds.has(row.id)}
disabled={selection.isRowSelectable ? !selection.isRowSelectable(row.original) : false}
onClick={(event) => event.stopPropagation()}
@@ -149,7 +151,7 @@ export function DataTable<TData>({
) : (
<tr>
<td colSpan={columnCount}>
<div className="an-data-table__state">{emptyText}</div>
<div className="an-data-table__state">{emptyText || t('common.noData')}</div>
</td>
</tr>
)}
@@ -173,18 +175,19 @@ export function DataTablePager({
total: number
onPageChange: (page: number) => void
}) {
const { t } = useTranslation()
const totalPages = Math.max(1, Math.ceil(total / pageSize))
return (
<div className="an-data-table__pager">
<span>
{page} / {totalPages} {total.toLocaleString()}
{t('common.page', { page, totalPages, total: total.toLocaleString() })}
</span>
<div className="an-data-table__pager-actions">
<Button size="sm" variant="subtle" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
{t('common.previousPage')}
</Button>
<Button size="sm" variant="subtle" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
{t('common.nextPage')}
</Button>
</div>
</div>

View File

@@ -1,18 +1,22 @@
import {
ChevronDown,
Languages,
LogOut,
Menu,
Moon,
Monitor,
Search,
Settings,
Sun,
X,
} from 'lucide-react'
import { type FocusEvent, type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import packageJson from '../../../../package.json'
import Scrollbar from '../../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl'
import { localeOptions, useLocale, type SupportedLocale } from '../../../i18n/locale'
import { useAuthStore } from '../../../stores/auth'
import { useAdminTheme, type AdminThemeMode } from '../../design/theme'
import { cn } from '../../utils'
@@ -28,6 +32,8 @@ export function AdminLayout({ children }: { children: ReactNode }) {
const location = useLocation()
const navigate = useNavigate()
const adminSearch = useAdminSearch()
const { t } = useTranslation()
const { locale, setLocale } = useLocale()
const { user, logout } = useAuthStore()
const { mode, setMode } = useAdminTheme()
const [collapsed, setCollapsed] = useState(false)
@@ -35,25 +41,39 @@ export function AdminLayout({ children }: { children: ReactNode }) {
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
const [searchQuery, setSearchQuery] = useState('')
const [searchOpen, setSearchOpen] = useState(false)
const [preferencesOpen, setPreferencesOpen] = useState(false)
const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0)
const menuViewportRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const isSuperAdmin = user?.role === 'super_admin'
const username = user?.username || '-'
const userInitial = username.trim().charAt(0).toUpperCase() || '?'
const preferencesLabel = preferencesOpen ? t('admin.collapsePreferences') : t('admin.expandPreferences')
const visibleRoutes = useMemo(() => getVisibleAdminRoutes(isSuperAdmin), [isSuperAdmin])
const navGroups = useMemo(() => {
return adminRouteGroups.map((group) => ({
...group,
children: visibleRoutes.filter((route) => route.group === group.key),
label: t(group.labelKey),
children: visibleRoutes
.filter((route) => route.group === group.key)
.map((route) => ({ ...route, label: t(route.labelKey) })),
})).filter((group) => group.children.length > 0)
}, [visibleRoutes])
}, [t, visibleRoutes])
const selectedKey = location.pathname === '/admin/' ? '/admin' : location.pathname.replace(/\/$/, '')
const activeRoute = visibleRoutes.find((route) => route.path === selectedKey)
const activeRouteLabel = activeRoute ? t(activeRoute.labelKey) : ''
const searchResults = useMemo(() => adminSearch.search(searchQuery), [adminSearch, searchQuery])
const themeOptions = useMemo(() => [
{ value: 'light' as const, label: '浅色', title: '浅色', icon: <Sun /> },
{ value: 'system' as const, label: '系统', title: '跟随系统', icon: <Monitor /> },
{ value: 'dark' as const, label: '深色', title: '深色', icon: <Moon /> },
], [])
{ value: 'light' as const, label: t('common.themeLight'), title: t('common.themeLight'), icon: <Sun /> },
{ value: 'system' as const, label: t('common.themeSystem'), title: t('common.themeFollowSystem'), icon: <Monitor /> },
{ value: 'dark' as const, label: t('common.themeDark'), title: t('common.themeDark'), icon: <Moon /> },
], [t])
const languageOptions = useMemo(() => localeOptions.map((option) => ({
value: option.value,
label: t(option.labelKey),
title: t(option.titleKey),
icon: <Languages />,
})), [t])
const updateOpenKeys = (nextKeys: string[]) => {
cachedOpenKeys = nextKeys
@@ -116,15 +136,15 @@ export function AdminLayout({ children }: { children: ReactNode }) {
event.stopPropagation()
setCollapsed((value) => !value)
}}
aria-label={collapsed ? '展开菜单' : '折叠菜单'}
title={collapsed ? '展开菜单' : '折叠菜单'}
title={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
aria-label={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
>
{collapsed ? <Menu size={18} /> : <X size={18} />}
</Button>
{!collapsed ? (
<div className="admin__brand-copy">
<span className="admin__brand-text"></span>
<span className="admin__brand-subtitle"></span>
<span className="admin__brand-text">{t('admin.brandTitle')}</span>
<span className="admin__brand-subtitle">{t('admin.brandSubtitle')}</span>
</div>
) : null}
</div>
@@ -186,36 +206,61 @@ export function AdminLayout({ children }: { children: ReactNode }) {
{!collapsed ? (
<div className="admin__account">
<div className="admin__account-row">
<div>
<strong>Hi, {user?.username || '-'}</strong>
<div className="admin__account-row admin__account-row--primary">
<div className="admin__account-profile">
<span className="admin__account-avatar" aria-hidden="true">{userInitial}</span>
<div>
<strong>{t('admin.greeting', { name: username })}</strong>
<span>{t('admin.version')} v{packageJson.version}</span>
</div>
</div>
<div className="admin__account-actions">
<Button
size="icon"
variant="ghost"
className={cn('admin__account-preferences', preferencesOpen && 'is-active')}
onClick={() => setPreferencesOpen((value) => !value)}
title={preferencesLabel}
aria-label={preferencesLabel}
aria-expanded={preferencesOpen}
>
<Settings size={15} />
</Button>
<Button
size="icon"
variant="ghost"
className="admin__account-logout"
onClick={() => {
logout()
navigate('/login')
}}
title={t('admin.logout')}
aria-label={t('admin.logout')}
>
<LogOut size={15} />
</Button>
</div>
<Button
size="icon"
variant="ghost"
className="admin__account-logout"
onClick={() => {
logout()
navigate('/login')
}}
aria-label="退出登录"
title="退出登录"
>
<LogOut size={15} />
</Button>
</div>
<div className="admin__account-row">
<span></span>
<strong>v{packageJson.version}</strong>
<div className={cn('admin__preferences-drawer', preferencesOpen && 'is-open')} aria-hidden={!preferencesOpen}>
<div className="admin__preferences-panel">
<SegmentedControl<SupportedLocale>
ariaLabel={t('admin.languageControl')}
className="admin__language-control admin__language-control--sider"
options={languageOptions}
scale={0.86}
value={locale}
onChange={setLocale}
/>
<SegmentedControl<AdminThemeMode>
ariaLabel={t('admin.themeControl')}
className="admin__theme-control admin__theme-control--sider"
options={themeOptions}
scale={0.86}
value={mode}
onChange={setMode}
/>
</div>
</div>
<SegmentedControl<AdminThemeMode>
ariaLabel="控制台主题"
className="admin__theme-control admin__theme-control--sider"
options={themeOptions}
scale={0.72}
value={mode}
onChange={setMode}
/>
</div>
) : null}
</>
@@ -250,22 +295,22 @@ export function AdminLayout({ children }: { children: ReactNode }) {
{mobileNavOpen ? (
<div className="admin__mobile-nav">
<div className="admin__mobile-nav-panel">{nav}</div>
<button className="admin__mobile-nav-backdrop" type="button" aria-label="关闭导航" onClick={() => setMobileNavOpen(false)} />
<button className="admin__mobile-nav-backdrop" type="button" aria-label={t('admin.closeNav')} onClick={() => setMobileNavOpen(false)} />
</div>
) : null}
<main className="admin__content">
<header className="admin__topbar">
<Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label="打开导航" title="打开导航">
<Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label={t('admin.openNav')} title={t('admin.openNav')}>
<Menu size={18} />
</Button>
<div className="admin__search" onBlur={handleSearchBlur}>
<Search className="admin__search-icon" size={16} />
<input
ref={searchInputRef}
aria-label="搜索功能、配置和文字"
aria-label={t('admin.search.label')}
autoComplete="off"
value={searchQuery}
placeholder={activeRoute ? `搜索功能、配置和文字,当前:${activeRoute.label}` : '搜索功能、配置和文字'}
placeholder={activeRouteLabel ? `${t('admin.search.placeholder')}${t('admin.search.current', { label: activeRouteLabel })}` : t('admin.search.placeholder')}
onChange={(event) => {
setSearchQuery(event.target.value)
setSearchOpen(true)
@@ -275,7 +320,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
onKeyDown={handleSearchKeyDown}
/>
{searchOpen ? (
<div className="admin__search-results" role="listbox" aria-label="Admin 搜索结果">
<div className="admin__search-results" role="listbox" aria-label={t('admin.search.results')}>
{searchResults.length > 0 ? searchResults.map((target, index) => {
const ResultIcon = target.icon || Search
return (
@@ -297,7 +342,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
</button>
)
}) : (
<div className="admin__search-empty">{adminSearch.loading ? '正在加载搜索索引…' : '没有找到匹配内容'}</div>
<div className="admin__search-empty">{adminSearch.loading ? t('admin.search.loading') : t('admin.search.empty')}</div>
)}
</div>
) : null}

View File

@@ -1,6 +1,7 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import Scrollbar from '../../../components/Scrollbar/Scrollbar'
import { Button } from './button'
@@ -15,6 +16,8 @@ interface DialogProps {
}
export function Dialog({ open, onOpenChange, title, description, children, footer, width }: DialogProps) {
const { t } = useTranslation()
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
@@ -30,7 +33,7 @@ export function Dialog({ open, onOpenChange, title, description, children, foote
) : null}
</div>
<DialogPrimitive.Close asChild>
<Button size="icon" variant="ghost" aria-label="关闭" title="关闭">
<Button size="icon" variant="ghost" aria-label={t('common.close')} title={t('common.close')}>
<X size={16} />
</Button>
</DialogPrimitive.Close>
@@ -60,12 +63,16 @@ export function ConfirmDialog({
onOpenChange,
title,
description,
confirmLabel = '确认',
cancelLabel = '取消',
confirmLabel,
cancelLabel,
danger = false,
loading = false,
onConfirm,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const resolvedCancelLabel = cancelLabel || t('common.cancel')
const resolvedConfirmLabel = confirmLabel || t('common.confirm')
return (
<Dialog
open={open}
@@ -76,15 +83,15 @@ export function ConfirmDialog({
footer={(
<>
<Button variant="subtle" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
{resolvedCancelLabel}
</Button>
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} loading={loading}>
{confirmLabel}
{resolvedConfirmLabel}
</Button>
</>
)}
>
<span className="sr-only">{description || '请确认本次操作。'}</span>
<span className="sr-only">{description || t('common.confirm')}</span>
</Dialog>
)
}

View File

@@ -1,6 +1,7 @@
import * as ToastPrimitive from '@radix-ui/react-toast'
import { X } from 'lucide-react'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
type ToastTone = 'default' | 'success' | 'error'
@@ -18,6 +19,7 @@ interface ToastContextValue {
const ToastContext = createContext<ToastContextValue | null>(null)
export function ToastProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const [items, setItems] = useState<ToastItem[]>([])
const toast = useCallback((item: Omit<ToastItem, 'id' | 'tone'> & { tone?: ToastTone }) => {
@@ -46,7 +48,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{item.description}
</ToastPrimitive.Description>
) : null}
<ToastPrimitive.Close className="an-toast__close" aria-label="关闭通知" title="关闭通知">
<ToastPrimitive.Close className="an-toast__close" aria-label={t('common.close')} title={t('common.close')}>
<X size={14} />
</ToastPrimitive.Close>
</ToastPrimitive.Root>

View File

@@ -4,6 +4,7 @@ import axios from 'axios'
import { Edit, Plus, Search, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { useAuthStore } from '../../stores/auth'
import { DataTable } from '../components/data-table/DataTable'
@@ -25,28 +26,17 @@ interface User {
created_at: string
}
const userSchema = z.object({
username: z.string().min(1, '请输入用户名'),
email: z.string().email('请输入有效邮箱'),
password: z.string().optional(),
role: z.string().min(1, '请选择角色'),
gatekeeper_groups: z.array(z.string()).optional(),
})
interface UserFormValues {
username: string
email: string
password?: string
role: string
gatekeeper_groups?: string[]
}
type UserFormValues = z.infer<typeof userSchema>
const roleValues = ['super_admin', 'admin', 'operator', 'viewer'] as const
const roleOptions = [
{ value: 'super_admin', label: '超级管理员' },
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '操作员' },
{ value: 'viewer', label: '只读用户' },
]
const gatekeeperOptions = [
{ value: 'docs_user', label: '文档:用户文档' },
{ value: 'docs_developer', label: '文档:开发文档' },
{ value: 'docs_admin', label: '文档:管理/运维文档' },
]
const gatekeeperValues = ['docs_user', 'docs_developer', 'docs_admin'] as const
function roleTone(role: string) {
if (role === 'super_admin') return 'red'
@@ -56,15 +46,8 @@ function roleTone(role: string) {
return 'default'
}
function roleLabel(role: string) {
return roleOptions.find((option) => option.value === role)?.label || role
}
function gatekeeperLabel(group: string) {
return gatekeeperOptions.find((option) => option.value === group)?.label || group
}
export default function Users() {
const { t } = useTranslation()
const { user: currentUser } = useAuthStore()
const { toast } = useToast()
const [users, setUsers] = useState<User[]>([])
@@ -74,6 +57,23 @@ export default function Users() {
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
const [searchText, setSearchText] = useState('')
const isSuperAdmin = currentUser?.role === 'super_admin'
const userSchema = useMemo(() => z.object({
username: z.string().min(1, t('auth.username')),
email: z.string().email(t('auth.email')),
password: z.string().optional(),
role: z.string().min(1, t('users.role')),
gatekeeper_groups: z.array(z.string()).optional(),
}), [t])
const roleOptions = useMemo(() => roleValues.map((value) => ({
value,
label: t(`users.roles.${value}`),
})), [t])
const gatekeeperOptions = useMemo(() => gatekeeperValues.map((value) => ({
value,
label: t(`users.gatekeeper.${value}`),
})), [t])
const roleLabel = (role: string) => roleOptions.find((option) => option.value === role)?.label || role
const gatekeeperLabel = (group: string) => gatekeeperOptions.find((option) => option.value === group)?.label || group
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
@@ -125,17 +125,17 @@ export default function Users() {
if (!isSuperAdmin) delete payload.gatekeeper_groups
if (editingUser) {
await axios.put(`/api/v1/users/${editingUser.id}`, payload)
toast({ tone: 'success', title: '更新成功' })
toast({ tone: 'success', title: t('users.updateSuccess') })
} else {
const createPayload = { ...payload, password: values.password || '' }
await axios.post('/api/v1/users', createPayload)
toast({ tone: 'success', title: '创建成功' })
toast({ tone: 'success', title: t('users.createSuccess') })
}
setModalVisible(false)
void fetchUsers()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
toast({ tone: 'error', title: '操作失败', description: err.response?.data?.detail || '请稍后重试' })
toast({ tone: 'error', title: t('common.operationFailed'), description: err.response?.data?.detail || t('users.retryLater') })
}
}
@@ -143,22 +143,22 @@ export default function Users() {
if (!deleteTarget) return
try {
await axios.delete(`/api/v1/users/${deleteTarget.id}`)
toast({ tone: 'success', title: '删除成功' })
toast({ tone: 'success', title: t('users.deleteSuccess') })
setDeleteTarget(null)
void fetchUsers()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
toast({ tone: 'error', title: '删除失败', description: err.response?.data?.detail || '请稍后重试' })
toast({ tone: 'error', title: t('users.deleteFailed'), description: err.response?.data?.detail || t('users.retryLater') })
}
}
const columns = useMemo<Array<ColumnDef<User>>>(() => [
{ accessorKey: 'id', header: 'ID', size: 80 },
{ accessorKey: 'username', header: '用户名', size: 180 },
{ accessorKey: 'email', header: '邮箱', size: 260 },
{ accessorKey: 'username', header: t('auth.username'), size: 180 },
{ accessorKey: 'email', header: t('auth.email'), size: 260 },
{
accessorKey: 'role',
header: '角色',
header: t('users.role'),
size: 140,
cell: ({ row }) => <Badge tone={roleTone(row.original.role)} title={row.original.role}>{roleLabel(row.original.role)}</Badge>,
},
@@ -175,30 +175,30 @@ export default function Users() {
<Badge key={group} tone={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}>
{gatekeeperLabel(group)}
</Badge>
)) : <Badge tone="slate"></Badge>}
)) : <Badge tone="slate">{t('users.unconfigured')}</Badge>}
</div>
)
},
},
{
accessorKey: 'is_active',
header: '状态',
header: t('users.status'),
size: 120,
cell: ({ row }) => <Badge tone={row.original.is_active ? 'green' : 'red'}>{row.original.is_active ? '活跃' : '禁用'}</Badge>,
cell: ({ row }) => <Badge tone={row.original.is_active ? 'green' : 'red'}>{row.original.is_active ? t('users.active') : t('users.disabled')}</Badge>,
},
{
id: 'actions',
header: '操作',
header: t('users.actions'),
size: 148,
enableSorting: false,
cell: ({ row }) => (
<div className="an-row-actions">
<Button size="sm" variant="ghost" onClick={() => handleEdit(row.original)}><Edit size={14} /></Button>
<Button size="sm" variant="ghost" className="is-danger" onClick={() => setDeleteTarget(row.original)}><Trash2 size={14} /></Button>
<Button size="sm" variant="ghost" onClick={() => handleEdit(row.original)}><Edit size={14} />{t('users.edit')}</Button>
<Button size="sm" variant="ghost" className="is-danger" onClick={() => setDeleteTarget(row.original)}><Trash2 size={14} />{t('common.delete')}</Button>
</div>
),
},
], [])
], [gatekeeperOptions, roleOptions, t])
const filteredUsers = useMemo(() => {
const keyword = searchText.trim().toLowerCase()
@@ -218,8 +218,8 @@ export default function Users() {
<div className="an-page">
<div className="an-page__header">
<div>
<h1></h1>
<p></p>
<h1>{t('admin.routes.users')}</h1>
<p>{t('users.description')}</p>
</div>
<div className="an-toolbar">
<div className="an-search-box">
@@ -227,15 +227,15 @@ export default function Users() {
<Input
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
placeholder="搜索用户、邮箱、角色"
placeholder={t('users.searchPlaceholder')}
/>
{searchText ? (
<Button size="icon" variant="ghost" onClick={() => setSearchText('')} aria-label="清空搜索" title="清空搜索">
<Button size="icon" variant="ghost" onClick={() => setSearchText('')} aria-label={t('users.clearSearch')} title={t('users.clearSearch')}>
<X size={14} />
</Button>
) : null}
</div>
<Button variant="primary" onClick={handleAdd}><Plus size={16} /></Button>
<Button variant="primary" onClick={handleAdd}><Plus size={16} />{t('users.addUser')}</Button>
</div>
</div>
<div className="an-page__body">
@@ -244,40 +244,40 @@ export default function Users() {
</div>
<Dialog
title={editingUser ? '编辑用户' : '添加用户'}
title={editingUser ? t('users.editUser') : t('users.addUser')}
open={modalVisible}
onOpenChange={setModalVisible}
footer={(
<>
<Button variant="subtle" onClick={() => setModalVisible(false)}></Button>
<Button variant="primary" loading={form.formState.isSubmitting} onClick={form.handleSubmit(handleSubmit)}></Button>
<Button variant="subtle" onClick={() => setModalVisible(false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={form.formState.isSubmitting} onClick={form.handleSubmit(handleSubmit)}>{t('users.submit')}</Button>
</>
)}
>
<form className="an-form" onSubmit={form.handleSubmit(handleSubmit)}>
<label className="an-field">
<span></span>
<span>{t('auth.username')}</span>
<Input {...form.register('username')} />
{form.formState.errors.username ? <em>{form.formState.errors.username.message}</em> : null}
</label>
<label className="an-field">
<span></span>
<span>{t('auth.email')}</span>
<Input {...form.register('email')} />
{form.formState.errors.email ? <em>{form.formState.errors.email.message}</em> : null}
</label>
{!editingUser ? (
<label className="an-field">
<span></span>
<span>{t('auth.password')}</span>
<Input type="password" {...form.register('password', { required: true, minLength: 8 })} />
{form.formState.errors.password ? <em> 8 </em> : null}
{form.formState.errors.password ? <em>{t('auth.passwordHint')}</em> : null}
</label>
) : null}
<label className="an-field">
<span></span>
<span>{t('users.role')}</span>
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)} options={roleOptions} />
</label>
<div className="an-field">
<span>Gatekeeper </span>
<span>{t('users.gatekeeperGroups')}</span>
<div className="an-checkbox-list" aria-disabled={!isSuperAdmin}>
{gatekeeperOptions.map((option) => (
<label key={option.value}>
@@ -301,14 +301,14 @@ export default function Users() {
</Dialog>
<ConfirmDialog
title="确认删除"
title={t('users.confirmDelete')}
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
description={`确定要删除用户 ${deleteTarget?.username || ''} 吗?`}
description={t('users.confirmDeleteDescription', { username: deleteTarget?.username || '' })}
danger
confirmLabel="删除"
confirmLabel={t('common.delete')}
onConfirm={() => void handleDelete()}
/>
</AdminLayout>

View File

@@ -17,6 +17,7 @@ import {
export interface AdminRouteItem {
path: string
label: string
labelKey: string
group: string
icon: LucideIcon
keywords: string[]
@@ -26,33 +27,34 @@ export interface AdminRouteItem {
export interface AdminRouteGroup {
key: string
label: string
labelKey: string
icon: LucideIcon
}
export const adminRouteGroups: AdminRouteGroup[] = [
{ key: 'overview', label: '总览', icon: CircleGauge },
{ key: 'collection', label: '采集与数据', icon: HardDrive },
{ key: 'observability', label: '专题观测', icon: AppWindow },
{ key: 'alerts', label: '告警与研判', icon: ShieldAlert },
{ key: 'ops', label: '运维与配置', icon: Settings },
{ key: 'overview', label: '总览', labelKey: 'admin.groups.overview', icon: CircleGauge },
{ key: 'collection', label: '采集与数据', labelKey: 'admin.groups.collection', icon: HardDrive },
{ key: 'observability', label: '专题观测', labelKey: 'admin.groups.observability', icon: AppWindow },
{ key: 'alerts', label: '告警与研判', labelKey: 'admin.groups.alerts', icon: ShieldAlert },
{ key: 'ops', label: '运维与配置', labelKey: 'admin.groups.ops', icon: Settings },
]
export const adminRoutes: AdminRouteItem[] = [
{ path: '/admin', label: '仪表盘', group: 'overview', icon: CircleGauge, keywords: ['dashboard', '总览', '驾驶舱'] },
{ path: '/earth', label: '智能星球', group: 'overview', icon: Globe2, keywords: ['earth', '地球', '智能星球'] },
{ path: '/docs', label: '文档', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] },
{ path: '/datasources', label: '数据源', group: 'collection', icon: Database, keywords: ['datasource', '采集', '目录'] },
{ path: '/data', label: '采集数据', group: 'collection', icon: AppWindow, keywords: ['data', 'records', '采集数据'] },
{ path: '/bgp', label: 'BGP观测', group: 'observability', icon: Network, keywords: ['bgp', '观测', '网络'] },
{ path: '/alerts/system', label: '系统告警', group: 'alerts', icon: AlertTriangle, keywords: ['alert', 'system', '告警'] },
{ path: '/alerts/bgp', label: 'BGP 告警', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] },
{ path: '/alerts/situational', label: '态势告警', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] },
{ path: '/ai', label: 'AI', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] },
{ path: '/earth-content', label: '智能星球内容', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand', 'news', 'rss', '新闻源'] },
{ path: '/collection-management', label: '采集管理', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] },
{ path: '/logs', label: '系统日志', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true },
{ path: '/users', label: '用户管理', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] },
{ path: '/settings', label: '系统设置', group: 'ops', icon: Settings, keywords: ['settings', 'smtp', 'security'] },
{ path: '/admin', label: '仪表盘', labelKey: 'admin.routes.dashboard', group: 'overview', icon: CircleGauge, keywords: ['dashboard', '总览', '驾驶舱'] },
{ path: '/earth', label: '智能星球', labelKey: 'admin.routes.earth', group: 'overview', icon: Globe2, keywords: ['earth', '地球', '智能星球'] },
{ path: '/docs', label: '文档', labelKey: 'admin.routes.docs', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] },
{ path: '/datasources', label: '数据源', labelKey: 'admin.routes.datasources', group: 'collection', icon: Database, keywords: ['datasource', '采集', '目录'] },
{ path: '/data', label: '采集数据', labelKey: 'admin.routes.data', group: 'collection', icon: AppWindow, keywords: ['data', 'records', '采集数据'] },
{ path: '/bgp', label: 'BGP观测', labelKey: 'admin.routes.bgp', group: 'observability', icon: Network, keywords: ['bgp', '观测', '网络'] },
{ path: '/alerts/system', label: '系统告警', labelKey: 'admin.routes.systemAlerts', group: 'alerts', icon: AlertTriangle, keywords: ['alert', 'system', '告警'] },
{ path: '/alerts/bgp', label: 'BGP 告警', labelKey: 'admin.routes.bgpAlerts', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] },
{ path: '/alerts/situational', label: '态势告警', labelKey: 'admin.routes.situationalAlerts', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] },
{ path: '/ai', label: 'AI', labelKey: 'admin.routes.ai', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] },
{ path: '/earth-content', label: '智能星球内容', labelKey: 'admin.routes.earthContent', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand', 'news', 'rss', '新闻源'] },
{ path: '/collection-management', label: '采集管理', labelKey: 'admin.routes.collectionManagement', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] },
{ path: '/logs', label: '系统日志', labelKey: 'admin.routes.logs', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true },
{ path: '/users', label: '用户管理', labelKey: 'admin.routes.users', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] },
{ path: '/settings', label: '系统设置', labelKey: 'admin.routes.settings', group: 'ops', icon: Settings, keywords: ['settings', 'smtp', 'security'] },
]
export function getVisibleAdminRoutes(isSuperAdmin: boolean) {

View File

@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import { buildDynamicAdminTargets, buildStaticAdminTargets, searchAdminTargets } from './indexers'
@@ -26,13 +27,14 @@ function targetSearchParams(target: AdminSearchTarget) {
export function AdminSearchProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const location = useLocation()
const { i18n } = useTranslation()
const { user } = useAuthStore()
const isSuperAdmin = user?.role === 'super_admin'
const [dynamicTargets, setDynamicTargets] = useState<AdminSearchTarget[]>([])
const [loading, setLoading] = useState(false)
const loadedRef = useRef(false)
const loadingRef = useRef<Promise<void> | null>(null)
const staticTargets = useMemo(() => buildStaticAdminTargets(isSuperAdmin), [isSuperAdmin])
const staticTargets = useMemo(() => buildStaticAdminTargets(isSuperAdmin), [i18n.language, isSuperAdmin])
const targets = useMemo(() => {
const byId = new Map<string, AdminSearchTarget>()
staticTargets.forEach((target) => byId.set(target.id, target))
@@ -44,7 +46,7 @@ export function AdminSearchProvider({ children }: { children: ReactNode }) {
loadedRef.current = false
loadingRef.current = null
setDynamicTargets([])
}, [isSuperAdmin])
}, [i18n.language, isSuperAdmin])
const ensureDynamicIndex = useCallback(async (query: string) => {
if (query.trim().length < 2 || loadedRef.current) return

View File

@@ -14,6 +14,7 @@ import {
ShieldAlert,
Users,
} from 'lucide-react'
import i18n from '../../i18n'
import { adminRoutes, getVisibleAdminRoutes } from '../routes/manifest'
import type { AdminSearchTarget } from './types'
@@ -50,11 +51,68 @@ function targetId(parts: Array<string | undefined>) {
return parts.filter(Boolean).join(':')
}
const labelKeys: Record<string, string> = {
'AI': 'admin.routes.ai',
'BGP观测': 'admin.routes.bgp',
'BGP': 'admin.sections.bgpOverview',
'BGP 事故': 'admin.sections.alerts',
'BGP 告警': 'admin.routes.bgpAlerts',
'Playground': 'admin.sections.aiPlayground',
'SMTP 邮件': 'admin.sections.smtp',
'工具调用': 'admin.sections.aiTools',
'提示词': 'admin.sections.aiPrompts',
'日志': 'admin.routes.logs',
'日志源': 'admin.sections.logsSources',
'智能星球内容': 'admin.routes.earthContent',
'模型供应商': 'admin.sections.aiIntegrations',
'模型预设': 'admin.sections.aiIntegrations',
'电视直播': 'admin.sections.tv',
'系统告警': 'admin.routes.systemAlerts',
'系统显示': 'admin.sections.settingsSystem',
'系统设置': 'admin.routes.settings',
'采集历史': 'admin.sections.collectionHistory',
'采集历史 / 快照': 'admin.sections.collectionHistory',
'采集器': 'admin.sections.collectorCredentials',
'采集数据': 'admin.routes.data',
'采集管理': 'admin.routes.collectionManagement',
'采集调度': 'admin.sections.collectors',
'数据源': 'admin.routes.datasources',
'用户': 'admin.routes.users',
'用户管理': 'admin.routes.users',
'告警记录': 'admin.sections.alerts',
'国界精度': 'admin.sections.earthAssets',
'品牌标识': 'admin.sections.earthBrand',
'态势告警': 'admin.routes.situationalAlerts',
'通知策略': 'admin.sections.notifications',
'安全策略': 'admin.sections.security',
'新闻源': 'admin.sections.newsSources',
'页面': 'admin.search.pageContext',
}
function translateLabel(label: string | undefined): string | undefined {
if (!label) return label
const key = labelKeys[label]
return key ? i18n.t(key) : label
}
function makeTarget(target: Omit<AdminSearchTarget, 'id'> & { id?: string }): AdminSearchTarget {
const routeLabel = translateLabel(target.routeLabel) || target.routeLabel
const sectionLabel = translateLabel(target.sectionLabel) || target.sectionLabel
const label = translateLabel(target.label) || target.label
const contextLabel = translateLabel(target.contextLabel) || target.contextLabel
return {
...target,
contextLabel,
id: target.id || targetId([target.routePath, target.sectionKey, target.groupKey, target.fieldKey, target.label]),
label,
routeLabel,
sectionLabel,
terms: Array.from(new Set([
label,
routeLabel,
sectionLabel,
contextLabel,
target.routeLabel,
target.sectionLabel,
target.contextLabel,
@@ -153,9 +211,9 @@ export function buildStaticAdminTargets(isSuperAdmin: boolean): AdminSearchTarge
.filter((route) => visiblePaths.has(route.path))
.map((route) => makeTarget({
routePath: route.path,
routeLabel: route.label,
label: route.label,
contextLabel: '页面',
routeLabel: i18n.t(route.labelKey),
label: i18n.t(route.labelKey),
contextLabel: i18n.t('admin.search.pageContext'),
terms: route.keywords,
icon: route.icon,
}))

View File

@@ -1,4 +1,7 @@
.admin-theme-root {
min-height: 0;
height: 100%;
overflow: hidden;
--an-page-padding: 16px;
--an-section-gap: 16px;
--an-panel-gap: 12px;
@@ -128,7 +131,10 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.admin__sider {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
height: 100%;
overflow: hidden;
background: var(--an-surface);
border-right: 1px solid var(--an-border);
}
@@ -171,8 +177,9 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
}
.admin__nav-scroll {
flex: 1;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.admin__nav {
@@ -237,10 +244,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
}
.admin__account {
flex: 0 0 auto;
border-top: 1px solid var(--an-border);
padding: 12px;
background: color-mix(in srgb, var(--an-bg) 42%, var(--an-surface));
display: grid;
gap: 9px;
gap: 0;
}
.admin__account-row {
@@ -251,21 +260,93 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
font-size: 12px;
}
.admin__account-row--primary {
min-height: 34px;
}
.admin__account-row > div {
min-width: 0;
display: grid;
gap: 2px;
}
.admin__account-profile {
display: flex !important;
grid-template-columns: none;
align-items: center;
min-width: 0;
flex: 1 1 auto;
margin-right: 6px;
}
.admin__account-row .admin__account-profile {
gap: 18px;
}
.admin__account-profile > div {
min-width: 0;
display: grid;
gap: 1px;
}
.admin__account-avatar {
flex: 0 0 30px;
width: 30px;
height: 30px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--an-accent);
color: #ffffff;
font-size: 12px;
font-weight: 800;
box-shadow: 0 6px 16px color-mix(in srgb, var(--an-accent) 22%, transparent);
}
.admin__account-row .admin__account-avatar {
color: #ffffff;
}
.admin__account-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin__account-logout {
.admin__account-actions {
display: flex !important;
grid-template-columns: none;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
gap: 4px;
}
.admin__account-logout,
.admin__account-preferences {
width: 28px;
height: 28px;
}
.admin__account-preferences {
color: var(--an-muted);
}
.admin__account-preferences svg {
transition: color 0.18s ease, transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.admin__account-preferences.is-active {
color: var(--an-accent);
background: color-mix(in srgb, var(--an-accent) 10%, var(--an-surface));
}
.admin__account-preferences.is-active svg {
transform: rotate(90deg);
}
.admin__account-logout {
color: var(--an-danger);
}
@@ -277,11 +358,46 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
width: 100%;
}
.admin__preferences-drawer {
max-height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(-5px);
transition:
max-height 0.24s ease,
opacity 0.18s ease,
transform 0.24s ease,
visibility 0s linear 0.24s;
}
.admin__preferences-drawer.is-open {
max-height: 146px;
opacity: 1;
visibility: visible;
transform: translateY(0);
transition:
max-height 0.28s ease,
opacity 0.18s ease,
transform 0.28s ease,
visibility 0s;
}
.admin__preferences-panel {
margin-top: 12px;
padding: 12px 14px;
border-top: 1px solid color-mix(in srgb, var(--an-border) 78%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--an-bg) 62%, var(--an-surface));
display: grid;
gap: 8px;
}
.admin__theme-control--sider {
--segmented-control-radius: 8px;
--segmented-control-slider-radius: 6px;
--segmented-control-button-gap: 0;
--segmented-control-icon-size: calc(17px * var(--segmented-control-scale, 1));
--segmented-control-icon-size: 13px;
}
.admin__theme-control--sider .segmented-control__button {
@@ -309,6 +425,21 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
color: #64748b;
}
.admin__language-control--sider {
width: 100%;
--segmented-control-radius: 8px;
--segmented-control-slider-radius: 6px;
--segmented-control-button-gap: 0;
--segmented-control-font-size: calc(10px * var(--segmented-control-scale, 1));
--segmented-control-font-weight: 800;
}
.admin__language-control--sider .segmented-control__button {
font-size: calc(10px * var(--segmented-control-scale, 1));
font-weight: 800;
line-height: 1;
}
.admin__logout {
justify-content: center;
}
@@ -597,9 +728,11 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.admin__content {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: 48px minmax(0, 1fr);
overflow: hidden;
}
.admin__topbar {
@@ -652,6 +785,25 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
color: var(--an-muted);
}
.admin__language-control {
width: 112px;
--segmented-control-radius: 8px;
--segmented-control-slider-radius: 6px;
--segmented-control-button-gap: 0;
}
.admin__language-control .segmented-control__button {
flex-direction: row;
}
.admin__language-control .segmented-control__icon {
display: none;
}
.admin__language-control.admin__language-control--sider {
width: 100%;
}
.admin__search-results {
position: absolute;
top: calc(100% + 6px);
@@ -754,6 +906,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
.admin__content-inner {
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
padding: var(--an-page-padding);
}
@@ -1633,7 +1786,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
color: var(--an-text);
padding: 8px 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) 104px;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: start;
gap: 10px;
text-align: left;
@@ -1683,19 +1836,22 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
}
.an-hierarchy-group__meta {
width: 104px;
max-width: 104px;
width: max-content;
max-width: none;
min-width: max-content;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
overflow: hidden;
justify-self: end;
overflow: visible;
}
.an-hierarchy-group__meta .an-status-pill {
flex: 0 0 74px;
width: 74px;
max-width: 74px;
flex: 0 0 auto;
width: auto;
min-width: max-content;
max-width: none;
}
.an-hierarchy-group__meta em {

View File

@@ -1,5 +1,6 @@
import { Check, Copy } from 'lucide-react'
import { memo, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from 'react'
import Scrollbar from '../Scrollbar/Scrollbar'
@@ -167,6 +168,7 @@ function isMermaidTextTarget(target: EventTarget | null): boolean {
}
function MarkdownCodeBlock({ code, language }: { code: string; language?: string }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const label = language?.trim() || 'text'
const codeClassName = language
@@ -187,8 +189,8 @@ function MarkdownCodeBlock({ code, language }: { code: string; language?: string
type="button"
className="markdown-renderer__code-copy"
onClick={handleCopy}
aria-label={copied ? '已复制代码' : '复制代码'}
title={copied ? '已复制' : '复制代码'}
aria-label={copied ? t('markdown.copiedCode') : t('markdown.copyCode')}
title={copied ? t('markdown.copied') : t('markdown.copyCode')}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
@@ -201,6 +203,7 @@ function MarkdownCodeBlock({ code, language }: { code: string; language?: string
}
function MarkdownMermaidBlock({ code }: { code: string }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const [svg, setSvg] = useState('')
const [error, setError] = useState('')
@@ -266,7 +269,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
} catch (renderError) {
if (!cancelled) {
setSvg('')
setError(renderError instanceof Error ? renderError.message : 'Mermaid 渲染失败')
setError(renderError instanceof Error ? renderError.message : t('markdown.mermaidRenderFailed'))
}
}
}
@@ -276,7 +279,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
return () => {
cancelled = true
}
}, [blockId, code, themeMode])
}, [blockId, code, t, themeMode])
const handleCopy = async () => {
await copyToClipboard(code)
@@ -339,8 +342,8 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
type="button"
className="markdown-renderer__code-copy"
onClick={handleCopy}
aria-label={copied ? '已复制图表源码' : '复制图表源码'}
title={copied ? '已复制' : '复制图表源码'}
aria-label={copied ? t('markdown.copiedChartSource') : t('markdown.copyChartSource')}
title={copied ? t('markdown.copied') : t('markdown.copyChartSource')}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
@@ -361,8 +364,8 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
openExpanded()
}
}}
aria-label="放大查看 Mermaid 图表"
title="点击放大查看"
aria-label={t('markdown.expandMermaid')}
title={t('markdown.clickToExpand')}
>
<span className="markdown-renderer__mermaid-canvas-inner" dangerouslySetInnerHTML={{ __html: svg }} />
</div>
@@ -381,15 +384,15 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
className="markdown-renderer__mermaid-viewer"
role="dialog"
aria-modal="true"
aria-label="Mermaid 图表查看器"
aria-label={t('markdown.mermaidViewer')}
onClick={closeExpanded}
>
<button
type="button"
className="markdown-renderer__mermaid-viewer-close"
onClick={closeExpanded}
aria-label="关闭 Mermaid 图表查看器"
title="关闭"
aria-label={t('markdown.closeMermaid')}
title={t('common.close')}
>
×
</button>
@@ -411,7 +414,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
/>
</div>
<div className="markdown-renderer__mermaid-viewer-hint">
· ·
{t('markdown.viewerHint')}
</div>
</div>
) : null}

View File

@@ -0,0 +1,159 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { legacyUiTextEnUS } from './legacy-ui'
import { normalizeLocale } from './locale'
const attributeNames = ['aria-label', 'placeholder', 'title']
const selector = '.admin-theme-root, .auth-shell'
const reverseLegacyUiText = Object.fromEntries(
Object.entries(legacyUiTextEnUS).map(([source, target]) => [target, source]),
)
type LegacyTextPattern = {
match: RegExp
replace: (match: RegExpMatchArray) => string
}
const legacyTextPatternsEnUS: LegacyTextPattern[] = [
{ match: /^结果\s+(.+)\s+条$/, replace: (match) => `Results ${match[1]}` },
{ match: /^筛选\s+(.+)\s+项$/, replace: (match) => `${match[1]} filters` },
{ match: /^共\s+(.+)\s+条结果$/, replace: (match) => `${match[1]} results` },
{ match: /^(.+)\s+条新闻。$/, replace: (match) => `${match[1]} news items.` },
{ match: /^(.+)\s+个历史快照,选择后查看该版本详情。$/, replace: (match) => `${match[1]} historical snapshots. Select one to view that version.` },
{ match: /^(.+)\s+字段$/, replace: (match) => `${match[1]} fields` },
{ match: /^(.+)\s+个源 \/ (.+)\s+个类型$/, replace: (match) => `${match[1]} sources / ${match[2]} categories` },
{ match: /^(.+)\s+个来源$/, replace: (match) => `${match[1]} sources` },
{ match: /^(.+)\s+个聚合项$/, replace: (match) => `${match[1]} aggregations` },
{ match: /^(.+)\s+条$/, replace: (match) => `${match[1]} items` },
{ match: /^(.+)\s+行$/, replace: (match) => `${match[1]} lines` },
{ match: /^(.+)\s+次$/, replace: (match) => `${match[1]} times` },
{ match: /^最后更新:\s*(.+)$/, replace: (match) => `Last updated: ${match[1]}` },
{ match: /^任务已创建:\s*(.+)$/, replace: (match) => `Task created: ${match[1]}` },
{ match: /^执行命令\s+(.+)$/, replace: (match) => `Command ${match[1]}` },
{ match: /^任务 ID\s+(.+)$/, replace: (match) => `Task ID ${match[1]}` },
{ match: /^触发已选\s+(.+)$/, replace: (match) => `Trigger selected ${match[1]}` },
{ match: /^新闻直播源\s+(.+)$/, replace: (match) => `News stream source ${match[1]}` },
{ match: /^新增新闻源\s+(.+)$/, replace: (match) => `New news source ${match[1]}` },
{ match: /^最终指标:(.+)$/, replace: (match) => `Final metric: ${match[1]}` },
{ match: /^指纹\s+(.+)$/, replace: (match) => `Fingerprint ${match[1]}` },
{ match: /^首次\s+(.+)\s+·\s+最近\s+(.+)$/, replace: (match) => `First ${match[1]} · Latest ${match[2]}` },
{ match: /^已导出\s+(.+)$/, replace: (match) => `Exported ${match[1]}` },
{ match: /^(.+)\s+采集失败$/, replace: (match) => `${match[1]} collection failed` },
{ match: /^(.+)\s+采集已取消$/, replace: (match) => `${match[1]} collection cancelled` },
]
const legacyTextPatternsZhCN: LegacyTextPattern[] = [
{ match: /^Results\s+(.+)$/, replace: (match) => `结果 ${match[1]}` },
{ match: /^(.+)\s+filters$/, replace: (match) => `筛选 ${match[1]}` },
{ match: /^(.+)\s+results$/, replace: (match) => `${match[1]} 条结果` },
{ match: /^(.+)\s+news items\.$/, replace: (match) => `${match[1]} 条新闻。` },
{ match: /^(.+)\s+historical snapshots\. Select one to view that version\.$/, replace: (match) => `${match[1]} 个历史快照,选择后查看该版本详情。` },
{ match: /^(.+)\s+fields$/, replace: (match) => `${match[1]} 字段` },
{ match: /^(.+)\s+sources \/ (.+)\s+categories$/, replace: (match) => `${match[1]} 个源 / ${match[2]} 个类型` },
{ match: /^(.+)\s+sources$/, replace: (match) => `${match[1]} 个来源` },
{ match: /^(.+)\s+aggregations$/, replace: (match) => `${match[1]} 个聚合项` },
{ match: /^(.+)\s+items$/, replace: (match) => `${match[1]}` },
{ match: /^(.+)\s+lines$/, replace: (match) => `${match[1]}` },
{ match: /^(.+)\s+times$/, replace: (match) => `${match[1]}` },
{ match: /^Last updated:\s*(.+)$/, replace: (match) => `最后更新: ${match[1]}` },
{ match: /^Task created:\s*(.+)$/, replace: (match) => `任务已创建: ${match[1]}` },
{ match: /^Command\s+(.+)$/, replace: (match) => `执行命令 ${match[1]}` },
{ match: /^Task ID\s+(.+)$/, replace: (match) => `任务 ID ${match[1]}` },
{ match: /^Trigger selected\s+(.+)$/, replace: (match) => `触发已选 ${match[1]}` },
{ match: /^News stream source\s+(.+)$/, replace: (match) => `新闻直播源 ${match[1]}` },
{ match: /^New news source\s+(.+)$/, replace: (match) => `新增新闻源 ${match[1]}` },
{ match: /^Final metric:\s*(.+)$/, replace: (match) => `最终指标:${match[1]}` },
{ match: /^Fingerprint\s+(.+)$/, replace: (match) => `指纹 ${match[1]}` },
{ match: /^First\s+(.+)\s+·\s+Latest\s+(.+)$/, replace: (match) => `首次 ${match[1]} · 最近 ${match[2]}` },
{ match: /^Exported\s+(.+)$/, replace: (match) => `已导出 ${match[1]}` },
{ match: /^(.+)\s+collection failed$/, replace: (match) => `${match[1]} 采集失败` },
{ match: /^(.+)\s+collection cancelled$/, replace: (match) => `${match[1]} 采集已取消` },
]
function preserveOuterWhitespace(source: string, replacement: string) {
const leading = source.match(/^\s*/)?.[0] || ''
const trailing = source.match(/\s*$/)?.[0] || ''
return `${leading}${replacement}${trailing}`
}
function translatePatternText(value: string, locale: string) {
const text = value.trim()
if (!text || text.length > 160) return value
const patterns = normalizeLocale(locale) === 'en-US' ? legacyTextPatternsEnUS : legacyTextPatternsZhCN
for (const pattern of patterns) {
const matched = text.match(pattern.match)
if (matched) return preserveOuterWhitespace(value, pattern.replace(matched))
}
return value
}
function translateText(value: string, locale: string) {
const text = value.trim()
if (!text) return value
const dictionary = normalizeLocale(locale) === 'en-US' ? legacyUiTextEnUS : reverseLegacyUiText
const replacement = dictionary[text]
if (replacement) return preserveOuterWhitespace(value, replacement)
return translatePatternText(value, locale)
}
function translateElementAttributes(element: Element, locale: string) {
attributeNames.forEach((attributeName) => {
const value = element.getAttribute(attributeName)
if (!value) return
const translated = translateText(value, locale)
if (translated !== value) element.setAttribute(attributeName, translated)
})
}
function translateNodeText(root: Element, locale: string) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
while (node) {
const value = node.textContent || ''
const translated = translateText(value, locale)
if (translated !== value) node.textContent = translated
node = walker.nextNode()
}
}
function translateRoot(root: Element, locale: string) {
translateElementAttributes(root, locale)
root.querySelectorAll('*').forEach((element) => translateElementAttributes(element, locale))
translateNodeText(root, locale)
}
export default function LegacyI18nBridge() {
const { i18n } = useTranslation()
const locale = normalizeLocale(i18n.resolvedLanguage || i18n.language)
useEffect(() => {
let frameId = 0
const translate = () => {
document.querySelectorAll(selector).forEach((root) => translateRoot(root, locale))
}
const scheduleTranslate = () => {
window.cancelAnimationFrame(frameId)
frameId = window.requestAnimationFrame(translate)
}
scheduleTranslate()
const observer = new MutationObserver(scheduleTranslate)
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: attributeNames,
characterData: true,
childList: true,
subtree: true,
})
}
return () => {
window.cancelAnimationFrame(frameId)
observer.disconnect()
}
}, [locale])
return null
}

View File

@@ -0,0 +1,27 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { readStoredLocale, syncDocumentLocale } from './locale'
import { resources } from './resources'
const initialLocale = readStoredLocale()
syncDocumentLocale(initialLocale)
void i18n
.use(initReactI18next)
.init({
fallbackLng: 'zh-CN',
interpolation: {
escapeValue: false,
},
lng: initialLocale,
resources,
returnEmptyString: false,
})
i18n.on('languageChanged', (locale) => {
syncDocumentLocale(locale === 'en-US' ? 'en-US' : 'zh-CN')
})
export default i18n

View File

@@ -0,0 +1,589 @@
export const legacyUiTextEnUS: Record<string, string> = {
'3D 模型': '3D models',
'AI 生成教程': 'AI-generated guide',
'AI 分区': 'AI sections',
'AI 配置详情': 'AI configuration details',
'AI 集成': 'AI integrations',
'AI 简报': 'AI brief',
'BGP 事故': 'BGP incident',
'BGP 事件与简报': 'BGP events and briefs',
'BGP 告警列表': 'BGP alert list',
'BGP 告警详情': 'BGP alert details',
'BGP 异常': 'BGP anomaly',
'BGP 概览': 'BGP overview',
'BGP 简报': 'BGP brief',
'BGP 详情': 'BGP details',
'BGP 观测': 'BGP Observatory',
'BGP 告警': 'BGP alert',
'BGP观测': 'BGP Observatory',
'Feed 信息页': 'Feed info page',
'Feed 地址': 'Feed URL',
'Feed 子项': 'Feed entries',
'Feed 标签': 'Feed tags',
'Feed 类型': 'Feed type',
'Feed 名称': 'Feed name',
'Feed ID': 'Feed ID',
'Gatekeeper 权限组': 'Gatekeeper groups',
'RSS 来源': 'RSS source',
'RSS 来源只读,新闻由抓取与增强链路维护。': 'RSS sources are read-only. News is maintained by the collection and enrichment pipeline.',
'RSS 订阅中心或 Feed 聚合页,只用于人工查看,不参与抓取。': 'RSS directory or feed aggregation page for manual review only. It is not fetched.',
'SMTP 邮件': 'SMTP email',
'System Prompt': 'System prompt',
'Time Capsule': 'Time Capsule',
'Web Search 预设': 'Web Search presets',
'不可用': 'Unavailable',
'事故': 'Incident',
'交互正常': 'Interactive',
'个来源': 'sources',
'个聚合项': 'aggregations',
'任务提示词': 'Task prompt',
'仪表盘': 'Dashboard',
'任务': 'Task',
'任务 ID': 'Task ID',
'任务已取消': 'Task cancelled',
'任务状态': 'Task status',
'今日任务': 'Tasks today',
'供应商': 'Provider',
'供应商状态': 'Provider status',
'供应商配置': 'Provider configuration',
'保存': 'Save',
'保存并重试': 'Save and retry',
'保存后会进入清洗、翻译、分类和定位队列。': 'After saving, the item enters the cleaning, translation, classification, and geocoding queue.',
'保存后才会固化到新闻源配置。': 'Changes are persisted to the news source configuration only after saving.',
'保存新闻': 'Save news item',
'修改邮箱': 'Change email',
'停止': 'Stop',
'停止采集': 'Stop collection',
'停止生成': 'Stop generation',
'停用': 'Disabled',
'关闭': 'Close',
'关于': 'About',
'关于配置': 'About configuration',
'内置源': 'Built-in sources',
'全部区域': 'All regions',
'全部国家/地区': 'All countries / regions',
'全部层级': 'All levels',
'全部级别': 'All levels',
'全部产品域': 'All product domains',
'全部执行状态': 'All execution statuses',
'全部数据源': 'All datasources',
'全部数据状态': 'All data statuses',
'全部源属性': 'All source attributes',
'全部状态': 'All statuses',
'全部类型': 'All types',
'其他': 'Other',
'刷新': 'Refresh',
'刷新当前 Provider 的模型配置': 'Refresh current provider model configuration',
'刷新模型': 'Refresh models',
'刷新模型列表': 'Refresh model list',
'刷新线程': 'Refresh thread',
'分类': 'Category',
'删除 Feed 子项': 'Delete feed entry',
'删除': 'Delete',
'删除失败': 'Delete failed',
'删除完成': 'Deletion complete',
'删除成功': 'Deleted',
'删除已取消': 'Deletion cancelled',
'删除新闻': 'Delete news item',
'删除新闻源': 'Delete news source',
'删除中': 'Deleting',
'删除直播源': 'Delete stream source',
'删除品牌配置': 'Delete brand configuration',
'前往登录': 'Go to login',
'加载中': 'Loading',
'加载日志内容失败': 'Failed to load log content',
'加载日志源失败': 'Failed to load log sources',
'加载重复日志详情失败': 'Failed to load duplicate log details',
'加载重复日志统计失败': 'Failed to load duplicate log stats',
'启动采集': 'Start collection',
'启动实时源': 'Start realtime source',
'启动边界构建': 'Start boundary build',
'启用': 'Enabled',
'启用抓取': 'Enable fetching',
'启用映射': 'Enable mapping',
'启用筛选': 'Active filters',
'告警': 'Alert',
'告警记录': 'Alert records',
'告警统计': 'Alert stats',
'名称': 'Name',
'后台账号': 'Console account',
'回到首页': 'Back to overview',
'国界精度': 'Boundary accuracy',
'国家': 'Country',
'地址': 'Address',
'基础字段': 'Basic fields',
'基础信息': 'Basic information',
'城市': 'City',
'字段': 'Fields',
'安全策略': 'Security policy',
'完成': 'Complete',
'密码': 'Password',
'导航': 'Navigation',
'已加载分区': 'Loaded section',
'已加载分区汇总': 'Loaded section total',
'工具调用': 'Tool calls',
'已保存': 'Saved',
'已取消': 'Cancelled',
'已处理': 'Resolved',
'已提交': 'Submitted',
'已读取': 'Loaded',
'已启用': 'Enabled',
'已定位': 'Located',
'已配置': 'Configured',
'已停止': 'Stopped',
'已停用': 'Disabled',
'已就绪': 'Ready',
'已跳过': 'Skipped',
'已有新闻': 'Existing news',
'已有任务运行': 'Task already running',
'已有账号,去登录': 'Already have an account? Log in',
'开始日期': 'Start date',
'底图资源': 'Basemap assets',
'开启后访问智能星球会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。': 'When enabled, Intelligent Planet opens directly into the OOBE guide, skips first-collection requirements, and ignores local temporary browse-first skips.',
'态势告警': 'Situational alerts',
'态势告警列表': 'Situational alert list',
'态势详情': 'Situational details',
'态势统计': 'Situational stats',
'快速访问地球可视化页面': 'Quickly open the Earth visualization page',
'恢复当前表单': 'Restore current form',
'恢复默认关于信息': 'Restore default about information',
'恢复默认配置': 'Restore defaults',
'恢复超时,请手动检查服务状态。': 'Recovery timed out. Please check service status manually.',
'成功': 'Success',
'成功率': 'Success rate',
'手动新闻组': 'Manual news group',
'手动新闻': 'Manual news',
'打开智能星球内容': 'Open Intelligent Planet content',
'打开智能星球': 'Open Intelligent Planet',
'处理告警': 'Resolve alert',
'播放与扩展': 'Playback and extensions',
'接入在线': 'Endpoints online',
'接口在线': 'Endpoints online',
'接口失败': 'Endpoint failed',
'接口请求失败': 'Endpoint request failed',
'提示': 'Info',
'控制台不混入其他配置或假数据。': 'The console does not mix in unrelated configuration or mock data.',
'控制台发生错误,请刷新页面重试。': 'The console encountered an error. Please refresh and try again.',
'控制台渲染错误': 'Console render error',
'拖动调整数据概览宽度': 'Drag to resize data overview',
'提交': 'Submit',
'提交失败': 'Submission failed',
'提交重启任务失败': 'Failed to submit restart task',
'提示词': 'Prompts',
'搜索日志正文': 'Search log text',
'搜索名称、描述、元数据等': 'Search name, description, metadata, and more',
'搜索': 'Search',
'搜索供应商': 'Search provider',
'搜索深度': 'Search depth',
'搜索用户、邮箱、角色': 'Search users, email, or role',
'数据源': 'Datasources',
'数据源列表': 'Datasource list',
'数据源详情': 'Datasource details',
'数据源总数': 'Total datasources',
'数据源状态': 'Datasource status',
'数据概览': 'Data overview',
'数据列表': 'Data list',
'数据类型': 'Data type',
'数据集': 'Dataset',
'数据状态': 'Data status',
'采集数据': 'Collected data',
'采集配置列表': 'Collection configuration list',
'采集器': 'Collectors',
'采集器详情': 'Collector details',
'采集器配置': 'Collector configuration',
'采集快照': 'Collection snapshot',
'采集历史 / 快照': 'Collection history / snapshots',
'采集时间': 'Collected at',
'采集已取消': 'Collection cancelled',
'采集失败': 'Collection failed',
'采集完成': 'Collection complete',
'采集中': 'Collecting',
'采集管理': 'Collection Management',
'采集调度': 'Collection schedule',
'采样': 'Sample',
'采样数据': 'Sample data',
'重新处理': 'Reprocess',
'重新处理新闻': 'Reprocess news item',
'新增 Feed 子项': 'Add feed entry',
'新增新闻': 'Add news item',
'新建': 'Create',
'新增新闻源': 'Add news source',
'新增新闻组': 'Add news group',
'新增采集器配置': 'Add collector configuration',
'新增 Schema 映射': 'Add schema mapping',
'新增直播源': 'Add stream source',
'新闻内容': 'News content',
'新闻条目': 'News items',
'新闻源': 'News sources',
'新闻源 ID 不能为空。': 'News source ID is required.',
'新闻源名称不能为空。': 'News source name is required.',
'新闻源配置': 'News source configuration',
'新闻源详情': 'News source details',
'新闻源测试失败': 'News source test failed',
'新闻组': 'News group',
'新闻类型': 'News category',
'新闻直播源': 'News stream source',
'无权访问': 'Permission required',
'无权限': 'No permission',
'无效': 'Invalid',
'暂无 Feed 子项': 'No feed entries',
'暂无会话': 'No conversation',
'暂无分组': 'No groups',
'暂无快照': 'No snapshots',
'暂无数据': 'No data',
'暂无新闻': 'No news items',
'暂无发生明细': 'No occurrences',
'暂无日志': 'No logs',
'暂无日志内容': 'No log content',
'暂无重复日志': 'No duplicate logs',
'暂无上报': 'No reports',
'暂无摘要': 'No summary',
'日志': 'Logs',
'日志源': 'Log sources',
'日志源不可用': 'Log source unavailable',
'日志详情': 'Log details',
'日志视图': 'Log views',
'日志跟随连接失败,可暂停后使用手动刷新。': 'Log follow connection failed. Pause it and refresh manually.',
'日志已复制': 'Logs copied',
'明细': 'Details',
'是否启用': 'Enabled',
'实时同步中': 'Syncing live',
'实时连接': 'Live connection',
'旧密码': 'Old password',
'映射模板': 'Mapping templates',
'映射预览': 'Mapping preview',
'显示名称': 'Display name',
'显示 LLM API Key / Service Token': 'Show LLM API Key / Service Token',
'显示': 'Display',
'智能星球': 'Intelligent Planet',
'智能星球内容配置': 'Intelligent Planet content configuration',
'智能星球配置详情': 'Intelligent Planet configuration details',
'智能星球内容': 'Planet Content',
'未知': 'Unknown',
'未配置': 'Not configured',
'未启用': 'Not enabled',
'未测试': 'Untested',
'未选择记录': 'No record selected',
'查看日志': 'View logs',
'最近': 'Latest',
'最后更新:': 'Last updated:',
'最大 Token': 'Max tokens',
'最大并发任务数': 'Max concurrent tasks',
'最大登录尝试次数': 'Max login attempts',
'最大结果数': 'Max results',
'最大文件(MB)': 'Max file size (MB)',
'标签': 'Tags',
'标题': 'Title',
'模型': 'Model',
'模型供应商': 'Model providers',
'模型预设': 'Model presets',
'清理数据库数据': 'Clear database data',
'清理智能星球图层缓存': 'Clear planet layer cache',
'清理缓存': 'Clear cache',
'测试 Web Search 连通性': 'Test Web Search connectivity',
'测试 AI Provider 连通性': 'Test AI Provider connectivity',
'测试当前 Feed': 'Test current feed',
'测试当前新闻源': 'Test current news source',
'测试收件人': 'Test recipient',
'测试 SMTP': 'Test SMTP',
'状态': 'Status',
'活跃数据源': 'Active datasources',
'海底光缆': 'Submarine cable',
'海缆': 'Cable',
'海缆登陆关系': 'Cable landing relation',
'海缆系统': 'Cable system',
'后端已停止响应,正在等待服务恢复。': 'Backend stopped responding. Waiting for service recovery.',
'源 ID': 'Source ID',
'源名称': 'Source name',
'源属性标签': 'Source attribute tags',
'源类型': 'Source type',
'源配置': 'Source configuration',
'源属性': 'Source attributes',
'源详情': 'Source details',
'源健康': 'Source health',
'区域': 'Region',
'按数据源': 'By datasource',
'按类型': 'By type',
'排序': 'Sort order',
'单条添加': 'Add one item',
'单源配置': 'Single-source configuration',
'上传': 'Upload',
'上传 JSON': 'Upload JSON',
'用户管理': 'User Management',
'电商': 'E-commerce',
'电视直播': 'TV streams',
'直播源': 'Stream sources',
'直播源详情': 'Stream source details',
'目标 Schema': 'Target schema',
'直达': 'Open',
'确认': 'Confirm',
'确认删除': 'Confirm deletion',
'确认告警': 'Confirm alert',
'确认操作': 'Confirm action',
'禁用': 'Disabled',
'空': 'Empty',
'空闲': 'Idle',
'等待中': 'Pending',
'简报': 'Brief',
'结果': 'Results',
'系统告警列表': 'System alert list',
'系统告警': 'System Alerts',
'系统日志': 'System Logs',
'系统显示': 'System display',
'系统设置': 'System Settings',
'系统总览与实时态势': 'System overview and realtime status',
'设置': 'Settings',
'设置详情': 'Settings details',
'设置分区': 'Settings sections',
'记录数': 'Records',
'计算中心': 'Compute center',
'选择 JSON 文件': 'Select JSON file',
'选择一条记录': 'Select a record',
'选择一组重复日志': 'Select a duplicate log group',
'选择左侧父级后编辑它的子配置。': 'Select a parent item on the left to edit its child configuration.',
'选择日志源后读取快照。': 'Select a log source to read its snapshot.',
'选择新闻直播源': 'Select news stream source',
'纬度': 'Latitude',
'经度': 'Longitude',
'统计': 'Stats',
'组内可按条添加,也可以上传 JSON 数组批量导入。': 'You can add items one by one or upload a JSON array for bulk import.',
'编辑': 'Edit',
'编辑新闻': 'Edit news',
'缺失': 'Missing',
'免费': 'Free',
'网络': 'Network',
'自定义': 'Custom',
'自定义源': 'Custom sources',
'自治系统统计': 'Autonomous system stats',
'自动回退': 'Auto fallback',
'英文标题': 'English title',
'英文摘要': 'English summary',
'英文正文': 'English content',
'英文分类': 'English category',
'草稿': 'Draft',
'警告': 'Warning',
'设备统计': 'Device stats',
'触发全部': 'Trigger all',
'触发采集': 'Trigger collection',
'访问智能星球': 'Open Intelligent Planet',
'访问官网': 'Open website',
'详 情': 'Details',
'详情': 'Details',
'详情/统计': 'Details / stats',
'详情会在右侧完整显示,不会挤压主表区域。': 'Details appear in the right pane without compressing the main table.',
'调试': 'Debug',
'请稍后重试': 'Please try again later',
'连接失败': 'Connection failed',
'连接中': 'Connecting',
'连接测试': 'Connection test',
'连通性': 'Connectivity',
'连通正常': 'Connectivity normal',
'连通性失败': 'Connectivity failed',
'运行': 'Run',
'运行中': 'Running',
'运行状态': 'Runtime status',
'运维与配置': 'Operations and Settings',
'过滤': 'Filters',
'跟随中': 'Following',
'跟随日志': 'Follow logs',
'输入': 'Input',
'返回上一级详情': 'Back to parent details',
'返回列表': 'Back to list',
'通知策略': 'Notification policy',
'配置错误': 'Configuration error',
'配置源': 'Configuration source',
'重要度与健康策略': 'Importance and health policy',
'重启': 'Restart',
'重启 AI Provider': 'Restart AI Provider',
'重启后端': 'Restart backend',
'重启服务': 'Restart service',
'重启前端': 'Restart frontend',
'重启数据库': 'Restart database',
'重启动作': 'Restart action',
'重启任务失败': 'Restart task failed',
'重复日志详情': 'Duplicate log details',
'重复日志统计': 'Duplicate log stats',
'重复统计': 'Duplicate stats',
'重启实时源': 'Restart realtime source',
'重置': 'Reset',
'重置 Prompt': 'Reset prompt',
'重置为默认内容': 'Reset to default content',
'重置为默认教程': 'Reset to default guide',
'重置品牌配置': 'Reset brand configuration',
'重试': 'Retry',
'重试次数': 'Retries',
'错误': 'Error',
'覆盖类型': 'Covered types',
'覆盖数据源': 'Covered datasources',
'执行命令': 'Command',
'暂停日志跟随': 'Pause log follow',
'隐藏 LLM API Key / Service Token': 'Hide LLM API Key / Service Token',
'隐藏': 'Hide',
'首页地址': 'Homepage URL',
'主页地址': 'Homepage URL',
'默认新闻类型': 'Default news category',
'默认': 'Default',
'默认教程': 'Default guide',
'默认模型': 'Default model',
'默认频道': 'Default channel',
'高亮命中': 'Highlighted match',
'AIS 船舶': 'AIS vessels',
'BGP 更新': 'BGP updates',
'BGP 路由': 'BGP route',
'BGP 路由表': 'BGP RIB',
'BGP 事件': 'BGP event',
'Docker 不可用': 'Docker unavailable',
'GPU 集群': 'GPU clusters',
'HTTP 失败': 'HTTP failed',
'当前分区没有可用后端能力,控制台不混入其他配置或假数据。': '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',
'当前采集源没有可查看的历史版本。': 'This collection source has no historical versions.',
'待定位': 'Pending location',
'后端能力未提供': 'Backend capability unavailable',
'只展示智能星球品牌、边界构建和电视内容配置。': 'Only Intelligent Planet branding, boundary build, and TV content configuration are shown.',
'只展示数据源相关接口,不混入其他设置对象。': 'Only datasource-related endpoints are shown; unrelated settings are not mixed in.',
'只展示系统设置分区AI 集成和采集器调度分别在对应模块管理。': 'Only system settings sections are shown. AI integrations and collector schedules are managed in their own modules.',
'只展示 BGP 事故、异常与简报。': 'Only BGP incidents, anomalies, and briefs are shown.',
'只记录官网、报告页或未来采集器线索,不参与 RSS/Atom 抓取;如需抓取,请改为 RSS、Atom 或 Aggregated。': 'Records official sites, reports, or future collector leads only. It does not participate in RSS/Atom fetching. Use RSS, Atom, or Aggregated to fetch.',
'只编辑当前新闻源;保存后才会固化到新闻源配置。': 'Only edits the current news source. Save to persist it into the news source configuration.',
'只重启 AI Provider 适配服务,前端页面通常保持在线。': 'Restart only the AI Provider adapter. The frontend usually stays online.',
'只重启后端服务,页面通常会短暂失联后自动恢复。': 'Restart only the backend service. The page may briefly disconnect and recover automatically.',
'只重启前端开发服务,页面会短暂不可用,恢复后自动刷新。': 'Restart only the frontend dev service. The page will be briefly unavailable and refresh after recovery.',
'失败时页面仍可操作': 'Page remains usable when requests fail',
'打开': 'Open',
'描述来源属性,不是媒体来源名;多个标签用逗号分隔,例如 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.',
'管理智能星球品牌、边界、电视内容和内容资产。': 'Manage Intelligent Planet branding, boundaries, TV content, and content assets.',
'管理模型供应商、工具调用、提示词和 Playground。': 'Manage model providers, tool calls, prompts, and Playground.',
'管理系统显示、通知策略、安全策略和 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',
'查看日志源、读取快照、复制原始输出,按控制台阅读方式组织。': '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.',
'显示态势统计与告警记录。': 'Shows situational stats and alert records.',
'查看态势统计、严重度、AI 简报入口和处理状态。': 'View situational stats, severity, AI brief entry points, and handling status.',
'系统告警、确认处理、AI 摘要和处置状态集中到一张低噪声列表。': 'System alerts, acknowledgements, AI summaries, and resolution status are collected into one low-noise list.',
'聚合 BGP 事故、异常和 AI 简报,突出严重度、影响范围和事件链路。': 'Aggregates BGP incidents, anomalies, and AI briefs, highlighting severity, affected scope, and event chains.',
'查看采集器、事故、异常、事件与 AI 简报;这是信息观测页,采用列表加详情。': 'View collectors, incidents, anomalies, events, and AI briefs in an information page with list plus detail.',
'按 BGP 实体聚合展示,保留事件、异常、事故和简报语义。': 'Aggregates by BGP entity while preserving event, anomaly, incident, and brief semantics.',
'配置类页面采用分层结构:先选父级,再编辑子配置。': 'Configuration pages use a hierarchy: select a parent first, then edit child configuration.',
'仅展示系统设置分区AI 集成和采集器调度分别在对应模块管理。': 'Only system setting sections are shown; AI integrations and collector schedules are managed in their own modules.',
'仅展示数据源相关接口,不混入其他设置对象。': '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',
'输入要发送给 AI 的内容': 'Enter content to send to AI',
'发送': 'Send',
'会话': 'Conversation',
'会话写入后端,刷新后保留线程状态。': 'Conversation state is stored in the backend and persists after refresh.',
'Playground 设置': 'Playground settings',
'预设': 'Preset',
'目标': 'Objective',
'约束': 'Constraints',
'描述': 'Description',
'优先级': 'Priority',
'边界状态': 'Boundary status',
'边界构建': 'Boundary build',
'边界构建任务': 'Boundary build task',
'品牌': 'Brand',
'品牌标识': 'Branding',
'品牌配置': 'Brand configuration',
'异常接口': 'Failing endpoints',
'图层资源': 'Layer resources',
'采集源': 'Collected sources',
'实时源': 'Realtime sources',
'重复日志': 'Duplicate logs',
'原始日志': 'Raw logs',
'审计事件': 'Audit events',
'审计日志': 'Audit logs',
'审计来源': 'Audit sources',
'原始ID': 'Raw ID',
'原始元数据': 'Raw metadata',
'扩展字段': 'Extended fields',
'参考日期': 'Reference date',
'快捷入口': 'Quick links',
'行': 'lines',
'次': 'times',
'首次': 'First',
'指纹': 'Fingerprint',
'离线': 'Offline',
'等待创建': 'Waiting to create',
'等待操作': 'Waiting for action',
'将重启服务。': 'The service will restart.',
'完全重启': 'Full restart',
'重启 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.',
'服务已恢复,正在刷新页面。': 'Service recovered. Refreshing the page.',
'前端已恢复,正在刷新页面。': 'Frontend recovered. Refreshing the page.',
'前端正在重启,正在等待页面入口恢复访问。': 'Frontend is restarting. Waiting for the page entry to recover.',
'获取数据失败': 'Failed to load data',
'最后更新': 'Last updated',
'总记录': 'Total records',
'筛选结果': 'Filtered results',
'清空': 'Clear',
'导出失败': 'Export failed',
'导出 JSON': 'Export JSON',
'导出 CSV': 'Export CSV',
'数据详情': 'Data details',
'按级别/日期/搜索条件读取快照': '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.',
'当前账号没有系统日志访问权限。': 'This account does not have system log access.',
'仅超级管理员可查看系统日志。': 'Only super admins can view system logs.',
'左侧展示按 fingerprint 聚合后的运行时错误。': 'The left side shows runtime errors grouped by fingerprint.',
'调整筛选条件或刷新日志源。': 'Adjust filters or refresh log sources.',
'复制日志': 'Copy logs',
'刷新日志': 'Refresh logs',
'刷新日志源': 'Refresh log sources',
'结束日期': 'End date',
'信息': 'Info',
'可用': 'Available',
'可读取': 'Readable',
'可编辑': 'Editable',
'只读': 'Read-only',
'暂无日志源': 'No log sources',
'登陆点': 'Landing point',
'算力中心': 'Compute center',
'互联网交换点': 'Internet exchange point',
'前缀地理位置': 'Prefix geography',
'卫星轨道根数': 'Satellite TLE',
'空间': 'Space',
'超算': 'Supercomputer',
'通用数据': 'Generic data',
'通用记录': 'Generic records',
'船舶': 'Vessel',
'设施': 'Facility',
'流量统计': 'Traffic stats',
'条': 'items',
'项': 'items',
'条结果': 'results',
'筛选': 'Filters',
'共': 'Total',
'智能星球计划': 'Intelligent Planet Plan',
'智能星球计划品牌标识': 'Intelligent Planet Plan branding',
'现实层宇宙全息感知系统': 'Reality-layer holographic awareness system',
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Submarine cables · Computing infrastructure',
'选择/拖入资产': 'Select / drop asset',
'元数据 / 原始字段': 'Metadata / raw fields',
'全部启用状态': 'All enabled states',
'失败': 'Failed',
'未执行': 'Not run',
'已采集': 'Collected',
'未采集': 'Not collected',
'当前分区暂无记录': 'No records in this section',
'切换上方分区可精准查看不同配置和接口。': 'Switch sections above to inspect different configurations and endpoints.',
'详情会在右侧完整滚动显示,不会挤压主表区域。': 'Details scroll fully on the right without compressing the main table.',
'连接、采样、运行和凭证配置': 'Connection, sampling, runtime, and credential configuration',
'采样 payload 到目标 Schema 的字段映射': 'Field mapping from sample payload to target schema',
'采集数据落库目标结构': 'Target schema for persisted collected data',
'标识': 'Identifier',
'更新时间': 'Updated at',
'卫星': 'Satellite',
'算力': 'Compute',
'媒体': 'Media',
}

View File

@@ -0,0 +1,68 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export type SupportedLocale = 'zh-CN' | 'en-US'
export type DocsLang = 'zh' | 'en'
export const defaultLocale: SupportedLocale = 'zh-CN'
export const localeStorageKey = 'planet-locale'
const legacyDocsLangStorageKey = 'docs-lang'
export const localeOptions: Array<{ value: SupportedLocale; labelKey: string; titleKey: string }> = [
{ value: 'zh-CN', labelKey: 'common.zh', titleKey: 'common.zh' },
{ value: 'en-US', labelKey: 'common.en', titleKey: 'common.en' },
]
export function normalizeLocale(value: string | null | undefined): SupportedLocale {
if (!value) return defaultLocale
const normalized = value.toLowerCase()
if (normalized === 'en' || normalized === 'en-us' || normalized.startsWith('en-')) return 'en-US'
if (normalized === 'zh' || normalized === 'zh-cn' || normalized.startsWith('zh-')) return 'zh-CN'
return defaultLocale
}
export function docsLangFromLocale(locale: SupportedLocale): DocsLang {
return locale === 'en-US' ? 'en' : 'zh'
}
export function localeFromDocsLang(lang: DocsLang): SupportedLocale {
return lang === 'en' ? 'en-US' : 'zh-CN'
}
export function readStoredLocale(): SupportedLocale {
if (typeof window === 'undefined') return defaultLocale
const storedLocale = window.localStorage.getItem(localeStorageKey)
if (storedLocale) return normalizeLocale(storedLocale)
const legacyDocsLang = window.localStorage.getItem(legacyDocsLangStorageKey)
if (legacyDocsLang === 'en' || legacyDocsLang === 'zh') {
return localeFromDocsLang(legacyDocsLang)
}
return defaultLocale
}
export function persistLocale(locale: SupportedLocale) {
if (typeof window === 'undefined') return
window.localStorage.setItem(localeStorageKey, locale)
window.localStorage.setItem(legacyDocsLangStorageKey, docsLangFromLocale(locale))
}
export function syncDocumentLocale(locale: SupportedLocale) {
if (typeof document === 'undefined') return
document.documentElement.lang = locale
}
export function useLocale() {
const { i18n } = useTranslation()
const locale = normalizeLocale(i18n.resolvedLanguage || i18n.language)
const docsLang = docsLangFromLocale(locale)
const setLocale = useCallback((nextLocale: SupportedLocale) => {
persistLocale(nextLocale)
syncDocumentLocale(nextLocale)
void i18n.changeLanguage(nextLocale)
}, [i18n])
return { docsLang, locale, setLocale }
}

View File

@@ -0,0 +1,430 @@
export const zhCN = {
app: {
title: '智能星球计划',
routeLoading: '正在加载',
},
common: {
cancel: '取消',
close: '关闭',
confirm: '确认',
delete: '删除',
language: '语言',
loading: '加载中',
noData: '暂无数据',
operationFailed: '操作失败',
page: '第 {{page}} / {{totalPages}} 页,共 {{total}} 条',
previousPage: '上一页',
nextPage: '下一页',
selectRow: '选择行',
selectVisibleRows: '选择当前可见数据',
theme: '主题',
themeLight: '浅色',
themeDark: '深色',
themeSystem: '系统',
themeFollowSystem: '跟随系统',
zh: '中文',
en: 'EN',
},
admin: {
brandTitle: '智能星球',
brandSubtitle: '控制台',
collapseMenu: '折叠菜单',
expandMenu: '展开菜单',
openNav: '打开导航',
closeNav: '关闭导航',
logout: '退出登录',
greeting: '您好,{{name}}',
version: '版本号',
themeControl: '控制台主题',
languageControl: '控制台语言',
expandPreferences: '展开偏好设置',
collapsePreferences: '收起偏好设置',
search: {
label: '搜索功能、配置和文字',
placeholder: '搜索功能、配置和文字',
current: '当前:{{label}}',
results: 'Admin 搜索结果',
loading: '正在加载搜索索引…',
empty: '没有找到匹配内容',
pageContext: '页面',
},
groups: {
overview: '总览',
collection: '采集与数据',
observability: '专题观测',
alerts: '告警与研判',
ops: '运维与配置',
},
routes: {
dashboard: '仪表盘',
earth: '智能星球',
docs: '文档',
datasources: '数据源',
data: '采集数据',
bgp: 'BGP观测',
systemAlerts: '系统告警',
bgpAlerts: 'BGP 告警',
situationalAlerts: '态势告警',
ai: 'AI',
earthContent: '智能星球内容',
collectionManagement: '采集管理',
logs: '系统日志',
users: '用户管理',
settings: '系统设置',
},
sections: {
alerts: '告警记录',
aiIntegrations: '模型供应商',
aiTools: '工具调用',
aiPrompts: '提示词',
aiPlayground: 'Playground',
bgpOverview: 'BGP',
collectionHistory: '采集历史 / 快照',
collectorCredentials: '采集器',
collectors: '采集调度',
earthAssets: '国界精度',
earthBrand: '品牌标识',
logsSources: '日志源',
newsSources: '新闻源',
notifications: '通知策略',
security: '安全策略',
settingsSystem: '系统显示',
smtp: 'SMTP 邮件',
tv: '电视直播',
},
},
auth: {
accountRecovery: '账号恢复',
alreadyHaveAccount: '已有账号,去登录',
backToLogin: '返回登录',
code: '验证码',
codeSent: '验证码已发送至 <strong>{{email}}</strong>10 分钟内有效。',
createAccount: '创建账号',
email: '邮箱',
emailVerification: '邮箱验证',
emailNotVerified: '邮箱未验证,请先完成邮箱验证。',
forgotPassword: '忘记密码?',
forgotPasswordDescription: '通过邮箱验证码重置后台账号密码。',
forgotPasswordTitle: '找回密码',
loginButton: '登录',
loginDescription: '使用你的后台账号进入运维工作台。',
loginFailed: '登录失败,请检查账号或密码。',
loginSuccess: '登录成功,正在进入控制台。',
loginTitle: '登录 Planet 控制台',
newPassword: '新密码',
password: '密码',
passwordHint: '至少 8 位',
passwordResetSuccess: '密码已重置,请用新密码登录。',
recoveryCodeSent: '若该邮箱已注册,验证码已发送。请到邮箱查收。',
register: '注册',
registerAccount: '注册账户',
registerDescription: '创建账号后需要完成邮箱验证,验证成功会自动进入控制台。',
resend: '重新发送验证码',
resendCountdown: '重发 ({{seconds}}s)',
resendSuccess: '验证码已重发。',
resetPassword: '重置密码',
sendCode: '发送验证码',
updateEmail: '修改邮箱',
username: '用户名',
usernameHint: '3-50 个字符',
verificationSent: '验证码已发送到邮箱。',
verifyAndLogin: '验证并登录',
verifyEmail: '验证邮箱',
verifyEmailDescription: '输入邮箱验证码后会自动登录并进入控制台。',
welcomeBack: '欢迎回来',
shell: {
product: 'Planet',
subtitle: 'Operations Console',
kicker: '现代控制台',
title: '把数据、告警、AI 和 Earth 运维放在同一个清爽工作台。',
description: '控制台默认进入现代化工作流,登录后直接使用 `/admin` 即可。',
},
},
docs: {
brandTitle: '智能星球文档',
brandSubtitle: '开发者和用户手册',
documentUnavailable: '文档不可用',
docs: '文档',
footerLanguage: 'Language',
footerTheme: 'Theme',
loading: '加载中...',
loginRequired: '需要登录',
loginRequiredDescription: '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。',
goToLogin: '前往登录',
forbidden: '无权访问',
forbiddenDescription: '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。',
notFound: '文档未找到',
notFoundDescription: '请求的文档不存在,或当前语言没有对应内容。',
returnOverview: '返回文档首页',
searchLabel: '搜索文档',
searchPlaceholder: '搜索文档...',
searchEmpty: '未找到匹配文档',
toc: '本页目录',
tocEmpty: '暂无章节',
},
markdown: {
copyCode: '复制代码',
copied: '已复制',
copiedCode: '已复制代码',
copyChartSource: '复制图表源码',
copiedChartSource: '已复制图表源码',
expandMermaid: '放大查看 Mermaid 图表',
clickToExpand: '点击放大查看',
closeMermaid: '关闭 Mermaid 图表查看器',
mermaidViewer: 'Mermaid 图表查看器',
mermaidRenderFailed: 'Mermaid 渲染失败',
viewerHint: '拖拽移动 · 滚轮缩放 · 点击空白关闭',
},
users: {
actions: '操作',
active: '活跃',
addUser: '添加用户',
clearSearch: '清空搜索',
confirmDelete: '确认删除',
confirmDeleteDescription: '确定要删除用户 {{username}} 吗?',
createSuccess: '创建成功',
deleteFailed: '删除失败',
deleteSuccess: '删除成功',
description: '维护后台账号、角色与文档权限组。',
disabled: '禁用',
edit: '编辑',
editUser: '编辑用户',
gatekeeperGroups: 'Gatekeeper 权限组',
retryLater: '请稍后重试',
role: '角色',
searchPlaceholder: '搜索用户、邮箱、角色',
status: '状态',
submit: '提交',
unconfigured: '未配置',
updateSuccess: '更新成功',
roles: {
super_admin: '超级管理员',
admin: '管理员',
operator: '操作员',
viewer: '只读用户',
},
gatekeeper: {
docs_user: '文档:用户文档',
docs_developer: '文档:开发文档',
docs_admin: '文档:管理/运维文档',
},
},
}
export const enUS = {
app: {
title: 'Intelligent Planet Plan',
routeLoading: 'Loading',
},
common: {
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
delete: 'Delete',
language: 'Language',
loading: 'Loading',
noData: 'No data',
operationFailed: 'Operation failed',
page: 'Page {{page}} / {{totalPages}}, {{total}} total',
previousPage: 'Previous',
nextPage: 'Next',
selectRow: 'Select row',
selectVisibleRows: 'Select visible rows',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
themeFollowSystem: 'Follow system',
zh: '中文',
en: 'EN',
},
admin: {
brandTitle: 'Intelligent Planet',
brandSubtitle: 'Console',
collapseMenu: 'Collapse menu',
expandMenu: 'Expand menu',
openNav: 'Open navigation',
closeNav: 'Close navigation',
logout: 'Log out',
greeting: 'Hi, {{name}}',
version: 'Version',
themeControl: 'Console theme',
languageControl: 'Console language',
expandPreferences: 'Expand preferences',
collapsePreferences: 'Collapse preferences',
search: {
label: 'Search features, settings, and text',
placeholder: 'Search features, settings, and text',
current: 'Current: {{label}}',
results: 'Admin search results',
loading: 'Loading search index...',
empty: 'No matching content',
pageContext: 'Page',
},
groups: {
overview: 'Overview',
collection: 'Collection and Data',
observability: 'Observability',
alerts: 'Alerts and Analysis',
ops: 'Operations and Settings',
},
routes: {
dashboard: 'Dashboard',
earth: 'Intelligent Planet',
docs: 'Docs',
datasources: 'Datasources',
data: 'Collected Data',
bgp: 'BGP Observatory',
systemAlerts: 'System Alerts',
bgpAlerts: 'BGP Alerts',
situationalAlerts: 'Situational Alerts',
ai: 'AI',
earthContent: 'Planet Content',
collectionManagement: 'Collection Management',
logs: 'System Logs',
users: 'User Management',
settings: 'System Settings',
},
sections: {
alerts: 'Alert Records',
aiIntegrations: 'Model Providers',
aiTools: 'Tool Calls',
aiPrompts: 'Prompts',
aiPlayground: 'Playground',
bgpOverview: 'BGP',
collectionHistory: 'Collection History / Snapshots',
collectorCredentials: 'Collectors',
collectors: 'Collection Schedule',
earthAssets: 'Boundary Accuracy',
earthBrand: 'Branding',
logsSources: 'Log Sources',
newsSources: 'News Sources',
notifications: 'Notification Policy',
security: 'Security Policy',
settingsSystem: 'System Display',
smtp: 'SMTP Email',
tv: 'TV Streams',
},
},
auth: {
accountRecovery: 'Account recovery',
alreadyHaveAccount: 'Already have an account? Log in',
backToLogin: 'Back to login',
code: 'Verification code',
codeSent: 'A 6-digit code was sent to <strong>{{email}}</strong>. It is valid for 10 minutes.',
createAccount: 'Create account',
email: 'Email',
emailVerification: 'Email verification',
emailNotVerified: 'Email is not verified. Please verify your email first.',
forgotPassword: 'Forgot password?',
forgotPasswordDescription: 'Reset your console password with an email verification code.',
forgotPasswordTitle: 'Reset password',
loginButton: 'Log in',
loginDescription: 'Use your admin account to enter the operations workspace.',
loginFailed: 'Login failed. Check your account or password.',
loginSuccess: 'Login succeeded. Opening the console.',
loginTitle: 'Log in to Planet Console',
newPassword: 'New password',
password: 'Password',
passwordHint: 'At least 8 characters',
passwordResetSuccess: 'Password reset. Log in with your new password.',
recoveryCodeSent: 'If this email is registered, a code has been sent. Please check your inbox.',
register: 'Register',
registerAccount: 'Register account',
registerDescription: 'Create an account, verify your email, then enter the console automatically.',
resend: 'Resend code',
resendCountdown: 'Resend ({{seconds}}s)',
resendSuccess: 'Verification code resent.',
resetPassword: 'Reset password',
sendCode: 'Send code',
updateEmail: 'Change email',
username: 'Username',
usernameHint: '3-50 characters',
verificationSent: 'Verification code sent to your email.',
verifyAndLogin: 'Verify and log in',
verifyEmail: 'Verify email',
verifyEmailDescription: 'Enter the email verification code to log in and open the console.',
welcomeBack: 'Welcome back',
shell: {
product: 'Planet',
subtitle: 'Operations Console',
kicker: 'Modern console',
title: 'Bring data, alerts, AI, and Earth operations into one focused workspace.',
description: 'The console opens the modern workflow by default. Use `/admin` after login.',
},
},
docs: {
brandTitle: 'Intelligent Planet Docs',
brandSubtitle: 'Developer & User Guide',
documentUnavailable: 'Document unavailable',
docs: 'Docs',
footerLanguage: 'Language',
footerTheme: 'Theme',
loading: 'Loading document...',
loginRequired: 'Login required',
loginRequiredDescription: 'This document requires login and the matching Gatekeeper permission group.',
goToLogin: 'Go to login',
forbidden: 'Permission required',
forbiddenDescription: 'Your account does not have the Gatekeeper permission group required for this document.',
notFound: 'Document not found',
notFoundDescription: 'The requested guide does not exist or is not available in the current language.',
returnOverview: 'Return to docs overview',
searchLabel: 'Search docs',
searchPlaceholder: 'Search guides, APIs, layers...',
searchEmpty: 'No matching docs',
toc: 'On this page',
tocEmpty: 'No sections',
},
markdown: {
copyCode: 'Copy code',
copied: 'Copied',
copiedCode: 'Code copied',
copyChartSource: 'Copy chart source',
copiedChartSource: 'Chart source copied',
expandMermaid: 'Expand Mermaid diagram',
clickToExpand: 'Click to expand',
closeMermaid: 'Close Mermaid diagram viewer',
mermaidViewer: 'Mermaid diagram viewer',
mermaidRenderFailed: 'Mermaid render failed',
viewerHint: 'Drag to pan · Scroll to zoom · Click blank space to close',
},
users: {
actions: 'Actions',
active: 'Active',
addUser: 'Add user',
clearSearch: 'Clear search',
confirmDelete: 'Confirm deletion',
confirmDeleteDescription: 'Delete user {{username}}?',
createSuccess: 'Created',
deleteFailed: 'Delete failed',
deleteSuccess: 'Deleted',
description: 'Maintain console accounts, roles, and Docs permission groups.',
disabled: 'Disabled',
edit: 'Edit',
editUser: 'Edit user',
gatekeeperGroups: 'Gatekeeper groups',
retryLater: 'Please try again later',
role: 'Role',
searchPlaceholder: 'Search users, email, or role',
status: 'Status',
submit: 'Submit',
unconfigured: 'Not configured',
updateSuccess: 'Updated',
roles: {
super_admin: 'Super admin',
admin: 'Admin',
operator: 'Operator',
viewer: 'Viewer',
},
gatekeeper: {
docs_user: 'Docs: user docs',
docs_developer: 'Docs: developer docs',
docs_admin: 'Docs: admin / ops docs',
},
},
}
export const resources = {
'zh-CN': { translation: zhCN },
'en-US': { translation: enUS },
} as const

View File

@@ -63,6 +63,7 @@ select {
}
.auth-shell__panel {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
@@ -70,6 +71,13 @@ select {
padding: clamp(28px, 5vw, 56px);
}
.auth-shell__language {
position: absolute;
top: 34px;
right: 34px;
width: 118px;
}
.auth-shell__brand {
position: absolute;
top: 34px;

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { registerAdminRuntimeErrorHandlers } from './admin/runtimeLogs'
import './i18n'
import './index.css'
registerAdminRuntimeErrorHandlers()

View File

@@ -1,6 +1,9 @@
import { ArrowLeft, Loader2, Sparkles } from 'lucide-react'
import { type FormEvent, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import { localeOptions, useLocale, type SupportedLocale } from '../../i18n/locale'
interface AuthShellProps {
eyebrow: string
@@ -11,14 +14,30 @@ interface AuthShellProps {
}
export function AuthShell({ eyebrow, title, description, children, aside }: AuthShellProps) {
const { t } = useTranslation()
const { locale, setLocale } = useLocale()
const languageOptions = localeOptions.map((option) => ({
value: option.value,
label: t(option.labelKey),
title: t(option.titleKey),
}))
return (
<main className="auth-shell">
<section className="auth-shell__panel">
<SegmentedControl<SupportedLocale>
ariaLabel={t('common.language')}
className="auth-shell__language"
options={languageOptions}
scale={0.78}
value={locale}
onChange={setLocale}
/>
<div className="auth-shell__brand">
<span className="auth-shell__logo"><Sparkles size={20} /></span>
<div>
<strong>Planet</strong>
<span>Operations Console</span>
<strong>{t('auth.shell.product')}</strong>
<span>{t('auth.shell.subtitle')}</span>
</div>
</div>
<div className="auth-shell__heading">
@@ -31,9 +50,9 @@ export function AuthShell({ eyebrow, title, description, children, aside }: Auth
<aside className="auth-shell__aside">
{aside || (
<>
<span className="auth-shell__aside-kicker"></span>
<h2>AI Earth </h2>
<p>使 `/admin` </p>
<span className="auth-shell__aside-kicker">{t('auth.shell.kicker')}</span>
<h2>{t('auth.shell.title')}</h2>
<p>{t('auth.shell.description')}</p>
</>
)}
</aside>
@@ -95,10 +114,12 @@ export function AuthLinks({ children }: { children: ReactNode }) {
}
export function BackToLogin() {
const { t } = useTranslation()
return (
<Link className="auth-link auth-link--back" to="/login">
<ArrowLeft size={15} />
{t('auth.backToLogin')}
</Link>
)
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate, useParams } from 'react-router-dom'
import axios from 'axios'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import { localeFromDocsLang, useLocale } from '../../i18n/locale'
import { useAuthStore } from '../../stores/auth'
import {
createHeadingIdResolver,
@@ -46,11 +48,6 @@ function getHashFromHref(href: string): string {
return hashIndex >= 0 ? href.slice(hashIndex) : ''
}
function readStoredLang(): DocsLang {
const stored = localStorage.getItem('docs-lang')
return stored === 'en' ? 'en' : 'zh'
}
function readStoredThemeMode(): DocsThemeMode {
const stored = localStorage.getItem('docs-theme')
if (stored === 'system' || stored === 'light' || stored === 'dark') {
@@ -69,9 +66,11 @@ function getSystemTheme(): 'light' | 'dark' {
export default function Docs() {
const { slug } = useParams()
const navigate = useNavigate()
const { t } = useTranslation()
const { docsLang, setLocale } = useLocale()
const { token } = useAuthStore()
const lang = docsLang
const [lang, setLang] = useState<DocsLang>(readStoredLang)
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
const [catalogItems, setCatalogItems] = useState<DocsCatalogItem[]>([])
@@ -94,14 +93,14 @@ export default function Docs() {
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
const langOptions = useMemo(() => [
{ value: 'zh' as const, label: '中文' },
{ value: 'en' as const, label: 'EN' },
], [])
{ value: 'zh' as const, label: t('common.zh') },
{ value: 'en' as const, label: t('common.en') },
], [t])
const themeOptions = useMemo(() => [
{
value: 'light' as const,
label: lang === 'zh' ? '浅色' : 'Light',
title: lang === 'zh' ? '浅色' : 'Light',
label: t('common.themeLight'),
title: t('common.themeLight'),
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
@@ -111,8 +110,8 @@ export default function Docs() {
},
{
value: 'system' as const,
label: lang === 'zh' ? '系统' : 'System',
title: lang === 'zh' ? '跟随系统' : 'Follow system',
label: t('common.themeSystem'),
title: t('common.themeFollowSystem'),
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
@@ -122,20 +121,19 @@ export default function Docs() {
},
{
value: 'dark' as const,
label: lang === 'zh' ? '深色' : 'Dark',
title: lang === 'zh' ? '深色' : 'Dark',
label: t('common.themeDark'),
title: t('common.themeDark'),
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
),
},
], [lang])
], [t])
const handleLangChange = useCallback((newLang: DocsLang) => {
setLang(newLang)
localStorage.setItem('docs-lang', newLang)
}, [])
setLocale(localeFromDocsLang(newLang))
}, [setLocale])
const handleThemeModeChange = useCallback((nextMode: DocsThemeMode) => {
setThemeMode(nextMode)
@@ -313,10 +311,10 @@ export default function Docs() {
<span className="docs-brand__mark"></span>
<span>
<span className="docs-brand__title">
{lang === 'zh' ? '智能星球文档' : 'Intelligent Planet Docs'}
{t('docs.brandTitle')}
</span>
<span className="docs-brand__subtitle">
{lang === 'zh' ? '开发者和用户手册' : 'Developer & User Guide'}
{t('docs.brandSubtitle')}
</span>
</span>
</Link>
@@ -359,7 +357,7 @@ export default function Docs() {
<footer className="docs-sidebar-footer">
<div className="docs-footer-row docs-footer-row--language">
<SegmentedControl
ariaLabel="Language"
ariaLabel={t('docs.footerLanguage')}
className="docs-lang-toggle"
options={langOptions}
scale={FOOTER_CONTROL_SCALE}
@@ -370,7 +368,7 @@ export default function Docs() {
<div className="docs-footer-row">
<SegmentedControl
ariaLabel="Theme"
ariaLabel={t('docs.footerTheme')}
className="docs-theme-toggle"
options={themeOptions}
scale={FOOTER_CONTROL_SCALE}
@@ -385,16 +383,16 @@ export default function Docs() {
<header className="docs-header">
<div>
<p className="docs-header__eyebrow">
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : lang === 'zh' ? '文档' : 'Docs'}
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : t('docs.docs')}
</p>
<h1 className="docs-header__title">
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')}
{activeHeaderEntry?.title || t('docs.documentUnavailable')}
</h1>
</div>
<div className="docs-search" ref={searchRef}>
<label className="docs-search__label" htmlFor="docs-search-input">
{lang === 'zh' ? '搜索文档' : 'Search docs'}
{t('docs.searchLabel')}
</label>
<input
id="docs-search-input"
@@ -409,7 +407,7 @@ export default function Docs() {
setIsSearchOpen(true)
}
}}
placeholder={lang === 'zh' ? '搜索文档...' : 'Search guides, APIs, layers...'}
placeholder={t('docs.searchPlaceholder')}
type="search"
/>
{shouldShowSearchResults && (
@@ -432,7 +430,7 @@ export default function Docs() {
))
) : (
<div className="docs-search__empty">
{lang === 'zh' ? '未找到匹配文档' : 'No matching docs'}
{t('docs.searchEmpty')}
</div>
)}
</Scrollbar>
@@ -445,7 +443,7 @@ export default function Docs() {
<Scrollbar className="docs-article" viewportRef={articleRef}>
{isCatalogLoading || isLoading ? (
<div className="docs-state">
{lang === 'zh' ? '加载中...' : 'Loading document...'}
{t('docs.loading')}
</div>
) : docError === 'none' ? (
<MarkdownRenderer
@@ -458,33 +456,27 @@ export default function Docs() {
<div className="docs-not-found">
{docError === 'unauthenticated' ? (
<>
<h2>{lang === 'zh' ? '需要登录' : 'Login required'}</h2>
<h2>{t('docs.loginRequired')}</h2>
<p>
{lang === 'zh'
? '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。'
: 'This document requires login and the matching Gatekeeper permission group.'}
{t('docs.loginRequiredDescription')}
</p>
<Link to="/admin">{lang === 'zh' ? '前往登录' : 'Go to login'}</Link>
<Link to="/admin">{t('docs.goToLogin')}</Link>
</>
) : docError === 'forbidden' ? (
<>
<h2>{lang === 'zh' ? '无权访问' : 'Permission required'}</h2>
<h2>{t('docs.forbidden')}</h2>
<p>
{lang === 'zh'
? '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。'
: 'Your account does not have the Gatekeeper permission group required for this document.'}
{t('docs.forbiddenDescription')}
</p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
<Link to="/docs">{t('docs.returnOverview')}</Link>
</>
) : (
<>
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
<h2>{t('docs.notFound')}</h2>
<p>
{lang === 'zh'
? '请求的文档不存在,或当前语言没有对应内容。'
: 'The requested guide does not exist or is not available in the current language.'}
{t('docs.notFoundDescription')}
</p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
<Link to="/docs">{t('docs.returnOverview')}</Link>
</>
)}
</div>
@@ -494,7 +486,7 @@ export default function Docs() {
<aside className="docs-toc" aria-label="Document table of contents">
<Scrollbar className="docs-toc__inner">
<h2 className="docs-toc__title">
{lang === 'zh' ? '本页目录' : 'On this page'}
{t('docs.toc')}
</h2>
{headings.length > 0 ? (
<nav className="docs-toc__nav">
@@ -515,7 +507,7 @@ export default function Docs() {
</nav>
) : (
<p className="docs-toc__empty">
{lang === 'zh' ? '暂无章节' : 'No sections'}
{t('docs.tocEmpty')}
</p>
)}
</Scrollbar>

View File

@@ -1,5 +1,6 @@
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'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -10,15 +11,16 @@ interface ErrorBody {
}
}
function extractDetail(error: unknown): string {
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 || '操作失败'
return '操作失败'
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')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
@@ -41,9 +43,9 @@ function ForgotPassword() {
await axios.post(`${API_URL}/auth/forgot-password`, { email })
setStep('reset')
setCooldown(60)
setFeedback({ tone: 'success', text: '若该邮箱已注册,验证码已发送。请到邮箱查收。' })
setFeedback({ tone: 'success', text: t('auth.recoveryCodeSent') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -58,9 +60,9 @@ function ForgotPassword() {
setStep('request')
setCode('')
setNewPassword('')
setFeedback({ tone: 'success', text: '密码已重置,请用新密码登录。' })
setFeedback({ tone: 'success', text: t('auth.passwordResetSuccess') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -71,38 +73,38 @@ function ForgotPassword() {
try {
await axios.post(`${API_URL}/auth/forgot-password`, { email })
setCooldown(60)
setFeedback({ tone: 'success', text: '验证码已重发。' })
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
}
}
return (
<AuthShell eyebrow="Account recovery" title="找回密码" description="通过邮箱验证码重置后台账号密码。">
<AuthShell eyebrow={t('auth.accountRecovery')} title={t('auth.forgotPasswordTitle')} description={t('auth.forgotPasswordDescription')}>
{step === 'request' ? (
<AuthForm onSubmit={onRequest}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="邮箱">
<AuthField label={t('auth.email')}>
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required />
</AuthField>
<AuthButton type="submit" loading={loading}></AuthButton>
<AuthButton type="submit" loading={loading}>{t('auth.sendCode')}</AuthButton>
<AuthLinks><BackToLogin /></AuthLinks>
</AuthForm>
) : (
<AuthForm onSubmit={onReset}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthNotice> <strong>{email}</strong>10 </AuthNotice>
<AuthField label="验证码">
<AuthNotice><Trans i18nKey="auth.codeSent" values={{ email }} components={{ strong: <strong /> }} /></AuthNotice>
<AuthField label={t('auth.code')}>
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
</AuthField>
<AuthField label="新密码">
<AuthField label={t('auth.newPassword')}>
<AuthInput value={newPassword} onChange={(event) => setNewPassword(event.target.value)} type="password" minLength={8} autoComplete="new-password" required />
</AuthField>
<AuthButton type="submit" loading={loading}></AuthButton>
<AuthButton type="submit" loading={loading}>{t('auth.resetPassword')}</AuthButton>
<AuthLinks>
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('request')}></button>
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('request')}>{t('auth.updateEmail')}</button>
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
{cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
</button>
</AuthLinks>
</AuthForm>

View File

@@ -1,4 +1,5 @@
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 { useAuthStore } from '../../stores/auth'
@@ -10,6 +11,7 @@ interface LoginError {
}
function Login() {
const { t } = useTranslation()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
@@ -23,38 +25,38 @@ function Login() {
setFeedback(null)
try {
await login(username.trim(), password)
setFeedback({ tone: 'success', text: '登录成功,正在进入控制台。' })
setFeedback({ tone: 'success', text: t('auth.loginSuccess') })
navigate('/admin', { replace: true })
} catch (error: unknown) {
const err = error as LoginError
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.code === 'EMAIL_NOT_VERIFIED') {
setFeedback({ tone: 'warning', text: '邮箱未验证,请先完成邮箱验证。' })
setFeedback({ tone: 'warning', text: t('auth.emailNotVerified') })
const email = detail.email ? `?email=${encodeURIComponent(detail.email)}` : ''
navigate(`/verify-email${email}`)
return
}
const fallback = typeof detail === 'string' ? detail : detail?.message
setFeedback({ tone: 'error', text: fallback || '登录失败,请检查账号或密码。' })
setFeedback({ tone: 'error', text: fallback || t('auth.loginFailed') })
} finally {
setLoading(false)
}
}
return (
<AuthShell eyebrow="Welcome back" title="登录 Planet 控制台" description="使用你的后台账号进入运维工作台。">
<AuthShell eyebrow={t('auth.welcomeBack')} title={t('auth.loginTitle')} description={t('auth.loginDescription')}>
<AuthForm onSubmit={onSubmit}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="用户名">
<AuthField label={t('auth.username')}>
<AuthInput value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" required autoFocus />
</AuthField>
<AuthField label="密码">
<AuthField label={t('auth.password')}>
<AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" autoComplete="current-password" required />
</AuthField>
<AuthButton type="submit" loading={loading}></AuthButton>
<AuthButton type="submit" loading={loading}>{t('auth.loginButton')}</AuthButton>
<AuthLinks>
<Link className="auth-link" to="/register"></Link>
<Link className="auth-link" to="/forgot-password"></Link>
<Link className="auth-link" to="/register">{t('auth.registerAccount')}</Link>
<Link className="auth-link" to="/forgot-password">{t('auth.forgotPassword')}</Link>
</AuthLinks>
</AuthForm>
</AuthShell>

View File

@@ -1,5 +1,6 @@
import axios from 'axios'
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 { useAuthStore } from '../../stores/auth'
@@ -15,15 +16,16 @@ interface ErrorBody {
}
}
function extractDetail(error: unknown): string {
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 || '操作失败'
return '操作失败'
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return fallback
}
function Register() {
const { t } = useTranslation()
const navigate = useNavigate()
const [step, setStep] = useState<'register' | 'verify'>('register')
const [username, setUsername] = useState('')
@@ -49,9 +51,9 @@ function Register() {
await axios.post(`${API_URL}/auth/register`, { username: username.trim(), email: email.trim(), password })
setStep('verify')
setCooldown(RESEND_COOLDOWN_SECONDS)
setFeedback({ tone: 'success', text: '验证码已发送到邮箱。' })
setFeedback({ tone: 'success', text: t('auth.verificationSent') })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -68,7 +70,7 @@ function Register() {
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
navigate('/admin', { replace: true })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -81,46 +83,46 @@ function Register() {
try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(RESEND_COOLDOWN_SECONDS)
setFeedback({ tone: 'success', text: '验证码已重发。' })
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
} catch (error) {
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) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally {
setResending(false)
}
}
return (
<AuthShell eyebrow="Create account" title={step === 'register' ? '注册账户' : '验证邮箱'} description="创建账号后需要完成邮箱验证,验证成功会自动进入控制台。">
<AuthShell eyebrow={t('auth.createAccount')} title={step === 'register' ? t('auth.registerAccount') : t('auth.verifyEmail')} description={t('auth.registerDescription')}>
{step === 'register' ? (
<AuthForm onSubmit={onRegister}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="用户名" hint="3-50 个字符">
<AuthField label={t('auth.username')} hint={t('auth.usernameHint')}>
<AuthInput value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={50} required autoComplete="username" />
</AuthField>
<AuthField label="邮箱">
<AuthField label={t('auth.email')}>
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" required autoComplete="email" />
</AuthField>
<AuthField label="密码" hint="至少 8 位">
<AuthField label={t('auth.password')} hint={t('auth.passwordHint')}>
<AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" minLength={8} required autoComplete="new-password" />
</AuthField>
<AuthButton type="submit" loading={loading}></AuthButton>
<AuthLinks><Link className="auth-link" to="/login"></Link></AuthLinks>
<AuthButton type="submit" loading={loading}>{t('auth.register')}</AuthButton>
<AuthLinks><Link className="auth-link" to="/login">{t('auth.alreadyHaveAccount')}</Link></AuthLinks>
</AuthForm>
) : (
<AuthForm onSubmit={onVerify}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthNotice> 6 <strong>{email}</strong>10 </AuthNotice>
<AuthField label="验证码">
<AuthNotice><Trans i18nKey="auth.codeSent" values={{ email }} components={{ strong: <strong /> }} /></AuthNotice>
<AuthField label={t('auth.code')}>
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
</AuthField>
<AuthButton type="submit" loading={loading}></AuthButton>
<AuthButton type="submit" loading={loading}>{t('auth.verifyAndLogin')}</AuthButton>
<AuthLinks>
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('register')}></button>
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('register')}>{t('auth.updateEmail')}</button>
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
{cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
</button>
</AuthLinks>
</AuthForm>

View File

@@ -1,5 +1,6 @@
import axios from 'axios'
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 { useAuthStore } from '../../stores/auth'
@@ -12,15 +13,16 @@ interface ErrorBody {
}
}
function extractDetail(error: unknown): string {
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 || '操作失败'
return '操作失败'
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
return fallback
}
function VerifyEmail() {
const { t } = useTranslation()
const navigate = useNavigate()
const [search] = useSearchParams()
const [email, setEmail] = useState(search.get('email') || '')
@@ -47,7 +49,7 @@ function VerifyEmail() {
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
navigate('/admin', { replace: true })
} catch (error) {
setFeedback({ tone: 'error', text: extractDetail(error) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally {
setLoading(false)
}
@@ -60,32 +62,32 @@ function VerifyEmail() {
try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(60)
setFeedback({ tone: 'success', text: '验证码已重发。' })
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
} catch (error) {
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) })
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
} finally {
setResending(false)
}
}
return (
<AuthShell eyebrow="Email verification" title="验证邮箱" description="输入邮箱验证码后会自动登录并进入控制台。">
<AuthShell eyebrow={t('auth.emailVerification')} title={t('auth.verifyEmail')} description={t('auth.verifyEmailDescription')}>
<AuthForm onSubmit={onVerify}>
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
<AuthField label="邮箱">
<AuthField label={t('auth.email')}>
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required />
</AuthField>
<AuthField label="验证码">
<AuthField label={t('auth.code')}>
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
</AuthField>
<AuthButton type="submit" loading={loading} disabled={!email}></AuthButton>
<AuthButton type="submit" loading={loading} disabled={!email}>{t('auth.verifyAndLogin')}</AuthButton>
<AuthLinks>
<BackToLogin />
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending || !email} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
{cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
</button>
</AuthLinks>
</AuthForm>