From 7e183c0cdc120fa58fd802361c2efe973699fed1 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 23 Mar 2026 09:59:35 +0800 Subject: [PATCH] =?UTF-8?q?R29-1=20R29-6:=20=E9=87=8D=E6=9E=84=E6=B5=B7?= =?UTF-8?q?=E9=9F=B3=E8=BE=BE=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=A3=B3=E5=B1=82?= =?UTF-8?q?=E4=B8=8E=E9=AB=98=E9=A2=91=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/auth/AuthContext.tsx | 49 +- .../src/components/common/EmptyStateCard.tsx | 12 +- client/src/components/common/PageHeader.tsx | 22 +- client/src/components/navigation/NavLinks.tsx | 116 ++- client/src/components/navigation/Sidebar.tsx | 17 +- client/src/components/navigation/Topbar.tsx | 68 +- .../components/patterns/DetailSideMeta.tsx | 10 + .../patterns/EntityOverviewHero.tsx | 24 + .../src/components/patterns/ListWorkspace.tsx | 22 + client/src/components/patterns/MetricCard.tsx | 47 ++ .../src/components/patterns/PageScaffold.tsx | 6 + .../src/components/patterns/SectionPanel.tsx | 29 + .../src/components/patterns/WorkbenchHero.tsx | 24 + client/src/components/shell/AppBrand.tsx | 13 + client/src/components/ui/button.tsx | 13 +- client/src/design/theme.css | 25 + client/src/design/tokens.ts | 41 + client/src/design/types.ts | 33 + client/src/features/navigation/schema.ts | 41 + client/src/features/portal/schema.ts | 13 + client/src/index.css | 149 +++- client/src/layouts/AppShellLayout.tsx | 31 +- client/src/layouts/AuthLayout.tsx | 33 +- client/src/pages/LeaveRequestPage.tsx | 573 ++++++------- client/src/pages/LoginPage.tsx | 224 +++--- client/src/pages/PortalHomePage.tsx | 369 ++++----- client/src/pages/ProfilePage.tsx | 241 +++--- client/src/pages/ProjectDetailPage.tsx | 759 +++++++++--------- client/src/pages/ProjectsPage.tsx | 112 ++- client/src/pages/ReimbursementsPage.tsx | 286 ++++--- client/src/pages/TodosPage.tsx | 184 +++-- client/src/pages/WeeklyReportsPage.tsx | 161 ++-- ...0-00-r29-frontend-design-system-rework.csv | 10 + 33 files changed, 2193 insertions(+), 1564 deletions(-) create mode 100644 client/src/components/patterns/DetailSideMeta.tsx create mode 100644 client/src/components/patterns/EntityOverviewHero.tsx create mode 100644 client/src/components/patterns/ListWorkspace.tsx create mode 100644 client/src/components/patterns/MetricCard.tsx create mode 100644 client/src/components/patterns/PageScaffold.tsx create mode 100644 client/src/components/patterns/SectionPanel.tsx create mode 100644 client/src/components/patterns/WorkbenchHero.tsx create mode 100644 client/src/components/shell/AppBrand.tsx create mode 100644 client/src/design/theme.css create mode 100644 client/src/design/tokens.ts create mode 100644 client/src/design/types.ts create mode 100644 client/src/features/navigation/schema.ts create mode 100644 client/src/features/portal/schema.ts create mode 100644 issues/2026-03-23_00-10-00-r29-frontend-design-system-rework.csv diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index b363e259..313af91f 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -143,6 +143,7 @@ export function AuthProvider({ children }: PropsWithChildren) { const [state, setState] = useState(EMPTY_STATE); const [offlineAccessMode, setOfflineAccessMode] = useState(false); const tokenRef = useRef(null); + const bootstrapPromiseRef = useRef | null>(null); useEffect(() => { tokenRef.current = state.accessToken; @@ -195,18 +196,28 @@ export function AuthProvider({ children }: PropsWithChildren) { return; } - try { - const refreshed = await refreshSession(); - const me = await fetchCurrentEmployee(refreshed.accessToken); - const mustChangePassword = readMustChangePasswordFromStorage(); - commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); - } catch { - if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { - activateOfflineSnapshot(offlineSnapshot); - return; - } - clearSession(); + if (!bootstrapPromiseRef.current) { + bootstrapPromiseRef.current = (async () => { + try { + const refreshed = await refreshSession(); + const me = await fetchCurrentEmployee(refreshed.accessToken); + const mustChangePassword = readMustChangePasswordFromStorage(); + commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); + return refreshed.accessToken; + } catch { + if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { + activateOfflineSnapshot(offlineSnapshot); + return null; + } + clearSession(); + return null; + } finally { + bootstrapPromiseRef.current = null; + } + })(); } + + await bootstrapPromiseRef.current; }, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]); const login = useCallback( @@ -241,8 +252,22 @@ export function AuthProvider({ children }: PropsWithChildren) { configureHttpAuth({ getAccessToken: () => tokenRef.current, refreshAccessToken: async () => { + if (bootstrapPromiseRef.current) { + const bootstrappedToken = await bootstrapPromiseRef.current; + if (bootstrappedToken) { + return bootstrappedToken; + } + } + try { const refreshed = await refreshSession(); + if (!state.me) { + const me = await fetchCurrentEmployee(refreshed.accessToken); + const mustChangePassword = readMustChangePasswordFromStorage(); + commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); + return refreshed.accessToken; + } + tokenRef.current = refreshed.accessToken; setOfflineAccessMode(false); setState((previous) => ({ @@ -258,7 +283,7 @@ export function AuthProvider({ children }: PropsWithChildren) { }, clearSession, }); - }, [clearSession]); + }, [clearSession, commitAuthenticatedUser, state.me]); useEffect(() => { void bootstrapByRefresh(); diff --git a/client/src/components/common/EmptyStateCard.tsx b/client/src/components/common/EmptyStateCard.tsx index 6663fa55..8ca8f055 100644 --- a/client/src/components/common/EmptyStateCard.tsx +++ b/client/src/components/common/EmptyStateCard.tsx @@ -4,14 +4,16 @@ type EmptyStateCardProps = { title: string; description: string; footer?: ReactNode; + icon?: ReactNode; }; -export function EmptyStateCard({ title, description, footer }: EmptyStateCardProps) { +export function EmptyStateCard({ title, description, footer, icon }: EmptyStateCardProps) { return ( -
-

{title}

-

{description}

- {footer ?
{footer}
: null} +
+ {icon ?
{icon}
: null} +

{title}

+

{description}

+ {footer ?
{footer}
: null}
); } diff --git a/client/src/components/common/PageHeader.tsx b/client/src/components/common/PageHeader.tsx index 226f0e85..ec44de7b 100644 --- a/client/src/components/common/PageHeader.tsx +++ b/client/src/components/common/PageHeader.tsx @@ -1,20 +1,26 @@ import type { ReactNode } from "react"; +import { cn } from "../../lib/utils"; + type PageHeaderProps = { title: string; subtitle?: string; actions?: ReactNode; + eyebrow?: string; + meta?: ReactNode; + className?: string; }; -export function PageHeader({ title, subtitle, actions }: PageHeaderProps) { +export function PageHeader({ title, subtitle, actions, eyebrow, meta, className }: PageHeaderProps) { return ( -
-
-

{title}

- {subtitle ?

{subtitle}

: null} +
+
+ {eyebrow ?
{eyebrow}
: null} +

{title}

+ {subtitle ?

{subtitle}

: null} + {meta ?
{meta}
: null}
- - {actions ?
{actions}
: null} -
+ {actions ?
{actions}
: null} + ); } diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index dc5bd484..07b19f3b 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -3,6 +3,11 @@ import { useQuery } from "@tanstack/react-query"; import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace"; import { useAuth } from "../../auth/AuthContext"; import { hasAnyPermission } from "../../auth/permissions"; +import { + ADMIN_NAV_ORDER, + BACKUP_NAV_ORDER, + MAIN_NAV_ORDER, +} from "../../features/navigation/schema"; import { cn } from "../../lib/utils"; export type NavItem = { @@ -38,15 +43,11 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/invoice-pool", label: "票据中心", icon: "receipt_long", anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"] }, { to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] }, { to: "/system/cutover", label: "切换控制台", icon: "swap_calls", anyOf: ["system.settings"] }, - { to: "/system/integrations/erp", label: "ERP 集成", icon: "hub", anyOf: ["integration.manage"] }, + { to: "/system/integrations/erp", label: "外部系统桥接", icon: "hub", anyOf: ["integration.manage"] }, { to: "/dashboard", label: "仪表盘", icon: "dashboard", anyOf: ["report.dashboard.view"] }, { to: "/settings/profile", label: "个人设置", icon: "manage_accounts" }, ]; -const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"]; -const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]; -const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"]; - const LEGACY_ACCESSIBLE_LABELS: Record = { "/reimbursements": "我的报销", "/workflow/tasks": "待我处理", @@ -61,38 +62,56 @@ type ResolvedNavItem = { }; function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) { + const accessibleLabel = LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label; + return ( onNavigate?.()} className={({ isActive }) => cn( - "flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors", + "group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm transition-colors", isActive - ? "bg-zinc-100 font-bold text-zinc-900" - : "font-medium text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900", + ? "bg-zinc-900 text-white shadow-sm" + : "text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900", ) } > - {item.icon} - {item.label} + {({ isActive }) => ( + <> + + {item.icon} + + {item.label} + + )} ); } -function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) { +function renderSection(title: string, description: string, items: ResolvedNavItem[], onNavigate?: () => void) { if (!items.length) { return null; } return ( -
-
{title}
- {items.map((item) => renderNavItem(item, onNavigate))} -
+
+
+
{title}
+
{description}
+
+
{items.map((item) => renderNavItem(item, onNavigate))}
+
); } @@ -102,14 +121,15 @@ function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void) } return ( -
- - 备用模块 +
+ +
+
备用模块
+
默认收起,不干扰主路径
+
按需展开
-
- {items.map((item) => renderNavItem(item, onNavigate))} -
+
{items.map((item) => renderNavItem(item, onNavigate))}
); } @@ -130,6 +150,7 @@ function buildFallbackItems(permissions: string[]) { function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) { const allItems = [...sections.main, ...sections.admin, ...sections.backup]; const byHref = new Map(); + for (const item of allItems) { if (!byHref.has(item.href)) { byHref.set(item.href, item); @@ -137,38 +158,45 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation } const used = new Set(); - const take = (hrefs: string[]) => hrefs - .map((href) => { - const item = byHref.get(href); - if (item) { - used.add(href); - } - return item; - }) - .filter((item): item is ResolvedNavItem => Boolean(item)); + const take = (hrefs: string[]) => + hrefs + .map((href) => { + const item = byHref.get(href); + if (item) { + used.add(href); + } + return item; + }) + .filter((item): item is ResolvedNavItem => Boolean(item)); - const main = take(MAIN_NAV_ORDER); - const admin = take(ADMIN_NAV_ORDER); - const backup = [ - ...take(BACKUP_NAV_ORDER), - ...allItems.filter((item) => !used.has(item.href)).map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })), - ]; - - return { main, admin, backup }; + return { + main: take(MAIN_NAV_ORDER), + admin: take(ADMIN_NAV_ORDER), + backup: [ + ...take(BACKUP_NAV_ORDER), + ...allItems + .filter((item) => !used.has(item.href)) + .map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })), + ], + }; } export function NavLinks({ onNavigate }: { onNavigate?: () => void }) { const { permissions } = useAuth(); - const navigationQuery = useQuery({ queryKey: ["system-navigation"], queryFn: fetchSystemNavigation, staleTime: 60_000 }); + const navigationQuery = useQuery({ + queryKey: ["system-navigation"], + queryFn: fetchSystemNavigation, + staleTime: 60_000, + }); const sections = navigationQuery.data?.sections ? normalizeSections(navigationQuery.data.sections) : buildFallbackItems(permissions); return ( -