feat(client): 初始化前端壳与鉴权守卫(P1-5)

- 技术栈:React 18 + TypeScript + Vite + Tailwind

- API 层:ApiError + 401 自动刷新 + single-flight 重试

- 鉴权:内存 Access Token + Cookie Refresh + 启动 bootstrap

- RBAC:权限映射 + RequireAuth/RequirePermission 路由守卫

- 布局:OA 工作台壳层 + 侧边栏 + 5 个占位页面

- 样式:复用 hyb-web 视觉 token(单向同步)

- 文档:新增 client README,补充 server 联调说明
这个提交包含在:
2026-02-07 16:43:57 +08:00
父节点 f527633716
当前提交 c290e03fef
共修改 35 个文件,包含 4127 行新增和 0 行删除
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
type EmptyStateCardProps = {
title: string;
description: string;
footer?: ReactNode;
};
export function EmptyStateCard({ title, description, footer }: EmptyStateCardProps) {
return (
<section className="card-std">
<h3 className="card-title">{title}</h3>
<p className="card-desc">{description}</p>
{footer ? <div className="mt-4">{footer}</div> : null}
</section>
);
}
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
type PageHeaderProps = {
title: string;
subtitle?: string;
actions?: ReactNode;
};
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-slate-900">{title}</h2>
{subtitle ? <p className="mt-1 text-sm text-secondary">{subtitle}</p> : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
);
}
@@ -0,0 +1,69 @@
import { NavLink } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { hasAnyPermission } from "../../auth/permissions";
type NavItem = {
to: string;
label: string;
anyOf?: string[];
};
const NAV_ITEMS: NavItem[] = [
{ to: "/dashboard", label: "仪表盘" },
{
to: "/reimbursements",
label: "我的报销",
anyOf: ["expense.view_own", "expense.view_all"],
},
{
to: "/workflow/tasks",
label: "待我处理",
anyOf: ["expense.approve"],
},
{
to: "/invoice-pool",
label: "票据中心",
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
},
{ to: "/settings/profile", label: "个人设置" },
];
export function Sidebar() {
const { permissions } = useAuth();
const visibleItems = NAV_ITEMS.filter((item) => {
if (!item.anyOf) {
return true;
}
return hasAnyPermission(permissions, item.anyOf);
});
return (
<aside className="hidden w-64 shrink-0 border-r border-slate-200 bg-white lg:flex lg:flex-col">
<div className="border-b border-slate-200 px-6 py-5">
<p className="text-sm font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
<h1 className="mt-1 text-lg font-bold text-slate-900">海音达工作台</h1>
</div>
<nav className="flex-1 space-y-1 p-4">
{visibleItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
[
"block rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary-50 text-primary"
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900",
].join(" ")
}
>
{item.label}
</NavLink>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,38 @@
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
export function Topbar() {
const { me, logout } = useAuth();
const roleCodes = me?.roleCodes ?? [];
return (
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 px-4 py-3 backdrop-blur sm:px-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-secondary">欢迎回来</p>
<p className="text-base font-semibold text-slate-900">{me?.name ?? "-"}</p>
</div>
<div className="flex items-center gap-3">
<div className="hidden text-right sm:block">
<p className="text-xs uppercase tracking-wide text-secondary">角色</p>
<p className="text-sm font-medium text-slate-900">{roleCodes.join(" / ") || "-"}</p>
</div>
<Link className="btn-secondary text-sm" to="/settings/profile">
个人设置
</Link>
<button
className="btn-primary text-sm"
onClick={() => {
void logout();
}}
type="button"
>
退出登录
</button>
</div>
</div>
</header>
);
}