release: bump version to 0.73.0
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "26.3.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"pbf": "^4.0.1",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"simplex-noise": "^4.0.1",
|
||||
@@ -84,6 +86,8 @@
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
@@ -556,6 +560,10 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["i18next@26.3.3", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -632,6 +640,8 @@
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.76.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
@@ -702,6 +712,8 @@
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.72.0",
|
||||
"version": "0.73.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
@@ -20,6 +20,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "26.3.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"pbf": "^4.0.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"simplex-noise": "^4.0.1",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
159
frontend/src/i18n/LegacyI18nBridge.tsx
Normal file
159
frontend/src/i18n/LegacyI18nBridge.tsx
Normal 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
|
||||
}
|
||||
27
frontend/src/i18n/index.ts
Normal file
27
frontend/src/i18n/index.ts
Normal 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
|
||||
589
frontend/src/i18n/legacy-ui.ts
Normal file
589
frontend/src/i18n/legacy-ui.ts
Normal 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',
|
||||
}
|
||||
68
frontend/src/i18n/locale.ts
Normal file
68
frontend/src/i18n/locale.ts
Normal 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 }
|
||||
}
|
||||
430
frontend/src/i18n/resources.ts
Normal file
430
frontend/src/i18n/resources.ts
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user