完成 Wave4-5 财务与管理页刷新(05-29-frontend-ux-wave4-5-finance-admin-refresh)

这个提交包含在:
2026-05-29 09:12:27 +08:00
父节点 1e1e0fecd6
当前提交 bfb1cb6e65
共修改 11 个文件,包含 924 行新增和 208 行删除
+65 -6
查看文件
@@ -61,6 +61,22 @@ export function ApprovalRulesPage() {
});
const rules = listQuery.data?.items ?? [];
const ruleStats = useMemo(() => {
const enabledRules = rules.filter((rule) => rule.enabled);
const disabledRules = rules.length - enabledRules.length;
const fallbackRules = rules.filter((rule) => rule.ownerSelfFallbackRole).length;
const topPriority = enabledRules.length
? Math.min(...enabledRules.map((rule) => rule.priority))
: null;
return {
total: rules.length,
enabled: enabledRules.length,
disabled: disabledRules,
fallback: fallbackRules,
topPriority,
};
}, [rules]);
const createPayload = useMemo(() => {
return {
@@ -169,8 +185,41 @@ export function ApprovalRulesPage() {
</p>
) : null}
<div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">新建审批规则</h3>
<div className="finance-status-strip" data-testid="approval-rules-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">规则总数</div>
<div className="finance-metric-card__value">{ruleStats.total}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">启用规则</div>
<div className="finance-metric-card__value">{ruleStats.enabled}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">停用规则</div>
<div className="finance-metric-card__value">{ruleStats.disabled}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">最高优先级</div>
<div className="finance-metric-card__value">{ruleStats.topPriority ?? "-"}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">本人兜底</div>
<div className="finance-metric-card__value">{ruleStats.fallback}</div>
</div>
</div>
<section className="admin-command-panel space-y-4" data-testid="approval-rules-create-panel">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">新建审批规则</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
规则按优先级匹配,只影响新提交单据;历史审批实例不回写。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-600">
金额段 + 角色链
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
@@ -311,10 +360,20 @@ export function ApprovalRulesPage() {
</Button>
</div>
</div>
</div>
</section>
<div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">规则列表</h3>
<section className="admin-command-panel space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">规则列表</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
优先级越小越先匹配;启停动作会经过确认。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
当前 {ruleStats.enabled} 条启用
</div>
</div>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
@@ -403,7 +462,7 @@ export function ApprovalRulesPage() {
</Table>
</PrimaryTable>
) : null}
</div>
</section>
<ConfirmDialog
open={confirmToggleRule !== null}
+68 -13
查看文件
@@ -7,19 +7,26 @@ import {
type OrganizationTreeNode,
} from "../api/employees";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
function countNodes(nodes: OrganizationTreeNode[]): number {
return nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
}
function countExternalManagers(nodes: OrganizationTreeNode[]): number {
return nodes.reduce(
(total, node) => total + (node.externalManager ? 1 : 0) + countExternalManagers(node.children),
0,
);
}
function OrganizationNodeCard({ node, depth = 0 }: { node: OrganizationTreeNode; depth?: number }) {
return (
<div className="space-y-3">
<div
className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
style={{ marginLeft: `${depth * 24}px` }}
className="finance-list-card"
style={{ marginLeft: `${Math.min(depth, 4) * 18}px` }}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
@@ -78,20 +85,45 @@ export function OrganizationPage() {
}, [organizationQuery.data?.items]);
if (organizationQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载组织架构中...</div>;
return (
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系。"
/>
<div className="admin-command-panel text-sm text-zinc-500">加载组织架构中...</div>
</ListWorkspace>
);
}
if (organizationQuery.isError) {
return <div className="card-std p-6 text-sm text-red-600">加载组织架构失败</div>;
return (
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系。"
/>
<div className="admin-command-panel text-sm text-red-600">加载组织架构失败</div>
</ListWorkspace>
);
}
const sections = organizationQuery.data?.items ?? [];
const totalEmployees = sections.reduce((total, section) => total + countNodes(section.roots), 0);
const externalManagerCount = sections.reduce((total, section) => total + countExternalManagers(section.roots), 0);
const expandedCount = sections.filter((section) => {
const key = section.department?.id ?? section.department?.name ?? "unknown";
return expandedDepartments[key] ?? true;
}).length;
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系,跨部门上级会在节点右侧单独标注。"
subtitle="按部门查看 active 员工的汇报关系,跨部门上级单独标注,便于组织复核。"
actions={
<div className="flex gap-2">
<Button
@@ -124,6 +156,29 @@ export function OrganizationPage() {
}
/>
<div className="finance-status-strip" data-testid="organization-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">部门分组</div>
<div className="finance-metric-card__value">{sections.length}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">展示员工</div>
<div className="finance-metric-card__value">{totalEmployees}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">已展开</div>
<div className="finance-metric-card__value">{expandedCount}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">跨部门汇报</div>
<div className="finance-metric-card__value">{externalManagerCount}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">视图模式</div>
<div className="finance-metric-card__value text-base">只读</div>
</div>
</div>
{sections.length ? (
<div className="space-y-6">
{sections.map((section: OrganizationDepartmentSection) => {
@@ -132,11 +187,11 @@ export function OrganizationPage() {
const totalNodes = countNodes(section.roots);
return (
<section key={sectionKey} className="card-std p-6">
<section key={sectionKey} className="admin-command-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">{section.department?.name ?? "未分配部门"}</h3>
<div className="mt-1 text-sm text-zinc-500">{totalNodes} 名员工</div>
<h2 className="text-base font-semibold text-zinc-900">{section.department?.name ?? "未分配部门"}</h2>
<div className="mt-1 text-sm text-zinc-500">{totalNodes} 名员工 · {expanded ? "已展开" : "已折叠"}</div>
</div>
<Button
type="button"
@@ -166,9 +221,9 @@ export function OrganizationPage() {
})}
</div>
) : (
<div className="card-std p-6 text-sm text-zinc-500">当前没有可展示的 active 员工。</div>
<div className="admin-command-panel text-sm text-zinc-500">当前没有可展示的 active 员工。</div>
)}
</div>
</ListWorkspace>
);
}
+169 -119
查看文件
@@ -29,7 +29,7 @@ import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { DateTextInput } from "../components/common/DateTextInput";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -397,6 +397,13 @@ export function PeoplePage() {
const wecomHrMasterError = wecomHrMasterRunsQuery.isError
? mapApiMessage(wecomHrMasterRunsQuery.error, "企微 HR 主数据同步未就绪")
: "";
const peopleStats = {
total: employeesQuery.data?.total ?? items.length,
active: items.filter((item) => item.status === "active").length,
archived: items.filter((item) => item.status === "archived").length,
departments: new Set(items.map((item) => item.department?.id).filter(Boolean)).size,
wecomAttention: items.filter((item) => shouldShowWecomRetry(item.wecomDirectory)).length,
};
function toggleSelection(employeeId: string, checked: boolean) {
setSelectedEmployeeIds((current) => {
@@ -415,10 +422,11 @@ export function PeoplePage() {
}
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="人员管理"
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
subtitle="按员工档案、账号状态和企业微信镜像做筛选、批量处理和审计留痕。"
actions={
<div className="flex flex-wrap gap-2">
{canManageUsers ? (
@@ -462,8 +470,31 @@ export function PeoplePage() {
}
/>
<div className="finance-status-strip" data-testid="people-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">员工总数</div>
<div className="finance-metric-card__value">{peopleStats.total}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">当前页生效</div>
<div className="finance-metric-card__value">{peopleStats.active}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">当前页归档</div>
<div className="finance-metric-card__value">{peopleStats.archived}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">涉及部门</div>
<div className="finance-metric-card__value">{peopleStats.departments}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">企微待处理</div>
<div className="finance-metric-card__value">{peopleStats.wecomAttention}</div>
</div>
</div>
{canManageUsers ? (
<div className="card-std p-4">
<section className="admin-command-panel" data-testid="people-wecom-command-panel">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
@@ -507,10 +538,21 @@ export function PeoplePage() {
</Button>
</div>
</div>
</div>
</section>
) : null}
<div className="card-std space-y-4 p-4">
<section className="admin-command-panel space-y-4" data-testid="people-filter-panel">
<div className="flex flex-col gap-1 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">筛选与批量范围</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
先收窄部门、状态、上级和入职时间,再执行批量动作。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
已选 {selectedEmployeeIds.length} 人
</div>
</div>
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
<Input
placeholder="搜索姓名、工号、手机号、邮箱"
@@ -582,10 +624,10 @@ export function PeoplePage() {
<span>到</span>
<span>{hireDateTo || "-"}</span>
</div>
</div>
</section>
{canManageUsers && selectedEmployeeIds.length ? (
<div className="card-std flex flex-wrap items-center justify-between gap-3 p-4">
<section className="admin-command-panel flex flex-wrap items-center justify-between gap-3" data-testid="people-batch-actions">
<div className="text-sm text-zinc-700">
已选 <span className="font-semibold text-zinc-900">{selectedEmployeeIds.length}</span> 名员工
</div>
@@ -622,10 +664,16 @@ export function PeoplePage() {
清空选择
</Button>
</div>
</div>
</section>
) : null}
<div className="card-std p-6">
<section className="admin-command-panel space-y-4">
<div>
<h2 className="text-base font-semibold text-zinc-900">人员列表</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
查看档案进入个人详情;启停、离职和企微重试会保留操作结果。
</p>
</div>
{exportError ? (
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
@@ -636,124 +684,126 @@ export function PeoplePage() {
{mapApiMessage(employeesQuery.error, "加载人员列表失败")}
</p>
) : null}
<Table>
<TableHeader>
<TableRow>
{canManageUsers ? <TableHead>选择</TableHead> : null}
<TableHead>员工</TableHead>
<TableHead>部门 / 上级</TableHead>
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>企业微信</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => {
const wecomView = wecomDirectoryStatusView(item.wecomDirectory);
return (
<TableRow key={item.id}>
{canManageUsers ? (
<PrimaryTable>
<Table data-testid="people-table">
<TableHeader>
<TableRow>
{canManageUsers ? <TableHead>选择</TableHead> : null}
<TableHead>员工</TableHead>
<TableHead>部门 / 上级</TableHead>
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>企业微信</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => {
const wecomView = wecomDirectoryStatusView(item.wecomDirectory);
return (
<TableRow key={item.id}>
{canManageUsers ? (
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
</div>
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={wecomView.status} label={wecomView.label} />
<div className="max-w-40 truncate text-xs text-zinc-500">{wecomView.hint}</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
{shouldShowWecomRetry(item.wecomDirectory) ? (
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={wecomView.status} label={wecomView.label} />
<div className="max-w-40 truncate text-xs text-zinc-500">{wecomView.hint}</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
{shouldShowWecomRetry(item.wecomDirectory) ? (
<Button
size="sm"
variant="secondary"
type="button"
disabled={wecomDirectoryRetryMutation.isPending}
onClick={() => wecomDirectoryRetryMutation.mutate(item.id)}
>
企微重试
</Button>
) : null}
<Button
size="sm"
variant="secondary"
type="button"
disabled={wecomDirectoryRetryMutation.isPending}
onClick={() => wecomDirectoryRetryMutation.mutate(item.id)}
disabled={offboardingMutation.isPending}
onClick={() => setOffboardingTarget(item)}
>
企微重试
发起离职
</Button>
) : null}
<Button
size="sm"
variant="secondary"
type="button"
disabled={offboardingMutation.isPending}
onClick={() => setOffboardingTarget(item)}
>
发起离职
</Button>
</>
) : null}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
暂无匹配员工
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : null}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
暂无匹配员工
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</PrimaryTable>
</section>
<Dialog open={transferDialogOpen} onOpenChange={setTransferDialogOpen}>
<DialogContent className="max-w-lg">
@@ -890,7 +940,7 @@ export function PeoplePage() {
}
}}
/>
</div>
</ListWorkspace>
);
}
+116 -62
查看文件
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSystemNavigation, fetchWorkspaceSettings, updateWorkspaceSettings } from "../api/workspace";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
@@ -46,6 +46,16 @@ const MODULE_LABELS: Record<string, string> = {
profile: "个人设置",
};
const VISIBILITY_OPTIONS = [
{ value: "main", label: "主导航" },
{ value: "admin", label: "管理区" },
{ value: "hidden", label: "隐藏" },
] as const;
function visibilityModeLabel(mode: string) {
return VISIBILITY_OPTIONS.find((item) => item.value === mode)?.label ?? mode;
}
export function WorkspaceSetupPage() {
const queryClient = useQueryClient();
const workspaceQuery = useQuery({ queryKey: ["workspace-settings"], queryFn: fetchWorkspaceSettings });
@@ -74,95 +84,139 @@ export function WorkspaceSetupPage() {
const workspace = workspaceQuery.data;
const rolePresets = navigationQuery.data?.recommendedRolePresets ?? [];
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
const workspaceStats = useMemo(() => {
const entries = Object.entries(workspace?.moduleVisibility ?? {});
return {
total: entries.length,
main: entries.filter(([, mode]) => mode === "main").length,
admin: entries.filter(([, mode]) => mode === "admin").length,
hidden: entries.filter(([, mode]) => mode === "hidden").length,
rolePresets: rolePresets.length,
};
}, [rolePresets.length, workspace?.moduleVisibility]);
if (workspaceQuery.isLoading || !workspace) {
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="工作区设置"
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
/>
<div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>
</div>
<div className="admin-command-panel text-sm text-zinc-500">加载工作区设置中...</div>
</ListWorkspace>
);
}
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="工作区设置"
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
subtitle="按小团队运营模式收敛主导航、管理入口和备用模块。"
/>
<div className="grid gap-4 xl:grid-cols-3">
<div className="card-std p-6">
<div className="text-sm text-zinc-500">产品模式</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.profile}</div>
<div className="mt-3 text-sm text-zinc-600">单公司、账号密码、站内通知、桌面优先。</div>
<div className="finance-status-strip" data-testid="workspace-settings-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">模块总数</div>
<div className="finance-metric-card__value">{workspaceStats.total}</div>
</div>
<div className="card-std p-6">
<div className="text-sm text-zinc-500">初始化状态</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.persisted ? "已完成" : "待初始化"}</div>
<div className="mt-3 text-sm text-zinc-600">首次管理员进入此页后保存一次,即完成小团队工作区初始化。</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">主导航</div>
<div className="finance-metric-card__value">{workspaceStats.main}</div>
</div>
<div className="card-std p-6">
<div className="text-sm text-zinc-500">推荐角色预设</div>
<div className="mt-3 space-y-2 text-sm">
<div className="finance-metric-card">
<div className="finance-metric-card__label">管理区</div>
<div className="finance-metric-card__value">{workspaceStats.admin}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">隐藏</div>
<div className="finance-metric-card__value">{workspaceStats.hidden}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">初始化</div>
<div className="finance-metric-card__value text-base">{workspace.persisted ? "已完成" : "待初始化"}</div>
</div>
</div>
<section className="admin-command-panel space-y-4" data-testid="workspace-profile-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">工作区基线</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
{workspace.profile} · 单公司 · 账号密码 · 站内通知 · 桌面优先。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
{workspaceStats.rolePresets} 个角色预设
</div>
</div>
{rolePresets.length ? (
<div className="admin-tile-grid">
{rolePresets.map((item) => (
<div key={item.key} className="rounded-lg border border-zinc-200 p-3">
<div key={item.key} className="admin-tile-card">
<div className="font-medium text-zinc-900">{item.label}</div>
<div className="mt-1 text-zinc-600">{item.description}</div>
<div className="mt-1 text-xs text-zinc-500">角色:{item.roleCodes.join(" / ")}</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-300 px-4 py-5 text-sm text-slate-500">
当前没有推荐角色预设。
</div>
)}
</section>
<div className="card-std p-6 space-y-4">
<section className="admin-command-panel space-y-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">模块显隐</h3>
<p className="mt-1 text-sm text-zinc-500">主导航只保留高频入口;备用模块保留代码与数据,但默认放在管理员备用区。</p>
<p className="mt-1 text-sm leading-6 text-zinc-600">
主导航只保留高频入口;备用模块保留代码与数据,低频功能默认收进管理区或隐藏。
</p>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>模块</TableHead>
<TableHead>当前显隐</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{moduleEntries.map(([key, mode]) => (
<TableRow key={key}>
<TableCell>{MODULE_LABELS[key] ?? key}</TableCell>
<TableCell>{mode}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{(["main", "admin", "hidden"] as const).map((nextMode) => (
<Button
key={nextMode}
size="sm"
variant={mode === nextMode ? "default" : "secondary"}
type="button"
disabled={mutation.isPending}
onClick={() => {
const nextVisibility = { ...(workspace.moduleVisibility ?? {}), [key]: nextMode };
mutation.mutate({ moduleVisibility: nextVisibility });
}}
>
{nextMode}
</Button>
))}
</div>
</TableCell>
<PrimaryTable>
<Table data-testid="workspace-modules-table">
<TableHeader>
<TableRow>
<TableHead>模块</TableHead>
<TableHead>当前显隐</TableHead>
<TableHead>操作</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TableHeader>
<TableBody>
{moduleEntries.map(([key, mode]) => (
<TableRow key={key}>
<TableCell>{MODULE_LABELS[key] ?? key}</TableCell>
<TableCell>{visibilityModeLabel(mode)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{VISIBILITY_OPTIONS.map((option) => (
<Button
key={option.value}
size="sm"
variant={mode === option.value ? "default" : "secondary"}
type="button"
disabled={mutation.isPending}
onClick={() => {
const nextVisibility = { ...(workspace.moduleVisibility ?? {}), [key]: option.value };
mutation.mutate({ moduleVisibility: nextVisibility });
}}
>
{option.label}
</Button>
))}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</PrimaryTable>
</section>
</ListWorkspace>
);
}
@@ -30,8 +30,11 @@ const allPermissions = [
"risk.review.manage",
"travel.payment.manage",
"system.user.manage",
"system.users",
"system.settings",
"system.audit.view",
"masterdata.manage",
"notification.channel.manage",
];
const visualMe = {
@@ -517,9 +520,13 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
navigationItem("invoice-pool", "/invoice-pool", "票据中心", "receipt_long"),
],
admin: [
navigationItem("people", "/people", "人员管理", "badge", "admin"),
navigationItem("organization", "/organization", "组织架构", "account_tree", "admin"),
navigationItem("workspace", "/system/workspace", "工作区设置", "dashboard_customize", "admin"),
navigationItem("users", "/system/users", "用户管理", "group", "admin"),
navigationItem("master-data", "/system/master-data", "主数据", "database", "admin"),
navigationItem("notification-channels", "/system/notification-channels", "通知渠道", "notifications", "admin"),
navigationItem("approval-rules", "/system/approval-rules", "审批规则", "account_tree", "admin"),
navigationItem("settings", "/system/settings", "系统设置", "settings", "admin"),
navigationItem("feature-flags", "/system/feature-flags", "功能开关", "toggle_on", "admin"),
navigationItem("workflow-definitions", "/system/workflow-definitions", "流程定义", "schema", "admin"),
@@ -532,6 +539,35 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
return;
}
if (pathName === "/api/system/workspace") {
await fulfillJson(route, {
id: "workspace-wave4",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: {
portal: "main",
workflowTasks: "main",
reimbursements: "main",
paymentBatches: "admin",
apOperations: "admin",
invoicePool: "admin",
people: "admin",
organization: "admin",
approvalRules: "admin",
featureFlags: "admin",
travelRequests: "hidden",
},
createdAt: null,
updatedAt: "2026-05-29T08:00:00.000Z",
updatedBy: visualMe.id,
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, { page: 1, pageSize: 5, total: 0, unreadOnly: false, unreadCount: 0, items: [] });
return;
@@ -803,6 +839,97 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
return;
}
if (pathName === "/api/employees/organization") {
await fulfillJson(route, {
items: [
{
department: departments[0],
roots: [
{
id: visualMe.id,
employeeNo: visualMe.employeeNo,
name: visualMe.name,
title: "管理员",
status: "active",
avatarUrl: null,
department: departments[0],
externalManager: null,
children: [],
},
],
},
{
department: departments[1],
roots: [],
},
],
});
return;
}
if (pathName === "/api/employees/wecom-hr-master-sync/runs") {
await fulfillJson(route, {
items: [
{
id: "wecom-hr-run-wave4",
mode: "dry_run",
status: "success",
tokenSource: null,
requestedUserids: null,
limit: 50,
summary: { total: 1, created: 0, updated: 1, skipped: 0, conflicts: 0, failed: 0 },
errorCode: null,
errorMessage: null,
triggeredBy: visualMe.id,
startedAt: "2026-05-29T07:50:00.000Z",
finishedAt: "2026-05-29T07:51:00.000Z",
createdAt: "2026-05-29T07:50:00.000Z",
updatedAt: "2026-05-29T07:51:00.000Z",
itemCount: 1,
items: [],
},
],
});
return;
}
if (pathName === "/api/approval-rules") {
await fulfillJson(route, {
enabled: null,
items: [
{
id: "approval-rule-wave5-1",
name: "5000 以下经理审批",
minAmount: "0.00",
maxAmount: "5000.00",
level1Role: "manager",
level2Role: null,
level3Role: null,
ownerSelfFallbackRole: "finance_mgr",
enabled: true,
priority: 1,
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:00:00.000Z",
},
{
id: "approval-rule-wave5-2",
name: "高额财务负责人复核",
minAmount: "5000.01",
maxAmount: null,
level1Role: "manager",
level2Role: "finance_mgr",
level3Role: "ceo",
ownerSelfFallbackRole: null,
enabled: false,
priority: 2,
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/master-data/legal-entities") {
await fulfillJson(route, { includeArchived: false, items: legalEntities });
return;
@@ -886,16 +1013,20 @@ const pages = [
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches" },
{ path: "/ap-operations", summary: "ap-operations-summary", proof: "text=异常付款", screenshot: "02-ap-operations" },
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool" },
{ path: "/system/users", summary: "users-summary", proof: "data-testid=users-table", screenshot: "04-users" },
{ path: "/system/master-data", summary: "master-data-summary", proof: "text=法体列表", screenshot: "05-master-data" },
{ path: "/system/notification-channels", summary: "notification-channels-summary", proof: "data-testid=notification-channels-table", screenshot: "06-notification-channels" },
{ path: "/system/settings", summary: "system-settings-summary", proof: "text=系统设置", screenshot: "07-system-settings" },
{ path: "/system/feature-flags", summary: "feature-flags-summary", proof: "data-testid=feature-flags-table", screenshot: "08-feature-flags" },
{ path: "/system/workflow-definitions", summary: "workflow-definitions-summary", proof: "text=版本列表", screenshot: "09-workflow-definitions" },
{ path: "/audit-logs", summary: "audit-summary", proof: "text=PAYMENT_BATCH_REVIEW", screenshot: "10-audit-logs" },
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
{ path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" },
{ path: "/system/workspace", summary: "workspace-settings-summary", proof: "data-testid=workspace-modules-table", screenshot: "07-workspace" },
{ path: "/system/users", summary: "users-summary", proof: "data-testid=users-table", screenshot: "08-users" },
{ path: "/system/master-data", summary: "master-data-summary", proof: "text=法体列表", screenshot: "09-master-data" },
{ path: "/system/notification-channels", summary: "notification-channels-summary", proof: "data-testid=notification-channels-table", screenshot: "10-notification-channels" },
{ path: "/system/settings", summary: "system-settings-summary", proof: "text=系统设置", screenshot: "11-system-settings" },
{ path: "/system/feature-flags", summary: "feature-flags-summary", proof: "data-testid=feature-flags-table", screenshot: "12-feature-flags" },
{ path: "/system/workflow-definitions", summary: "workflow-definitions-summary", proof: "text=版本列表", screenshot: "13-workflow-definitions" },
{ path: "/audit-logs", summary: "audit-summary", proof: "text=PAYMENT_BATCH_REVIEW", screenshot: "14-audit-logs" },
];
test("PC 财务与管理页 Wave4 视觉冒烟", async ({ page }) => {
test("PC 财务与管理页 Wave4-5 视觉冒烟", async ({ page }) => {
await mockWave4Apis(page);
await page.setViewportSize({ width: 1440, height: 960 });
await login(page);