重构 PC 首页与报销列表降噪(frontend-simplification-refactor)

这个提交包含在:
2026-05-29 11:25:45 +08:00
父节点 a5549bdce6
当前提交 03f31958fa
共修改 18 个文件,包含 689 行新增和 296 行删除
+147 -92
查看文件
@@ -67,6 +67,16 @@ type QuickActionConfig = {
disabled?: boolean;
};
type PortalFocusAction = {
label: string;
to: string;
};
type PortalFocusLink = {
label: string;
to: string;
};
type GettingStartedChecklistItem = {
key: string;
title: string;
@@ -129,11 +139,6 @@ function compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[])
return cards.filter((card) => !excludedKeys.includes(card.key));
}
function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
if (!payload) return "未启用";
return payload.status === "submitted" ? "已提交" : "待填写";
}
function formatRelativeTime(value: string | null) {
if (!value) {
return "-";
@@ -411,6 +416,7 @@ function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHom
{cards.map((card) => (
<MetricCard
key={card.key}
ariaLabel={`portal-card-${card.key}`}
title={card.title}
value={card.value}
href={card.href}
@@ -481,7 +487,7 @@ function CompactMetricLinks({ title, cards }: { title: string; cards: PortalHome
return (
<CompactGroup title={title}>
{cards.length ? cards.slice(0, 6).map((card) => (
<Link key={card.key} to={card.href} className="portal-compact-row">
<Link key={card.key} to={card.href} className="portal-compact-row" aria-label={`portal-card-${card.key}`}>
<span className="min-w-0 truncate">{card.title}</span>
<span className={card.severity === "high" || card.severity === "warning" ? "shrink-0 font-semibold text-amber-700" : "shrink-0 font-semibold text-slate-900"}>
{card.value}
@@ -713,47 +719,77 @@ function QuickActionsPanel({
);
}
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
const visibleItems = items.slice(0, 5);
const hiddenCount = Math.max(items.length - visibleItems.length, 0);
function TodayTaskPanel({
primaryAction,
secondaryLinks,
focusItem,
hiddenFocusCount,
}: {
primaryAction: PortalFocusAction | null;
secondaryLinks: PortalFocusLink[];
focusItem: TodoAggregateItem | null;
hiddenFocusCount: number;
}) {
return (
<SectionPanel
title="今日待办焦点"
description="审批、TODO 和异常提醒集中在这里。"
className="portal-panel-fill"
>
<div className="space-y-3">
{visibleItems.length ? visibleItems.map((item) => (
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="portal-list-card">
<div className="flex items-start gap-3">
<div className={item.severity === "high"
? "mt-0.5 flex size-9 items-center justify-center rounded-lg bg-red-50 text-red-600"
: item.severity === "warning"
? "mt-0.5 flex size-9 items-center justify-center rounded-lg bg-amber-50 text-amber-600"
: "mt-0.5 flex size-9 items-center justify-center rounded-lg bg-zinc-100 text-zinc-600"}
>
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-zinc-900">{item.title}</div>
<span className="badge-neutral">{item.sourceLabel}</span>
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
<SectionPanel title="今日任务" className="portal-panel-fill">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)] xl:items-stretch">
<div className="rounded-lg border border-slate-200 bg-white/80 px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">当前焦点</div>
{hiddenFocusCount > 0 ? <div className="text-xs text-zinc-500">另有 {hiddenFocusCount} 项</div> : null}
</div>
{focusItem ? (
<Link
to={focusItem.href}
className="mt-3 block rounded-lg border border-slate-200 bg-slate-50/70 px-3 py-2.5 transition-colors hover:border-slate-300 hover:bg-white"
>
<div className="flex items-center gap-3">
<div className={focusItem.severity === "high"
? "flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-600"
: focusItem.severity === "warning"
? "flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-50 text-amber-600"
: "flex size-9 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-600"}
>
<span className="material-symbols-outlined text-[18px]">{focusItem.icon}</span>
</div>
<div className="mt-1 text-sm text-primary">进入</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-zinc-900">{focusItem.title}</div>
<div className="mt-1 text-xs text-zinc-500">{focusItem.sourceLabel}</div>
</div>
<span className="material-symbols-outlined text-zinc-400">arrow_forward</span>
</div>
</Link>
) : (
<div className="mt-3 rounded-lg border border-dashed border-slate-300 px-3 py-4 text-sm text-zinc-500">
暂无必须立即处理的事项。
</div>
</Link>
)) : <EmptyStateCard title="暂无待办" description="没有需要立刻处理的事。" />}
</div>
{hiddenCount > 0 ? (
<div className="portal-panel-footer">
<span>另有 {hiddenCount} 项。</span>
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
)}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-white/90 px-3.5 py-3">
<div className="text-sm font-semibold text-zinc-900">下一步</div>
{primaryAction ? (
<Button asChild className="mt-3 w-full" type="button">
<Link to={primaryAction.to}>{primaryAction.label}</Link>
</Button>
) : (
<Button className="mt-3 w-full" disabled type="button">
暂无主动作
</Button>
)}
{secondaryLinks.length ? (
<div className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
{secondaryLinks.map((item) => (
<Button key={`${item.label}-${item.to}`} asChild variant="secondary" type="button">
<Link to={item.to}>{item.label}</Link>
</Button>
))}
</div>
) : null}
</div>
</div>
</SectionPanel>
);
}
@@ -1112,6 +1148,33 @@ export function PortalHomePage() {
});
}
const approvalAction = quickActions.find((item) => item.key === "approval");
const expenseAction = quickActions.find((item) => item.key === "expense");
const fallbackAction = quickActions.find((item): item is QuickActionConfig & { to: string } => Boolean(item.to));
const primaryAction: PortalFocusAction | null =
payload.pendingTasks.count > 0 && approvalAction?.to
? { label: "处理待办", to: approvalAction.to }
: expenseAction?.to
? { label: "新建报销", to: expenseAction.to }
: fallbackAction
? { label: fallbackAction.label, to: fallbackAction.to }
: null;
const secondaryLinkCandidates: Array<PortalFocusLink | null> = [
hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)
? { label: "报销进度", to: "/reimbursements" }
: null,
payload.pendingTasks.count > 0 && approvalAction?.to
? { label: "审批中心", to: approvalAction.to }
: null,
todoAggregateItems.length > 1
? { label: "全部待办", to: "/todos" }
: null,
];
const secondaryLinks = secondaryLinkCandidates
.filter((item): item is PortalFocusLink => item !== null)
.filter((item) => item.to !== primaryAction?.to)
.slice(0, 2);
return (
<PageScaffold>
<ErrorBoundary fallback={null}>
@@ -1123,12 +1186,12 @@ export function PortalHomePage() {
title={`你好,${payload.greeting.name}`}
className="workbench-hero--compact"
subtitle={roleCapabilities.canUseFinance
? "付款、风险、审批。"
? "先处理今天的付款、风险和审批。"
: roleCapabilities.canManageSystem
? "先看待处理,再进入配置、审计和通知。"
? "先处理待办,再进入管理入口。"
: isManagerView
? "审批待办、团队状态和周报缺口集中查看。"
: "先看待办,再提交申请、补材料或查看状态。"}
? "先处理待办,再看团队状态。"
: "先处理待办,再发起申请。"}
actions={
!payload.workspace.persisted && canSetupWorkspace ? (
<Button asChild variant="secondary" type="button">
@@ -1138,25 +1201,12 @@ export function PortalHomePage() {
}
/>
<div className="portal-metric-strip ops-status-strip">
{primaryCards.map((card) => (
<MetricCard
key={card.key}
ariaLabel={`portal-card-${card.key}`}
href={card.href}
note={card.key === "my_weekly_report"
? formatWeeklyStatus(payload.weeklyReport)
: card.key === "today_presence"
? payload.todayPresence?.note ?? "更新今日状态"
: card.key === "team_presence"
? payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态板待启用"
: undefined}
title={card.title}
tone={severityTone(card.severity)}
value={card.value}
/>
))}
</div>
<TodayTaskPanel
primaryAction={primaryAction}
secondaryLinks={secondaryLinks}
focusItem={todoAggregateItems[0] ?? null}
hiddenFocusCount={Math.max(todoAggregateItems.length - 1, 0)}
/>
{showGettingStarted ? (
<ErrorBoundary
@@ -1175,33 +1225,33 @@ export function PortalHomePage() {
</ErrorBoundary>
) : null}
<div className="portal-home-grid">
<TodoAggregatePanel items={todoAggregateItems} />
<QuickActionsPanel items={quickActions} layout="rail" />
</div>
<div className="portal-home-grid portal-home-grid--collaboration">
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="团队全员状态"
description="今日团队状态。"
/>
)}
>
<TeamStatusBoardPanel items={teamStatusBoard} />
</ErrorBoundary>
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="近期动态流"
description="最近业务动作。"
/>
)}
>
<ActivityAnnouncementsPanel activities={recentActivities} announcements={announcementItems} />
</ErrorBoundary>
</div>
<SecondaryWorkspacePanel
title="团队与通知"
description="团队状态、动态和公告"
>
<div className="portal-home-grid portal-home-grid--collaboration">
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="团队全员状态"
description="今日团队状态。"
/>
)}
>
<TeamStatusBoardPanel items={teamStatusBoard} />
</ErrorBoundary>
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="近期动态流"
description="最近业务动作。"
/>
)}
>
<ActivityAnnouncementsPanel activities={recentActivities} announcements={announcementItems} />
</ErrorBoundary>
</div>
</SecondaryWorkspacePanel>
{isManagerView ? (
<>
@@ -1210,6 +1260,10 @@ export function PortalHomePage() {
description="入口"
>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr),minmax(0,1fr),340px]">
{primaryCards.length ? (
<SecondaryCardsPanel title="状态摘要" cards={primaryCards} />
) : null}
<QuickActionsPanel items={quickActions} />
{roleCapabilities.canApproveTasks ? (
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
) : null}
@@ -1248,6 +1302,7 @@ export function PortalHomePage() {
description="申请、资产、最近访问"
>
<div className="portal-secondary-compact-grid">
<CompactMetricLinks title="状态摘要" cards={primaryCards} />
<CompactSummaryLinks title="我的申请与资产" items={employeeSummaryItems} />
<CompactRecentVisits />
<CompactQuickLinks items={quickLinkItems} />
+212 -171
查看文件
@@ -29,8 +29,7 @@ import { Pagination } from "../components/common/Pagination";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -66,6 +65,15 @@ type BatchResultState = {
>;
};
const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "rejected", label: "被驳回" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "cancelled", label: "已取消" },
];
function formatCurrency(value: number) {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
}
@@ -108,6 +116,7 @@ export function ReimbursementsPage() {
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
@@ -178,6 +187,7 @@ export function ReimbursementsPage() {
);
const canShowBatchEntry =
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
const showAdvancedTools = showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector;
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
@@ -363,67 +373,18 @@ export function ReimbursementsPage() {
<ListWorkspace>
<WorkspaceHeader
eyebrow="报销"
title={scope === "all" ? "报销列表" : "我的报销"}
subtitle="先看状态和金额,再继续编辑、查看详情、导出或批量处理。"
title={scope === "all" ? "报销台账" : "我的报销"}
actions={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{canCreateExpense ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
空白报销
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptionsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => startCreateReimbursement({ templateId: template.id })}
>
{template.name}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请
</div>
{linkableTravelRequestsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem
key={travelRequest.id}
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
>
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
canCreateExpense ? (
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null
}
/>
@@ -462,13 +423,6 @@ export function ReimbursementsPage() {
</p>
) : null}
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
</StatStrip>
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
@@ -477,14 +431,7 @@ export function ReimbursementsPage() {
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{[
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
].map((item) => (
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
@@ -497,52 +444,144 @@ export function ReimbursementsPage() {
</Tabs>
<div className="ops-command-bar__actions">
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
{canApproveExpense ? (
<Button
data-testid="reimbursements-actionable"
size="sm"
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
type="button"
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
待我处理
</Button>
) : null}
<Button
size="sm"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
type="button"
>
高级 / 台账
</Button>
</div>
</div>
{canViewAll ? (
<>
<Button
data-testid="reimbursements-scope-own"
size="sm"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
size="sm"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
<Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
</Button>
</>
) : null}
{showAdvancedTools ? (
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3">
<div className="grid gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">审批中</div>
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">已支付</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="mt-3 flex flex-wrap items-center gap-2">
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
{canCreateExpense ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary" type="button">
更多创建方式
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptionsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => startCreateReimbursement({ templateId: template.id })}
>
{template.name}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请
</div>
{linkableTravelRequestsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem
key={travelRequest.id}
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
>
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{canViewAll ? (
<>
<Button
data-testid="reimbursements-scope-own"
size="sm"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
size="sm"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
<Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
</Button>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
size="sm"
@@ -552,41 +591,42 @@ export function ReimbursementsPage() {
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => setExportConfirmFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => setExportConfirmFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => setExportConfirmFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => setExportConfirmFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canShowBatchEntry ? (
<Button
size="sm"
variant={batchMode ? "default" : "secondary"}
onClick={() => {
setBatchMode((current) => !current);
setSelectedReimbursementIds([]);
setBatchDialogAction(null);
setBatchComment("");
setBatchError("");
}}
type="button"
>
{batchMode ? "退出批量模式" : "批量操作"}
</Button>
) : null}
{canShowBatchEntry ? (
<Button
size="sm"
variant={batchMode ? "default" : "secondary"}
onClick={() => {
setBatchMode((current) => !current);
setSelectedReimbursementIds([]);
setBatchDialogAction(null);
setBatchComment("");
setBatchError("");
}}
type="button"
>
{batchMode ? "退出批量模式" : "批量操作"}
</Button>
) : null}
</div>
</div>
</div>
) : null}
{batchMode ? (
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
@@ -595,16 +635,6 @@ export function ReimbursementsPage() {
) : null}
</FilterBar>
{status === "draft" ? (
<WorkspaceNotice title="草稿可以继续编辑或删除" tone="neutral">
提交后的单据进入审批链;已通过、已支付单据不再提供删除入口。
</WorkspaceNotice>
) : status === "reviewing" ? (
<WorkspaceNotice title="审批中单据以详情页为主" tone="neutral">
查看当前节点、票据、附件和审批历史后再处理;有批量权限时只会选择你可处理的待办。
</WorkspaceNotice>
) : null}
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
{listQuery.isError ? (
@@ -810,15 +840,26 @@ export function ReimbursementsPage() {
</Link>
</Button>
{canDeleteDraft ? (
<Button
disabled={deleteMutation.isPending}
onClick={() => handleDeleteDraft(item)}
size="sm"
type="button"
variant="destructive"
>
删除
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={deleteMutation.isPending}
size="sm"
type="button"
variant="secondary"
>
更多
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600"
onSelect={() => handleDeleteDraft(item)}
>
删除草稿
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</td>
@@ -36,8 +36,10 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
await page.getByRole("button", { name: "保存状态" }).click();
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
await page.goto("/portal");
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
await page.getByRole("tab", { name: "公告" }).click();
await expect(page.getByText(announcementTitle)).toBeVisible();
await page.locator("summary").filter({ hasText: "更多入口" }).click();
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
await logout(page, EMPLOYEE.name);
+1 -1
查看文件
@@ -24,7 +24,7 @@ type Envelope<T> = {
export async function assertPortalLoaded(page: Page) {
await expect(page).toHaveURL(/\/portal/);
await expect(page.getByRole("link", { name: "Portal" }).first()).toBeVisible();
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
}
export async function assertForbiddenPage(page: Page) {
+16 -11
查看文件
@@ -512,25 +512,26 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await login(page, EMPLOYEE);
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.getByText("当前焦点")).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible();
await expect(page.getByRole("link", { name: "新建报销" })).toBeVisible();
await page.locator("summary").filter({ hasText: "更多入口" }).click();
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("aria-label")),
);
expect(labels).toEqual([
expect(labels.slice(0, 4)).toEqual([
"portal-card-my_todos",
"portal-card-today_presence",
"portal-card-my_weekly_report",
"portal-card-my_projects",
]);
await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await expect(page.getByRole("heading", { name: "常用" })).toBeVisible();
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今天先处理" })).toBeVisible();
await expect(page.getByRole("heading", { name: "通知" })).toBeVisible();
});
@@ -544,19 +545,21 @@ test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page
await login(page, MANAGER);
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible();
await page.locator("summary").filter({ hasText: "角色重点" }).click();
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("aria-label")),
);
expect(labels).toEqual([
expect(labels.slice(0, 4)).toEqual([
"portal-card-pending_approvals",
"portal-card-team_presence",
"portal-card-weekly_missing",
"portal-card-weekly_help",
]);
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible();
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
await expect(page.getByRole("link", { name: "差旅申请" })).not.toBeVisible();
@@ -636,7 +639,7 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录
await page.goto("/portal");
await expect(page).toHaveURL(/\/portal/);
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
expect(refreshAttempts).toBeGreaterThanOrEqual(2);
});
@@ -650,7 +653,9 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
await login(page, EMPLOYEE);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
await page.locator("summary").filter({ hasText: "角色重点" }).click();
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
});
@@ -194,8 +194,10 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
await page.goto("/portal");
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
await page.getByRole("tab", { name: "公告" }).click();
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 });
await page.locator("summary").filter({ hasText: "更多入口" }).click();
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
return announcementTitle;
});
@@ -316,7 +316,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
await page.getByTestId("login-password").fill(visualUser.password);
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible();
await capture(page, "02-portal");
await page.goto("/reimbursements?status=reviewing&scope=all");
@@ -323,7 +323,8 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
await expect(page.getByText(reimbursementId)).toHaveCount(0);
await page.getByRole("button", { name: "删除" }).first().click();
await page.getByRole("button", { name: "更多" }).first().click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber);
await page.getByTestId("action-confirm-submit").click();
await expect(page.getByTestId("reimbursement-row")).toHaveCount(0);
@@ -120,7 +120,7 @@ test.describe("R105 pretrial visual acceptance", () => {
await loginLiveUser(page, "employee");
await page.goto("/portal");
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expectNoLegacyDesignCopy(page);
await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true });
@@ -277,17 +277,20 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto("/reimbursements");
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await page.getByTestId("reimbursement-row").getByRole("button", { name: "删除" }).click();
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
expect(calls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-cancel").click();
expect(calls.deleteReimbursement).toBe(0);
await page.getByTestId("reimbursement-row").getByRole("button", { name: "删除" }).click();
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursement).toBe(1);
await page.getByRole("button", { name: "高级 / 台账" }).click();
await page.getByTestId("reimbursements-export").click();
await page.getByTestId("reimbursements-export-csv").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
+11 -13
查看文件
@@ -20,12 +20,11 @@ test.describe("visual portal", () => {
await page.setViewportSize({ width: 1440, height: 1200 });
await login(page, EMPLOYEE);
await expect(page.locator('[aria-label="portal-card-my_todos"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-my_weekly_report"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-my_projects"]')).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.getByText("当前焦点")).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible();
await attachScreenshot(page, testInfo, "portal-employee.png");
});
@@ -35,12 +34,11 @@ test.describe("visual portal", () => {
await login(page, ADMIN);
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0);
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await attachScreenshot(page, testInfo, "portal-admin.png");
await logout(page, ADMIN.name);
@@ -51,8 +49,8 @@ test.describe("visual portal", () => {
await login(page, FINANCE);
await expect(page.getByText("财务工作台")).toBeVisible();
await expect(page.getByText("财务处理")).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
await attachScreenshot(page, testInfo, "portal-finance.png");
await logout(page, FINANCE.name);
+5 -3
查看文件
@@ -334,15 +334,17 @@ test.describe("visual shell", () => {
await page.setViewportSize({ width: 1440, height: 1024 });
await loginVisualUser(page);
await expect(page.getByRole("heading", { name: "今天先处理" })).toBeVisible();
await expect(page.getByRole("heading", { name: "常用动作" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).toBeVisible();
await attachScreenshot(page, testInfo, "pc-portal-wave1.png");
await page.goto("/reimbursements");
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
await expect(page.getByTestId("reimbursements-export")).toBeVisible();
await expect(page.getByRole("button", { name: "高级 / 台账" })).toBeVisible();
await expect(page.getByTestId("reimbursements-export")).not.toBeVisible();
await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await attachScreenshot(page, testInfo, "pc-reimbursements-wave1.png");