重构前端 Wave0-2 体验基线(05-28-frontend-ux-redesign)
这个提交包含在:
@@ -13,16 +13,16 @@ type PageHeaderProps = {
|
||||
|
||||
export function PageHeader({ title, subtitle, actions, eyebrow, meta, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={cn("flex flex-col gap-4 md:flex-row md:items-end md:justify-between", className)}>
|
||||
<header className={cn("page-header-shell", className)}>
|
||||
<div className="min-w-0 max-w-3xl">
|
||||
{eyebrow ? (
|
||||
<div className="text-xs font-bold uppercase text-primary">{eyebrow}</div>
|
||||
<div className="page-header-shell__eyebrow">{eyebrow}</div>
|
||||
) : null}
|
||||
<h1 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)] md:text-3xl">{title}</h1>
|
||||
{subtitle ? <p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</p> : null}
|
||||
<h1 className="page-header-shell__title">{title}</h1>
|
||||
{subtitle ? <p className="page-header-shell__subtitle">{subtitle}</p> : null}
|
||||
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
{actions ? <div className="page-header-shell__actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,31 @@ const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
|
||||
"/assets": "资产台账",
|
||||
};
|
||||
|
||||
const WORK_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/todos"];
|
||||
const BUSINESS_NAV_ORDER = [
|
||||
"/projects",
|
||||
"/contracts-registry",
|
||||
"/purchase-requests",
|
||||
"/assets",
|
||||
"/weekly-reports",
|
||||
"/team",
|
||||
"/announcements",
|
||||
"/leave-requests",
|
||||
"/attendance",
|
||||
"/wecom-approval-archive",
|
||||
];
|
||||
const FINANCE_NAV_ORDER = ["/payment-batches", "/ap-operations", "/invoice-pool", "/budgets", "/inventory-checks"];
|
||||
const MANAGEMENT_NAV_ORDER = [
|
||||
"/people",
|
||||
"/organization",
|
||||
"/system/workspace",
|
||||
"/system/workflow-definitions",
|
||||
"/system/settings",
|
||||
"/system/feature-flags",
|
||||
"/audit-logs",
|
||||
];
|
||||
const MORE_NAV_ORDER = ["/travel-requests", "/dashboard", "/settings/profile", "/system/cutover", "/system/integrations/erp"];
|
||||
|
||||
type ResolvedNavItem = {
|
||||
key?: string;
|
||||
href: string;
|
||||
@@ -75,10 +100,8 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition duration-150",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary ring-1 ring-primary/10"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-slate-50 hover:text-[var(--color-text-primary)]",
|
||||
"group nav-link-item",
|
||||
isActive ? "nav-link-item--active" : "nav-link-item--idle",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -86,10 +109,8 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-lg border text-[20px] transition-colors",
|
||||
isActive
|
||||
? "border-primary/15 bg-white text-primary"
|
||||
: "border-slate-200 bg-white text-[var(--color-text-tertiary)] group-hover:border-slate-300 group-hover:text-primary",
|
||||
"nav-link-item__icon",
|
||||
isActive ? "nav-link-item__icon--active" : "nav-link-item__icon--idle",
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
@@ -101,17 +122,14 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(title: string, description: string, items: ResolvedNavItem[], onNavigate?: () => void) {
|
||||
function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="px-3">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary/70">{title}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{description}</div>
|
||||
</div>
|
||||
<section className="nav-section">
|
||||
<div className="nav-section__label">{title}</div>
|
||||
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, onNavigate))}</div>
|
||||
</section>
|
||||
);
|
||||
@@ -126,10 +144,10 @@ function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void)
|
||||
<details className="rounded-2xl border border-slate-200 bg-slate-50/80 px-3 py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-2 py-1.5 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
<div>
|
||||
<div>备用模块</div>
|
||||
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">默认收起,不干扰主路径</div>
|
||||
<div>更多</div>
|
||||
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">低频入口和历史模块</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-[var(--color-text-tertiary)]">按需展开</span>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
|
||||
</summary>
|
||||
<div className="mt-3 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, onNavigate))}</div>
|
||||
</details>
|
||||
@@ -186,6 +204,41 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation
|
||||
};
|
||||
}
|
||||
|
||||
function regroupSections(sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] }) {
|
||||
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
|
||||
const byHref = new Map<string, ResolvedNavItem>();
|
||||
for (const item of allItems) {
|
||||
if (!byHref.has(item.href)) {
|
||||
byHref.set(item.href, item);
|
||||
}
|
||||
}
|
||||
|
||||
const used = new Set<string>();
|
||||
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));
|
||||
|
||||
return {
|
||||
work: take(WORK_NAV_ORDER),
|
||||
business: take(BUSINESS_NAV_ORDER),
|
||||
finance: take(FINANCE_NAV_ORDER),
|
||||
management: take(MANAGEMENT_NAV_ORDER),
|
||||
more: [
|
||||
...take(MORE_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 { me, permissions } = useAuth();
|
||||
const navigationQuery = useQuery({
|
||||
@@ -197,12 +250,15 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const sections = navigationQuery.data?.sections
|
||||
? normalizeSections(navigationQuery.data.sections)
|
||||
: buildFallbackItems(permissions, me?.roleCodes ?? []);
|
||||
const groupedSections = regroupSections(sections);
|
||||
|
||||
return (
|
||||
<nav className="space-y-5">
|
||||
{renderSection("主导航", "今日工作与高频协同。", sections.main, onNavigate)}
|
||||
{renderSection("管理入口", "仅管理员与负责人使用。", sections.admin, onNavigate)}
|
||||
{renderBackupSection(sections.backup, onNavigate)}
|
||||
{renderSection("工作", groupedSections.work, onNavigate)}
|
||||
{renderSection("业务", groupedSections.business, onNavigate)}
|
||||
{renderSection("财务", groupedSections.finance, onNavigate)}
|
||||
{renderSection("管理", groupedSections.management, onNavigate)}
|
||||
{renderBackupSection(groupedSections.more, onNavigate)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import { NavLinks } from "./NavLinks";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 bg-white/96 shadow-[8px_0_24px_rgba(15,23,42,0.045)] lg:flex lg:flex-col">
|
||||
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 lg:flex lg:flex-col">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<AppBrand />
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-3 text-sm text-[var(--color-text-secondary)]">
|
||||
<div className="sidebar-focus-card mt-4 text-[var(--color-text-secondary)]">
|
||||
<div className="flex items-center gap-2 font-semibold text-[var(--color-text-primary)]">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
今日工作台
|
||||
今日优先
|
||||
</div>
|
||||
<div className="mt-1.5 leading-6">待处理、团队状态和常用入口。</div>
|
||||
<div className="mt-1.5 leading-6">先处理待办,再进入报销、项目和财务流程。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -135,8 +135,8 @@ export function Topbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-10 border-b border-slate-200/80 bg-white/94 px-4 py-3 shadow-[0_4px_14px_rgba(15,23,42,0.035)] sm:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<header className="app-topbar sticky top-0 z-10 px-4 py-2.5 sm:px-8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -164,9 +164,11 @@ export function Topbar() {
|
||||
</Dialog>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary/70">{pageMeta.group}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-lg font-semibold text-[var(--color-text-primary)]">{pageMeta.title}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-base font-semibold text-[var(--color-text-primary)] md:text-lg">{pageMeta.title}</p>
|
||||
<span className="hidden rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-[var(--color-text-secondary)] sm:inline-flex">
|
||||
{pageMeta.group}
|
||||
</span>
|
||||
{wecomEntry ? (
|
||||
<span
|
||||
className="badge-neutral border-primary-200 bg-primary-50 text-primary"
|
||||
@@ -176,7 +178,7 @@ export function Topbar() {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-secondary)]">{me ? `你好,${me.name}` : "欢迎进入海音达工作台"}</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--color-text-secondary)]">{me ? `${me.name} · 当前工作区` : "海音达 OA"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,10 +189,11 @@ export function Topbar() {
|
||||
type="button"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
data-testid="global-search-trigger"
|
||||
className="hidden md:inline-flex"
|
||||
className="app-command-button hidden md:inline-flex"
|
||||
>
|
||||
<span className="material-symbols-outlined mr-1 text-[18px]">search</span>
|
||||
搜索
|
||||
<span className="ml-2 rounded bg-white px-1.5 py-0.5 text-[11px] text-slate-500">Ctrl K</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -199,7 +202,7 @@ export function Topbar() {
|
||||
type="button"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
data-testid="global-search-trigger-mobile"
|
||||
className="md:hidden"
|
||||
className="app-command-button md:hidden"
|
||||
aria-label="打开搜索"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">search</span>
|
||||
@@ -213,7 +216,7 @@ export function Topbar() {
|
||||
aria-label="通知"
|
||||
type="button"
|
||||
data-testid="notifications-bell"
|
||||
className="relative"
|
||||
className="app-command-button relative"
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 ? (
|
||||
|
||||
@@ -21,14 +21,14 @@ export function ListWorkspace({ className, children }: WorkspaceChildrenProps) {
|
||||
|
||||
export function WorkspaceHeader({ eyebrow, title, subtitle, meta, actions, className }: WorkspaceHeaderProps) {
|
||||
return (
|
||||
<header className={cn("flex flex-col gap-3 md:flex-row md:items-end md:justify-between", className)}>
|
||||
<header className={cn("page-header-shell", className)}>
|
||||
<div className="min-w-0 max-w-3xl">
|
||||
{eyebrow ? <div className="text-xs font-bold uppercase text-primary">{eyebrow}</div> : null}
|
||||
<h1 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)] md:text-[1.65rem]">{title}</h1>
|
||||
{subtitle ? <div className="mt-1.5 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</div> : null}
|
||||
{eyebrow ? <div className="page-header-shell__eyebrow">{eyebrow}</div> : null}
|
||||
<h1 className="page-header-shell__title">{title}</h1>
|
||||
{subtitle ? <div className="page-header-shell__subtitle">{subtitle}</div> : null}
|
||||
{meta ? <div className="mt-2.5 flex flex-wrap gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">{actions}</div> : null}
|
||||
{actions ? <div className="page-header-shell__actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户