diff --git a/AGENTS.md b/AGENTS.md index 93b0eb58..dd94c86f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,10 @@ the user's login interactive shell instead of assuming a specific dotfile. rendered smoke evidence for public pages, auth guards, authenticated admin route/section availability, safe navigation/search/tab interactions, mobile layout, and 125% / 150% zoom, not only a build. +- Admin or Docs layout changes must load `rules.md` `uiux` and preserve the + one-screen (`一屏` / `首屏`) height chain: route roots use `height: 100%`, + intermediate wrappers keep `min-height: 0`, and only the intended child owns + scrolling. - `aiprovider` is a protocol/provider adapter; keep business prompts and product workflows in the backend. - Earth rendering depends on layer order, depth behavior, picking, and diff --git a/VERSION b/VERSION index 7375dee5..31b648bd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.72.0 +0.73.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f7269a30..fdde7b1c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,24 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.73.0] — 2026-06-29 + +Released: 2026-06-29 + +### Highlights +- 新增前端统一 i18n 基础设施,让认证页、Docs UI、控制台外壳、导航、搜索和核心共享组件共用 `zh-CN` / `en-US` 语言状态。 +- 控制台侧边栏偏好面板接入语言与主题切换,并修复一屏高度链、账号区、状态指示器和英文态文案裁切问题。 +- 扩展 harness 与 smoke 覆盖,确保 admin shell 高度、移动/缩放布局、语言切换、搜索、Docs 和核心控制台交互在发布前被验证。 + +### Added / Fixed / Improved +- 新增 `frontend/src/i18n/`,用 `i18next` / `react-i18next` 维护资源、locale 映射、Docs 兼容和过渡期 legacy UI 翻译桥。 +- 将 AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast、MarkdownRenderer 和 Users 页迁移到统一翻译资源。 +- 补齐 Planet Content、Collected Data、System Logs、Datasources、Settings 和 Collection Management 等英文态残留翻译,并覆盖动态计数字符串。 +- 改进控制台侧边栏账号区、语言 switch、状态 pill 自适应宽度和 admin shell overflow ownership,避免首屏溢出和状态词裁切。 +- 更新 i18n 计划、控制台前端上下文、harness 文档和规则,记录语言迁移边界、状态指示器布局约束和一屏验证要求。 + +--- + ## [0.72.0] — 2026-06-29 Released: 2026-06-29 diff --git a/docs/HARNESS.md b/docs/HARNESS.md index 7df8f215..e68fbe35 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -24,6 +24,11 @@ static checks, Playwright smoke coverage, and remaining manual review areas, so an agent can distinguish a proved harness pass from a rule that still needs human-quality inspection. +When the user describes work with product words rather than module names, use +the `rules.md` **Agent Discovery Index** before deciding which modules to load. +It maps Chinese phrases such as `一屏`, `高度没控住`, `文档`, `数据源`, +`地球`, `模型供应商`, and `发版` to the required rule modules. + ## Starting Work Recommended startup flow: @@ -103,7 +108,12 @@ and authenticated `super_admin` rendering for every admin route plus core Authenticated admin checks run at desktop size, mobile size, and 125% / 150% zoom; desktop and mobile passes also fail on global horizontal overflow so table/detail panels -must keep overflow ownership inside their own scroll regions. The smoke also +must keep overflow ownership inside their own scroll regions. To enforce the +existing `rules.md` `uiux` one-screen workspace rule, admin shell pages have a +hard rendered check: the shell must resolve to the viewport height through the +root 100% height chain, `#root`/document/body must not gain vertical overflow, +and the desktop sidebar account/preferences area must remain inside the first +viewport while the nav owns any excess scrolling. The smoke also derives the sidebar menu from the actual admin route manifest and clicks every visible `super_admin` menu entry on both desktop and mobile viewports, then exercises safe interaction paths for admin search, section tabs, the AI settings diff --git a/docs/harness-audit.md b/docs/harness-audit.md index aa250589..02bc1595 100644 --- a/docs/harness-audit.md +++ b/docs/harness-audit.md @@ -120,6 +120,10 @@ command fails when the rule regresses. "Smoke" means the rendered product route or interaction is opened with Playwright. "Manual" means the rule is still a judgment call and must be inspected during review. +Before using this matrix, start from `rules.md`'s **Agent Discovery Index** when +the user describes work with Chinese/product terms instead of module names. The +index is the routing layer; this table is the coverage/evidence layer. + | `rules.md` Area | Rule Surface | Harness Evidence | Remaining Review | | --- | --- | --- | --- | | `core` | Remove stale transitional paths, duplicated helpers, and naming drift after large changes. | `scripts/harness/docs-consistency-check.sh` blocks known stale stack terms, old `?tab=` links, public Docs metadata drift, and README/project context drift. `scripts/harness/frontend-rules-check.sh` blocks repeated detached AI/WebSearch connection-test buttons by requiring `ConnectionTestInput`. | Naming quality, function size, and whether a new abstraction is worth keeping remain manual review items. | @@ -129,7 +133,7 @@ judgment call and must be inspected during review. | `workflow` | Agents should find `bun` and `uv` even when non-interactive `PATH` is incomplete. | `scripts/harness/lib.sh` checks the current `PATH`, then asks `$SHELL`, `zsh`, and `bash` login interactive shells for the command path without hardcoding a dotfile. `doctor.sh`, `quick-check.sh`, and `validate.sh` all source it. | System package installation remains outside harness scope and should be reported instead of auto-fixed. | | `docs` | Keep public Docs whitelist-driven and synchronized with backend authorization metadata. | `docs-consistency-check.sh` compares frontend Docs metadata against backend Gatekeeper metadata, verifies files exist for both languages, checks public link titles, and blocks missing zh/en technical doc pairs. `frontend-smoke.mjs` opens every Chinese Docs catalog slug plus detail/search/language/theme interactions. | Quality of prose, examples, and whether a doc should be public are still editorial review items. | | `docs` | User manuals must match real console routes and deep links. | `docs-consistency-check.sh` compares manual console tables with `frontend/src/admin/routes/manifest.tsx` and validates documented `?section=` links from actual `AdminRoutes.tsx` plus `PlainResourcePages.tsx` section config. | Screenshots and UI-copy nuance are not exhaustively validated. | -| `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` warns on suspicious `overflow: hidden`, blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS, and `frontend-smoke.mjs` checks every admin route at desktop, mobile, and 125% / 150% zoom. Desktop/mobile smoke also fails global horizontal overflow. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. | +| `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` fails missing admin shell height-chain declarations (`.admin-theme-root`, `.admin`, `.admin__sider`, `.admin__nav-scroll`, `.admin__account`, `.admin__content`, `.admin__content-inner`), warns on suspicious `overflow: hidden`, and blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS. `frontend-smoke.mjs` checks every admin route at desktop/mobile and verifies `.admin` equals viewport height, `#root`/document/body have no vertical overflow, and desktop sidebar account/preferences stay in the first viewport. Zoom passes still cover 125% / 150% rendering. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. | | `uiux` | Controls use expected patterns and accessible icon buttons. | `frontend-rules-check.sh` blocks icon `Button` without `aria-label` and `title`, native ` diff --git a/frontend/src/admin/components/layout/AdminLayout.tsx b/frontend/src/admin/components/layout/AdminLayout.tsx index 696edbc0..d8aada2b 100644 --- a/frontend/src/admin/components/layout/AdminLayout.tsx +++ b/frontend/src/admin/components/layout/AdminLayout.tsx @@ -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(cachedOpenKeys) const [searchQuery, setSearchQuery] = useState('') const [searchOpen, setSearchOpen] = useState(false) + const [preferencesOpen, setPreferencesOpen] = useState(false) const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0) const menuViewportRef = useRef(null) const searchInputRef = useRef(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: }, - { value: 'system' as const, label: '系统', title: '跟随系统', icon: }, - { value: 'dark' as const, label: '深色', title: '深色', icon: }, - ], []) + { value: 'light' as const, label: t('common.themeLight'), title: t('common.themeLight'), icon: }, + { value: 'system' as const, label: t('common.themeSystem'), title: t('common.themeFollowSystem'), icon: }, + { value: 'dark' as const, label: t('common.themeDark'), title: t('common.themeDark'), icon: }, + ], [t]) + const languageOptions = useMemo(() => localeOptions.map((option) => ({ + value: option.value, + label: t(option.labelKey), + title: t(option.titleKey), + icon: , + })), [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 ? : } {!collapsed ? (
- 智能星球 - 控制台 + {t('admin.brandTitle')} + {t('admin.brandSubtitle')}
) : null} @@ -186,36 +206,61 @@ export function AdminLayout({ children }: { children: ReactNode }) { {!collapsed ? (
-
-
- Hi, {user?.username || '-'} +
+
+ +
+ {t('admin.greeting', { name: username })} + {t('admin.version')} v{packageJson.version} +
+
+
+ +
-
-
- 版本号 - v{packageJson.version} +
+
+ + ariaLabel={t('admin.languageControl')} + className="admin__language-control admin__language-control--sider" + options={languageOptions} + scale={0.86} + value={locale} + onChange={setLocale} + /> + + ariaLabel={t('admin.themeControl')} + className="admin__theme-control admin__theme-control--sider" + options={themeOptions} + scale={0.86} + value={mode} + onChange={setMode} + /> +
- - ariaLabel="控制台主题" - className="admin__theme-control admin__theme-control--sider" - options={themeOptions} - scale={0.72} - value={mode} - onChange={setMode} - />
) : null} @@ -250,22 +295,22 @@ export function AdminLayout({ children }: { children: ReactNode }) { {mobileNavOpen ? (
{nav}
-
) : null}
-
{ setSearchQuery(event.target.value) setSearchOpen(true) @@ -275,7 +320,7 @@ export function AdminLayout({ children }: { children: ReactNode }) { onKeyDown={handleSearchKeyDown} /> {searchOpen ? ( -
+
{searchResults.length > 0 ? searchResults.map((target, index) => { const ResultIcon = target.icon || Search return ( @@ -297,7 +342,7 @@ export function AdminLayout({ children }: { children: ReactNode }) { ) }) : ( -
{adminSearch.loading ? '正在加载搜索索引…' : '没有找到匹配内容'}
+
{adminSearch.loading ? t('admin.search.loading') : t('admin.search.empty')}
)}
) : null} diff --git a/frontend/src/admin/components/ui/dialog.tsx b/frontend/src/admin/components/ui/dialog.tsx index 48026f2d..72f9f9db 100644 --- a/frontend/src/admin/components/ui/dialog.tsx +++ b/frontend/src/admin/components/ui/dialog.tsx @@ -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 ( @@ -30,7 +33,7 @@ export function Dialog({ open, onOpenChange, title, description, children, foote ) : null}
- @@ -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 ( )} > - {description || '请确认本次操作。'} + {description || t('common.confirm')} ) } diff --git a/frontend/src/admin/components/ui/toast.tsx b/frontend/src/admin/components/ui/toast.tsx index f6dc51d1..91c4c65a 100644 --- a/frontend/src/admin/components/ui/toast.tsx +++ b/frontend/src/admin/components/ui/toast.tsx @@ -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(null) export function ToastProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation() const [items, setItems] = useState([]) const toast = useCallback((item: Omit & { tone?: ToastTone }) => { @@ -46,7 +48,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { {item.description} ) : null} - + diff --git a/frontend/src/admin/pages/Users.tsx b/frontend/src/admin/pages/Users.tsx index 28590bbc..9c181b8e 100644 --- a/frontend/src/admin/pages/Users.tsx +++ b/frontend/src/admin/pages/Users.tsx @@ -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 +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([]) @@ -74,6 +57,23 @@ export default function Users() { const [deleteTarget, setDeleteTarget] = useState(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({ 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>>(() => [ { 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 }) => {roleLabel(row.original.role)}, }, @@ -175,30 +175,30 @@ export default function Users() { {gatekeeperLabel(group)} - )) : 未配置} + )) : {t('users.unconfigured')}}
) }, }, { accessorKey: 'is_active', - header: '状态', + header: t('users.status'), size: 120, - cell: ({ row }) => {row.original.is_active ? '活跃' : '禁用'}, + cell: ({ row }) => {row.original.is_active ? t('users.active') : t('users.disabled')}, }, { id: 'actions', - header: '操作', + header: t('users.actions'), size: 148, enableSorting: false, cell: ({ row }) => (
- - + +
), }, - ], []) + ], [gatekeeperOptions, roleOptions, t]) const filteredUsers = useMemo(() => { const keyword = searchText.trim().toLowerCase() @@ -218,8 +218,8 @@ export default function Users() {
-

用户管理

-

维护后台账号、角色与文档权限组。

+

{t('admin.routes.users')}

+

{t('users.description')}

@@ -227,15 +227,15 @@ export default function Users() { setSearchText(event.target.value)} - placeholder="搜索用户、邮箱、角色" + placeholder={t('users.searchPlaceholder')} /> {searchText ? ( - ) : null}
- +
@@ -244,40 +244,40 @@ export default function Users() {
- - + + )} >
{!editingUser ? ( ) : null}
@@ -494,7 +486,7 @@ export default function Docs() {