R96 U1-U7 精简页面设计与文案

这个提交包含在:
2026-05-06 16:47:13 +08:00
父节点 6127a537d8
当前提交 3741a3d626
共修改 19 个文件,包含 329 行新增和 549 行删除
+2 -2
查看文件
@@ -18,8 +18,8 @@ export function PageHeader({ title, subtitle, actions, eyebrow, meta, className
{eyebrow ? (
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">{eyebrow}</div>
) : null}
<h2 className="mt-1 text-[2rem] font-semibold tracking-tight text-[var(--color-text-primary)]">{title}</h2>
{subtitle ? <p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</p> : null}
<h2 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)]">{title}</h2>
{subtitle ? <p className="mt-1.5 text-sm text-[var(--color-text-secondary)]">{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 gap-2">{actions}</div> : null}
+1 -1
查看文件
@@ -166,7 +166,7 @@ export function Topbar() {
<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 tracking-tight text-[var(--color-text-primary)]">{pageMeta.title}</p>
<p className="truncate text-lg font-semibold text-[var(--color-text-primary)]">{pageMeta.title}</p>
{wecomEntry ? (
<span
className="badge-neutral border-primary-200 bg-primary-50 text-primary"
@@ -21,7 +21,7 @@ export function WorkbenchHero({
<h1 className="workbench-hero__title">{title}</h1>
{subtitle ? <p className="workbench-hero__subtitle">{subtitle}</p> : null}
</div>
{actions ? <div className="shrink-0 rounded-2xl border border-white/70 bg-white/55 p-2 shadow-sm backdrop-blur">{actions}</div> : null}
{actions ? <div className="shrink-0 rounded-lg border border-white/70 bg-white/55 p-2 shadow-sm backdrop-blur">{actions}</div> : null}
</section>
);
}
+2 -2
查看文件
@@ -7,8 +7,8 @@ export function AppBrand({ compact = false }: { compact?: boolean }) {
</svg>
</div>
<div>
<div className="text-sm font-semibold tracking-tight text-[var(--color-text-primary)]">海音达工作台</div>
<div className="text-xs text-[var(--color-text-secondary)]">Hyinda OA · 小团队运营中枢</div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">海音达工作台</div>
<div className="text-xs text-[var(--color-text-secondary)]">审批 报销 团队</div>
</div>
</div>
);
-1
查看文件
@@ -26,7 +26,6 @@ export function AppShellLayout() {
return (
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
<div className="pointer-events-none fixed inset-0 -z-10 bg-[radial-gradient(circle_at_12%_8%,rgba(0,57,138,0.10),transparent_28rem),radial-gradient(circle_at_90%_12%,rgba(19,168,184,0.12),transparent_32rem)]" />
<Sidebar />
<main className="flex min-w-0 flex-1 flex-col lg:ml-72">
+4 -10
查看文件
@@ -4,17 +4,11 @@ import { AppBrand } from "../components/shell/AppBrand";
export function AuthLayout() {
return (
<div className="min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-10">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-5xl items-center justify-center">
<section className="hidden w-full max-w-sm lg:block">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-md flex-col justify-center">
<div className="mb-5">
<AppBrand />
<div className="mt-8">
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div>
<h1 className="mt-3 text-4xl font-semibold tracking-tight text-slate-900">海音达 OA</h1>
<p className="mt-4 text-base leading-7 text-slate-600">打开后直接处理今天的审批、报销和团队事项。</p>
</div>
</section>
<section className="w-full max-w-md lg:ml-20">
</div>
<section className="w-full">
<Outlet />
</section>
</div>
+18 -18
查看文件
@@ -242,7 +242,7 @@ export function CutoverConsolePage() {
onSuccess: async () => {
setDryRunFile(null);
await refresh();
toast.success("dry-run 已完成");
toast.success("演练已完成");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
@@ -319,32 +319,32 @@ export function CutoverConsolePage() {
return (
<div className="space-y-6">
<PageHeader title="切换控制台" subtitle="聚焦范围开关、延迟生效和 dry-run 验证,不改 Cutover 核心模型。" />
<PageHeader title="切换控制台" subtitle="范围开关和导入演练。" />
<div className="grid gap-4 md:grid-cols-3">
<div className="card-std p-5">
<div className="text-sm text-zinc-500">已启用</div>
<div className="mt-2 text-3xl font-semibold text-emerald-600">{summary.active}</div>
<div className="mt-1 text-xs text-zinc-500">enabled=true 且已到生效时间</div>
<div className="mt-1 text-xs text-zinc-500">已到生效时间</div>
</div>
<div className="card-std p-5">
<div className="text-sm text-zinc-500">已禁用</div>
<div className="mt-2 text-3xl font-semibold text-zinc-800">{summary.disabled}</div>
<div className="mt-1 text-xs text-zinc-500">enabled=false</div>
<div className="mt-1 text-xs text-zinc-500">手动关闭</div>
</div>
<div className="card-std p-5">
<div className="text-sm text-zinc-500">待生效</div>
<div className="mt-2 text-3xl font-semibold text-amber-600">{summary.pending}</div>
<div className="mt-1 text-xs text-zinc-500">enabled=true 且 startsAt &gt; now</div>
<div className="mt-1 text-xs text-zinc-500">等待生效时间</div>
</div>
</div>
<SectionPanel
title="切换范围"
description="列表视图看当前配置,时间线视图看创建、计划生效与最近更新时间。"
description="当前配置和时间线。"
actions={
<Button type="button" onClick={openCreateDialog}>
新增 scope
新增范围
</Button>
}
>
@@ -368,9 +368,9 @@ export function CutoverConsolePage() {
<TableHeader>
<tr>
<TableHead>类型</TableHead>
<TableHead>范围 Key</TableHead>
<TableHead>范围标识</TableHead>
<TableHead>状态</TableHead>
<TableHead>startsAt</TableHead>
<TableHead>生效时间</TableHead>
<TableHead>倒计时</TableHead>
<TableHead>备注</TableHead>
<TableHead className="w-40">操作</TableHead>
@@ -453,10 +453,10 @@ export function CutoverConsolePage() {
</SectionPanel>
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<SectionPanel title="上传 dry-run CSV" description="导入 CSV 前先做演练,减少正式迁移时的意外。">
<SectionPanel title="上传演练 CSV" description="导入前先演练。">
<div className="space-y-4">
<div className="space-y-2">
<Label>runType</Label>
<Label>演练类型</Label>
<select
className="select-std"
value={dryRunType}
@@ -478,12 +478,12 @@ export function CutoverConsolePage() {
disabled={dryRunMutation.isPending}
onClick={() => dryRunMutation.mutate()}
>
{dryRunMutation.isPending ? "执行中..." : "执行 dry-run"}
{dryRunMutation.isPending ? "执行中..." : "执行演练"}
</Button>
</div>
</SectionPanel>
<SectionPanel title="最近 dry-run" description="保留最近演练记录,方便核对上传文件与结果。">
<SectionPanel title="最近演练" description="核对上传文件和结果。">
{runsQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
{runsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -502,7 +502,7 @@ export function CutoverConsolePage() {
</div>
))}
{!runsQuery.data?.items?.length ? (
<div className="text-sm text-zinc-500">暂无 dry-run 记录</div>
<div className="text-sm text-zinc-500">暂无演练记录</div>
) : null}
</div>
) : null}
@@ -552,7 +552,7 @@ export function CutoverConsolePage() {
</div>
<div className="space-y-2">
<Label>范围 Key</Label>
<Label>范围标识</Label>
<Input
disabled={Boolean(editingScope)}
value={scopeForm.scopeKey}
@@ -562,12 +562,12 @@ export function CutoverConsolePage() {
scopeKey: event.target.value,
}))
}
placeholder="部门 ID / 法体 ID / domain key"
placeholder="部门 ID / 法体 ID / 业务域"
/>
</div>
<div className="space-y-2">
<Label>startsAt</Label>
<Label>生效时间</Label>
<Input
type="datetime-local"
value={scopeForm.startsAt}
@@ -607,7 +607,7 @@ export function CutoverConsolePage() {
}
type="checkbox"
/>
启用该 scope
启用该范围
</label>
</div>
+18 -18
查看文件
@@ -238,7 +238,7 @@ export function ErpIntegrationPage() {
queryClient.invalidateQueries({ queryKey: ["integration-outbox-stats"] }),
queryClient.invalidateQueries({ queryKey: ["integration-outbox-detail"] }),
]);
toast.success("Outbox 事件已重置为待处理");
toast.success("同步任务已重置为待处理");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
@@ -262,7 +262,7 @@ export function ErpIntegrationPage() {
<div className="space-y-6">
<PageHeader
title="ERP 集成"
subtitle="管理主数据同步、Outbox 队列和本地与 ERP 的 ID 映射。报销、采购和资产的具体同步动作仍保留在业务详情区。"
subtitle="主数据同步、业务同步队列和 ID 映射。"
actions={
tab === "master-data" ? (
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
@@ -279,7 +279,7 @@ export function ErpIntegrationPage() {
<Tabs value={tab} onValueChange={(value) => setTab(value as ErpTab)}>
<TabsList className="flex w-full justify-start gap-1">
<TabsTrigger value="master-data">主数据同步</TabsTrigger>
<TabsTrigger value="outbox">Outbox 队列</TabsTrigger>
<TabsTrigger value="outbox">同步队列</TabsTrigger>
<TabsTrigger value="bindings">ID 映射</TabsTrigger>
</TabsList>
@@ -303,14 +303,14 @@ export function ErpIntegrationPage() {
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
</div>
<div>
<div className="text-zinc-500">Provider / 主数据端点</div>
<div className="text-zinc-500">同步服务 / 主数据端点</div>
<div className="font-medium">{gate.provider}</div>
<div className="text-xs text-zinc-500">
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
</div>
</div>
<div>
<div className="text-zinc-500">凭证端点 / Cron</div>
<div className="text-zinc-500">凭证端点 / 自动同步</div>
<div className="font-medium">
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
</div>
@@ -372,9 +372,9 @@ export function ErpIntegrationPage() {
<div className="card-std space-y-2 p-5 text-sm text-zinc-600">
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
<p>- 本页只负责 ERP 主数据同步、Outbox 队列和 ID 映射,不承担业务单据本身的录入。</p>
<p>- 报销、采购和资产的 ERP 同步动作请前往对应业务详情区执行。</p>
<p>- 当前阶段不接入真实 ERP Provider,仍以 mock Provider 为准。</p>
<p>- 本页只负责主数据同步、业务同步队列和 ID 映射。</p>
<p>- 报销、采购和资产的同步动作在对应业务详情区执行。</p>
<p>- 当前仍使用演示同步服务。</p>
</div>
</TabsContent>
@@ -410,8 +410,8 @@ export function ErpIntegrationPage() {
<div className="card-std space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">Outbox 事件列表</h3>
<p className="text-sm text-zinc-500">支持按状态和事件类型筛选,失败和死信事件可在此重试。</p>
<h3 className="text-base font-semibold text-zinc-900">同步任务列表</h3>
<p className="text-sm text-zinc-500">失败任务可重试。</p>
</div>
<div className="flex flex-col gap-3 md:flex-row">
<label className="space-y-1 text-sm text-zinc-600">
@@ -520,7 +520,7 @@ export function ErpIntegrationPage() {
) : (
<TableRow>
<TableCell colSpan={8} className="py-6 text-center text-zinc-500">
当前筛选下暂无 Outbox 事件
当前筛选下暂无同步任务
</TableCell>
</TableRow>
)}
@@ -542,7 +542,7 @@ export function ErpIntegrationPage() {
</div>
<div className="flex flex-col gap-3 md:flex-row">
<label className="space-y-1 text-sm text-zinc-600">
<span>Provider</span>
<span>同步服务</span>
<select
className="select-std min-w-40"
value={bindingProvider}
@@ -551,7 +551,7 @@ export function ErpIntegrationPage() {
setBindingsPage(1);
}}
>
<option value="">全部 Provider</option>
<option value="">全部同步服务</option>
<option value="erp">erp</option>
</select>
</label>
@@ -587,7 +587,7 @@ export function ErpIntegrationPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Provider</TableHead>
<TableHead>同步服务</TableHead>
<TableHead>实体类型</TableHead>
<TableHead>本地 ID</TableHead>
<TableHead>外部 ID</TableHead>
@@ -636,8 +636,8 @@ export function ErpIntegrationPage() {
>
<DialogContent className="max-h-[85vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Outbox 事件详情</DialogTitle>
<DialogDescription>查看完整 payload、错误摘要和当前队列状态。</DialogDescription>
<DialogTitle>同步任务详情</DialogTitle>
<DialogDescription>查看数据内容、错误摘要和当前队列状态。</DialogDescription>
</DialogHeader>
{outboxDetailQuery.isLoading ? <p className="text-sm text-zinc-500">详情加载中...</p> : null}
@@ -650,7 +650,7 @@ export function ErpIntegrationPage() {
{outboxDetailQuery.data ? (
<div className="space-y-4">
<div className="grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 md:grid-cols-2">
<div>Outbox ID:{outboxDetailQuery.data.id}</div>
<div>队列 ID:{outboxDetailQuery.data.id}</div>
<div>事件类型:{outboxDetailQuery.data.eventType}</div>
<div>状态:{outboxStatusLabel(outboxDetailQuery.data.status)}</div>
<div>尝试次数:{outboxDetailQuery.data.attempts} / {outboxDetailQuery.data.maxAttempts}</div>
@@ -668,7 +668,7 @@ export function ErpIntegrationPage() {
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-900">Payload</div>
<div className="text-sm font-medium text-zinc-900">数据内容</div>
<pre className="max-h-[45vh] overflow-auto rounded-lg border border-zinc-200 bg-zinc-950 p-4 text-xs text-zinc-100">
{JSON.stringify(outboxDetailQuery.data.payload ?? null, null, 2)}
</pre>
+1 -1
查看文件
@@ -445,7 +445,7 @@ export function InvoicePoolPage() {
<div className="space-y-6">
<PageHeader
title="票据中心"
subtitle="支持上传、OCR 状态轮询、绑定报销与签名预览。"
subtitle="上传、识别、绑定。"
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
+8 -9
查看文件
@@ -145,12 +145,11 @@ export function LoginPage() {
}
return (
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-7">
<div className="space-y-2">
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">海音达 OA</h1>
<p className="text-sm leading-6 text-slate-600">
{showWecomPrimary ? "从企业微信进入,直接打开移动工作台。" : "使用账号密码进入工作台。"}
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<div>
<h1 className="text-xl font-semibold text-slate-900">登录工作台</h1>
<p className="mt-1 text-sm text-slate-500">
{showWecomPrimary ? "企业微信进入优先。" : "账号密码登录。"}
</p>
</div>
@@ -163,13 +162,13 @@ export function LoginPage() {
) : null}
{showWecomPrimary ? (
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5" data-testid="login-wecom-primary-panel">
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4" data-testid="login-wecom-primary-panel">
<Button className="w-full" data-testid="wecom-entry-primary" disabled={!wecomEntry} type="button" onClick={handleWecomLogin}>
{wecomConfigQuery.isLoading ? "正在准备入口..." : "进入工作台"}
{wecomConfigQuery.isLoading ? "准备中..." : "进入工作台"}
</Button>
{wecomConfig && !wecomEntry ? (
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
企业微信入口暂不可用,请使用账号密码登录。
企业微信入口不可用,请用账号密码。
</div>
) : null}
{!showPasswordPanel ? (
+6 -13
查看文件
@@ -731,13 +731,6 @@ export function MobileApprovalsPage() {
}
const listTitle = taskTab === "pending" ? "待办任务" : "已处理任务";
const listSubtitle = canUseRemote
? taskTab === "pending"
? "在线串行审批,支持进入批量处理模式。"
: "展示最近 30 天已处理任务。"
: taskTab === "pending"
? "离线状态下仅展示已缓存待办,并支持批量入队。"
: "离线模式下暂不提供已处理历史。";
const allVisibleSelected =
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
@@ -745,7 +738,7 @@ export function MobileApprovalsPage() {
<PageScaffold className="space-y-4">
<PageHeader
title="移动审批"
subtitle={deepLinkTaskId ? `深链待办:${deepLinkTaskId}` : "小屏快速处理待办任务,支持离线草稿与联网重放。"}
subtitle={deepLinkTaskId ? `待办:${deepLinkTaskId}` : "处理待办。"}
/>
{!isOnline ? (
@@ -766,7 +759,7 @@ export function MobileApprovalsPage() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3>
<p className="mt-1 text-sm text-zinc-500">{listSubtitle}</p>
<p className="mt-1 text-sm text-zinc-500">{filteredTaskCards.length} 条</p>
</div>
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
</div>
@@ -865,7 +858,7 @@ export function MobileApprovalsPage() {
rows={3}
value={batchRejectComment}
onChange={(event) => setBatchRejectComment(event.target.value)}
placeholder="批量驳回共享意见,所有选中任务将复用这条说明。"
placeholder="驳回意见。"
/>
</div>
) : null}
@@ -927,7 +920,7 @@ export function MobileApprovalsPage() {
<div className="font-medium text-zinc-900">{task.title}</div>
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</div>
<div className="mt-1 text-xs text-zinc-500">任务ID: {task.id}</div>
<div className="mt-1 text-xs text-zinc-500">任务:{task.id}</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
@@ -949,7 +942,7 @@ export function MobileApprovalsPage() {
<div className="font-medium text-zinc-900">{task.title}</div>
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</div>
<div className="mt-1 text-xs text-zinc-500">任务ID: {task.id}</div>
<div className="mt-1 text-xs text-zinc-500">任务:{task.id}</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.reference && task.reference !== task.id ? (
<div className="mt-1 text-xs text-zinc-500">业务参考:{task.reference}</div>
@@ -962,7 +955,7 @@ export function MobileApprovalsPage() {
</section>
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail">
<h3 className="text-base font-semibold text-zinc-900">任务详情与动作</h3>
<h3 className="text-base font-semibold text-zinc-900">详情与处理</h3>
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
+2 -2
查看文件
@@ -173,7 +173,7 @@ export function MobileCapturePage() {
<PageScaffold className="space-y-4">
<PageHeader
title="票据上传"
subtitle="拍照或选择发票文件,离线时自动加入待上传队列。"
subtitle="拍照或选择文件。"
/>
{error ? (
@@ -188,7 +188,7 @@ export function MobileCapturePage() {
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
<p className="mt-1 text-sm leading-5 text-zinc-500">支持图片和 PDF,手机会优先打开相机。</p>
<p className="mt-1 text-sm leading-5 text-zinc-500">图片或 PDF。</p>
</div>
<span className={isOnline ? "shrink-0 text-sm text-emerald-600" : "shrink-0 text-sm text-orange-600"}>
{isOnline ? "在线" : "离线"}
+200 -412
查看文件
@@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -6,33 +5,33 @@ import {
clockInAttendance,
clockOutAttendance,
fetchMyTodayAttendance,
type AttendanceTodayResponse,
} from "../api/attendance";
import { fetchNotifications } from "../api/notifications";
import { fetchPortalTeamStatus } from "../api/portal";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications } from "../api/notifications";
import { isApiError } from "../api/types";
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
import { fetchWorkflowTasks } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
MOBILE_APPROVAL_PERMISSIONS,
MOBILE_ASSET_SCAN_PERMISSIONS,
MOBILE_CAPTURE_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
import { buildMobileApprovalsPath } from "../pwa";
const QUICK_PRESENCE_OPTIONS = [
{ label: "在岗", value: "office" as const },
{ label: "居家", value: "remote" as const },
{ label: "外出", value: "out" as const },
];
type MobileAction = {
key: string;
label: string;
meta: string;
icon: string;
to: string;
tone?: "primary" | "warning";
};
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
@@ -44,12 +43,22 @@ function resolveErrorMessage(error: unknown) {
return "请求失败,请稍后重试。";
}
function isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
function formatDateTime(value: string | null) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString("zh-CN");
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return value;
}
@@ -70,44 +79,6 @@ function formatShortTime(value: string | null) {
}
}
function presenceStatusLabel(item: PresenceItem | null) {
if (!item) {
return "未更新";
}
switch (item.status) {
case "office":
return "在岗";
case "remote":
return "居家";
case "out":
return "外出";
case "travel":
return "出差";
case "leave":
return "请假";
default:
return item.status;
}
}
function canQuickUpdatePresence(item: PresenceItem | null) {
return !(item?.status === "leave" && item.source === "leave_sync");
}
function isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
function attendanceExemptionLabel(reason: unknown) {
if (reason === "leave") return "今日请假,无需打卡";
if (reason === "travel") return "今日差旅,无需打卡";
if (reason === "holiday") return "今日节假日,无需打卡";
if (reason === "weekend") return "今日周末,无需打卡";
if (reason === "policy_disabled") return "当前考勤策略未启用";
return "今日无需打卡";
}
function formatWorkMinutes(value: number | null | undefined) {
if (value == null) {
return "-";
@@ -117,27 +88,13 @@ function formatWorkMinutes(value: number | null | undefined) {
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function resolveNotificationTargetPath(
item: { targetType: string | null; targetId: string | null },
canAccessApprovals: boolean,
) {
if (!item.targetType || !item.targetId) {
return "/notifications";
}
if (item.targetType === "workflow_task" && canAccessApprovals) {
return buildMobileApprovalsPath(item.targetId);
}
if (item.targetType === "employee") {
return "/mobile/me";
}
if (item.targetType === "reimbursement_form") {
return `/reimbursements/${item.targetId}`;
}
return "/notifications";
function attendanceExemptionLabel(reason: unknown) {
if (reason === "leave") return "今日请假";
if (reason === "travel") return "今日差旅";
if (reason === "holiday") return "节假日";
if (reason === "weekend") return "周末";
if (reason === "policy_disabled") return "无需打卡";
return "无需打卡";
}
function workflowEntityLabel(entityType: string | null | undefined) {
@@ -145,69 +102,46 @@ function workflowEntityLabel(entityType: string | null | undefined) {
if (entityType === "reimbursement") return "报销";
if (entityType === "purchase_request") return "采购";
if (entityType === "travel_request") return "差旅";
return entityType || "审批";
return "审批";
}
function resolveTeamStatusBadge(item: {
onLeave: boolean;
leaveType: string | null;
presenceStatus: string | null;
attendanceClockIn: string | null;
}) {
if (item.onLeave) {
return { label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", className: "badge-warning" };
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
if (!attendanceToday) {
return null;
}
if (item.presenceStatus === "office") {
return { label: "在岗", className: "badge-success" };
if (!attendanceToday.needClockIn) {
return null;
}
if (item.presenceStatus === "out") {
return {
label: "外出",
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
};
if (!attendanceToday.item?.clockInAt) {
return "clock-in" as const;
}
if (item.presenceStatus === "travel") {
return {
label: "差旅",
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
};
if (!attendanceToday.item.clockOutAt) {
return "clock-out" as const;
}
if (item.presenceStatus === "remote") {
return { label: "居家", className: "badge-neutral" };
}
if (!item.attendanceClockIn) {
return { label: "未打卡", className: "badge-neutral" };
}
return {
label: "已打卡",
className: "inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
};
return null;
}
function QuickLinkCard({
label,
description,
to,
disabled = false,
}: {
label: string;
description: string;
to: string;
disabled?: boolean;
}) {
if (disabled) {
return (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-400">
<div className="font-medium">{label}</div>
<div className="mt-1 text-xs">{description}</div>
</div>
);
}
function ActionTile({ action }: { action: MobileAction }) {
const isPrimary = action.tone === "primary";
const isWarning = action.tone === "warning";
return (
<Link to={to} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm text-zinc-700 shadow-sm transition-colors hover:bg-zinc-50">
<div className="font-medium text-zinc-900">{label}</div>
<div className="mt-1 text-xs text-zinc-500">{description}</div>
<Link
to={action.to}
className={
isPrimary
? "flex min-h-24 flex-col justify-between rounded-lg bg-zinc-900 p-3 text-white shadow-sm"
: isWarning
? "flex min-h-24 flex-col justify-between rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900"
: "flex min-h-24 flex-col justify-between rounded-lg border border-zinc-200 bg-white p-3 text-zinc-900 shadow-sm"
}
data-testid={`mobile-action-${action.key}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold">{action.label}</span>
<span className="material-symbols-outlined text-[20px]">{action.icon}</span>
</div>
<div className={isPrimary ? "text-xs text-white/75" : "text-xs text-zinc-500"}>{action.meta}</div>
</Link>
);
}
@@ -215,27 +149,11 @@ function QuickLinkCard({
export function MobilePortalPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [nowLabel, setNowLabel] = useState(() =>
new Date().toLocaleTimeString("zh-CN", { hour12: false }),
);
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
useEffect(() => {
const timer = window.setInterval(() => {
setNowLabel(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
}, 1000);
return () => window.clearInterval(timer);
}, []);
const presenceQuery = useQuery({
queryKey: ["mobile", "portal", "presence"],
queryFn: () => fetchMyPresence(),
});
const attendanceTodayQuery = useQuery({
queryKey: ["mobile", "portal", "attendance", "today"],
queryFn: () => fetchMyTodayAttendance(),
@@ -245,34 +163,12 @@ export function MobilePortalPage() {
const pendingCountQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-items"],
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }),
});
const teamStatusQuery = useQuery({
queryKey: ["mobile", "portal", "team-status"],
queryFn: () => fetchPortalTeamStatus(),
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
});
const notificationsQuery = useQuery({
queryKey: ["mobile", "portal", "notifications"],
queryFn: () => fetchNotifications({ page: 1, pageSize: 5 }),
});
const presenceMutation = useMutation({
mutationFn: (status: "office" | "remote" | "out") => upsertMyPresence({ status }),
onSuccess: (response) => {
queryClient.setQueryData(
["mobile", "portal", "presence"],
(current: { item: PresenceItem | null; date: string } | undefined) => ({
item: response.item,
date: current?.date ?? response.item.date,
}),
);
toast.success("今日考勤状态已更新");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
});
const attendanceClockInMutation = useMutation({
@@ -283,11 +179,7 @@ export function MobilePortalPage() {
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success(
response.item.isLate
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
: "上班打卡成功",
);
toast.success(response.item.isLate ? `上班打卡成功,迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
@@ -309,273 +201,170 @@ export function MobilePortalPage() {
},
});
const presenceItem = presenceQuery.data?.item ?? null;
const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceAction = resolveAttendanceAction(attendanceToday);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const teamStatusItems = teamStatusQuery.data ?? [];
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
const pendingTasks = pendingCountQuery.data?.items ?? [];
const notifications = notificationsQuery.data?.items ?? [];
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
const actions: MobileAction[] = [
canAccessApprovals
? {
key: "approvals",
label: "审批",
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
icon: "fact_check",
to: "/mobile/approvals",
tone: pendingApprovalCount > 0 ? "warning" : "primary",
}
: null,
canAccessCapture
? {
key: "capture",
label: "传票据",
meta: "拍照或选文件",
icon: "receipt_long",
to: "/mobile/capture",
tone: "primary",
}
: null,
canAccessReimbursements
? {
key: "reimbursements",
label: "报销",
meta: "新建和查进度",
icon: "request_quote",
to: "/reimbursements",
}
: null,
canAccessLeave
? {
key: "leave",
label: "请假",
meta: "提交申请",
icon: "event_available",
to: "/leave-requests",
}
: null,
{
key: "me",
label: "我的",
meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知",
icon: "person",
to: "/mobile/me",
},
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
<PageScaffold className="space-y-4">
<PageHeader
title="今天"
subtitle="先处理待办,再进入报销、票据和团队事项。"
/>
<PageHeader title="今天" />
<section className="card-std space-y-4 p-4" data-testid="mobile-today-focus">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">今日重点</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">移动端只保留高频动作,低频模块放到“我的”。</div>
</div>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">先处理这几件事</div>
<span className={pendingApprovalCount > 0 ? "badge-warning" : "badge-success"}>
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待审批` : "暂无审批待办"}
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 待批` : "清爽"}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<div className="text-2xl font-semibold text-zinc-900">{pendingApprovalCount}</div>
<div className="mt-1 text-xs text-zinc-500">审批待办</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<div className="text-2xl font-semibold text-zinc-900">{unreadNotificationCount}</div>
<div className="mt-1 text-xs text-zinc-500">未读通知</div>
</div>
</div>
<div className="grid gap-3">
<QuickLinkCard label="处理审批" description="查看待办并快速处理" to="/mobile/approvals" disabled={!canAccessApprovals} />
<QuickLinkCard label="发起报销" description="提交报销和查看进度" to="/reimbursements" disabled={!canAccessReimbursements} />
<QuickLinkCard label="上传票据" description="拍照或选择文件上传" to="/mobile/capture" disabled={!canAccessCapture} />
</div>
</section>
{!attendanceFeatureDisabled ? (
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">今日考勤</div>
<div className="mt-1 text-xs text-zinc-500">
{attendanceToday?.workDate ?? "今日"} · 当前时间 {nowLabel}
</div>
</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
{attendanceTodayQuery.isLoading ? (
<p className="text-sm text-zinc-500">加载今日考勤中...</p>
) : attendanceTodayQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(attendanceTodayQuery.error)}
</p>
) : attendanceToday && !attendanceToday.needClockIn ? (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-base font-semibold text-zinc-900">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
<div className="mt-2 text-sm text-zinc-500">
{attendanceToday.item?.clockInAt || attendanceToday.item?.clockOutAt
? `已有补录记录:${formatDateTime(attendanceToday.item.clockInAt)} / ${formatDateTime(attendanceToday.item.clockOutAt)}`
: "系统已自动排除缺卡统计。"}
</div>
</div>
) : attendanceToday?.item?.clockInAt && !attendanceToday.item.clockOutAt ? (
<div className="space-y-3">
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-700">
已打上班卡:{formatDateTime(attendanceToday.item.clockInAt)}
{attendanceToday.item.isLate ? `,迟到 ${attendanceToday.item.lateMinutes} 分钟` : ""}
</div>
<Button
type="button"
size="lg"
className="w-full bg-amber-500 text-white hover:bg-amber-600"
disabled={attendanceClockOutMutation.isPending}
onClick={() => attendanceClockOutMutation.mutate()}
>
下班打卡
</Button>
</div>
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-base font-semibold text-zinc-900">今日已完成</div>
<div className="mt-2 text-sm text-zinc-600">
上班:{formatDateTime(attendanceToday.item.clockInAt)}
</div>
<div className="mt-1 text-sm text-zinc-600">
下班:{formatDateTime(attendanceToday.item.clockOutAt)}
</div>
<div className="mt-1 text-sm text-zinc-600">
工时:{formatWorkMinutes(attendanceToday.item.workMinutes)}
</div>
</div>
) : (
<div className="space-y-3">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-700">
今日尚未打上班卡。
</div>
<Button
type="button"
size="lg"
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
disabled={attendanceClockInMutation.isPending}
onClick={() => attendanceClockInMutation.mutate()}
>
上班打卡
</Button>
</div>
)}
</section>
) : null}
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">团队状态</div>
<div className="mt-1 text-xs text-zinc-500">查看全员今日在岗、请假和打卡状态。</div>
</div>
<span className="badge-neutral">{teamStatusItems.length} 人</span>
</div>
{teamStatusQuery.isLoading ? <p className="text-sm text-zinc-500">加载团队状态中...</p> : null}
{teamStatusQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(teamStatusQuery.error)}
</p>
) : null}
{!teamStatusQuery.isLoading && !teamStatusQuery.isError && !teamStatusItems.length ? (
<p className="text-sm text-zinc-500">暂无团队状态。</p>
) : null}
<div className="grid grid-cols-2 gap-3">
{teamStatusItems.map((item) => {
const badge = resolveTeamStatusBadge(item);
return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center gap-2">
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-[11px] text-zinc-500">
{item.attendanceClockIn ? `${formatShortTime(item.attendanceClockIn)} 已打卡` : "暂无打卡"}
</div>
</div>
</div>
<div className="mt-2">
<span className={badge.className}>{badge.label}</span>
</div>
</div>
);
})}
</div>
</section>
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">今日考勤状态</div>
<div className="mt-1 text-xs text-zinc-500">
{presenceQuery.isError ? "当前无法读取考勤状态" : `当前状态:${presenceStatusLabel(presenceItem)}`}
</div>
</div>
<span className="badge-neutral">{presenceQuery.data?.date ?? "今日"}</span>
</div>
{presenceItem?.status === "leave" && presenceItem.source === "leave_sync" ? (
<p className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
当前日期已被审批通过的请假覆盖,不能在移动首页手动修改。
</p>
) : null}
<div className="grid grid-cols-3 gap-2">
{QUICK_PRESENCE_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={presenceItem?.status === option.value ? "default" : "secondary"}
className="touch-target"
disabled={presenceMutation.isPending || presenceQuery.isError || !canQuickUpdatePresence(presenceItem)}
onClick={() => presenceMutation.mutate(option.value)}
>
{option.label}
</Button>
<div className="grid grid-cols-2 gap-2">
{actions.map((action) => (
<ActionTile key={action.key} action={action} />
))}
</div>
</section>
<section className="card-std space-y-3 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">待办聚合</div>
<div className="mt-1 text-xs text-zinc-500">
{canAccessApprovals ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
</div>
{!attendanceFeatureDisabled ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-attendance-card">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">打卡</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
{canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
</span>
</div>
{canAccessApprovals ? (
<>
<Button asChild type="button" className="w-full">
<Link to="/mobile/approvals">进入移动审批</Link>
{attendanceTodayQuery.isLoading ? (
<p className="text-sm text-zinc-500">读取中...</p>
) : attendanceTodayQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(attendanceTodayQuery.error)}
</p>
) : attendanceToday && !attendanceToday.needClockIn ? (
<div className="text-sm text-zinc-600">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
<div className="text-sm text-zinc-600">
{formatShortTime(attendanceToday.item.clockInAt)} - {formatShortTime(attendanceToday.item.clockOutAt)}
<span className="ml-2 text-zinc-400">工时 {formatWorkMinutes(attendanceToday.item.workMinutes)}</span>
</div>
) : attendanceToday?.item?.clockInAt ? (
<div className="text-sm text-zinc-600">已上班 {formatShortTime(attendanceToday.item.clockInAt)}</div>
) : (
<div className="text-sm text-zinc-600">今天还没打卡</div>
)}
{attendanceAction ? (
<Button
type="button"
className="mt-3 w-full"
disabled={attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending}
onClick={() => {
if (attendanceAction === "clock-in") {
attendanceClockInMutation.mutate();
} else {
attendanceClockOutMutation.mutate();
}
}}
>
{attendanceAction === "clock-in" ? "上班打卡" : "下班打卡"}
</Button>
) : null}
</section>
) : null}
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">加载待办中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">当前没有审批待办。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">
业务:{workflowEntityLabel(task.workflowInstance?.entityType)} · 实体:{task.workflowInstance?.entityId ?? "-"}
</div>
</Link>
))}
</div>
</>
) : (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
你当前没有审批权限,审批页入口已禁用。
{canAccessApprovals ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-pending-tasks">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待审批</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">全部</Link>
</Button>
</div>
)}
</section>
{(canAccessLeave || canAccessAssetScan) ? (
<section className="card-std space-y-3 p-4">
<div className="text-sm font-semibold text-zinc-900">其他快捷入口</div>
<div className="grid gap-3 sm:grid-cols-2">
<QuickLinkCard label="提交请假" description="进入请假申请页" to="/leave-requests" disabled={!canAccessLeave} />
<QuickLinkCard label="资产扫描" description="进入扫码与盘点" to="/mobile/assets/scan" disabled={!canAccessAssetScan} />
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">没有待处理审批。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">{workflowEntityLabel(task.workflowInstance?.entityType)}</div>
</Link>
))}
</div>
</section>
) : null}
<section className="card-std space-y-3 p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">最近通知</div>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-notifications">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">通知</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/notifications">查看全部</Link>
<Link to="/notifications">全部</Link>
</Button>
</div>
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">加载通知中...</p> : null}
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{notificationsQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(notificationsQuery.error)}
</p>
) : null}
@@ -587,15 +376,14 @@ export function MobilePortalPage() {
{notifications.map((item) => (
<Link
key={item.id}
to={resolveNotificationTargetPath(item, canAccessApprovals)}
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
to="/notifications"
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="font-medium text-zinc-900">{item.title}</div>
{!item.readAt ? <span className="badge-warning shrink-0">未读</span> : null}
<div className="flex items-center justify-between gap-3">
<div className="truncate font-medium text-zinc-900">{item.title}</div>
{!item.readAt ? <span className="badge-warning shrink-0">新</span> : null}
</div>
{item.body ? <div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
<div className="mt-2 text-[11px] text-zinc-400">{formatDateTime(item.createdAt)}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</Link>
))}
</div>
+34 -36
查看文件
@@ -352,11 +352,11 @@ function resolveClockAction(
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
if (!items.length) {
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
return <EmptyStateCard title={title} description="暂无摘要。" />;
}
return (
<SectionPanel title={title} description="优先看结论与下一步,再进入对应模块处理。">
<SectionPanel title={title}>
<div className="space-y-3">
{items.map((item) => (
<Link
@@ -391,7 +391,7 @@ function AnnouncementsList({ announcements }: { announcements: PortalHomePayload
</a>
) : null}
</div>
)) : <EmptyStateCard title="暂无公告" description="当前没有需要广播的公告。" />}
)) : <EmptyStateCard title="暂无公告" description="没有公告。" />}
</div>
);
}
@@ -400,14 +400,14 @@ function RecentVisitsPanel() {
const recentVisits = readRecentVisits();
return (
<SectionPanel title="继续工作" description="保留最近访问路径,帮助快速回到上一次处理中断的位置。">
<SectionPanel title="继续工作">
<div className="space-y-3">
{recentVisits.length ? recentVisits.map((item) => (
<Link key={item.path} to={item.path} className="portal-list-card">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs text-zinc-500">{item.path}</div>
</Link>
)) : <EmptyStateCard title="暂无最近访问" description="开始使用后,最近处理过的页面会显示在这里。" />}
)) : <EmptyStateCard title="暂无最近访问" description="最近页面会显示在这里。" />}
</div>
</SectionPanel>
);
@@ -417,13 +417,13 @@ function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] })
const safeItems = Array.isArray(items) ? items : [];
return (
<SectionPanel title="常用入口" description="只保留高频入口,避免把所有模块堆成超市。">
<SectionPanel title="常用入口">
<div className="grid gap-3 sm:grid-cols-2">
{safeItems.length ? safeItems.map((item) => (
<Link key={item.key} to={item.href} className="portal-list-card text-sm font-medium text-slate-700">
{item.title}
</Link>
)) : <EmptyStateCard title="暂无常用入口" description="工作区尚未配置推荐入口。" />}
)) : <EmptyStateCard title="暂无常用入口" description="尚未配置。" />}
</div>
</SectionPanel>
);
@@ -431,7 +431,7 @@ function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] })
function SecondaryCardsPanel({ title, description, cards }: { title: string; description: string; cards: PortalHomePayload["cards"] }) {
if (!cards.length) {
return <EmptyStateCard title={title} description="暂无需要额外关注的统计项。" />;
return <EmptyStateCard title={title} description="暂无统计项。" />;
}
return (
@@ -443,7 +443,7 @@ function SecondaryCardsPanel({ title, description, cards }: { title: string; des
title={card.title}
value={card.value}
href={card.href}
note="进入详情页处理"
note="查看"
tone={severityTone(card.severity)}
/>
))}
@@ -462,26 +462,26 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-left">
<div>
<div className="text-base font-semibold text-zinc-900">试点观测摘要</div>
<div className="mt-1 text-sm text-zinc-500">管理员按需展开,用于观察习惯是否已形成。</div>
<div className="mt-1 text-sm text-zinc-500">管理员按需查看。</div>
</div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">展开 / 收起</span>
</summary>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="portal-status-card text-sm">
<div className="text-zinc-500">近 7 天登录覆盖率</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.login7dRate}%</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.login7dRate}%</div>
</div>
<div className="portal-status-card text-sm">
<div className="text-zinc-500">周报提交率</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
</div>
<div className="portal-status-card text-sm">
<div className="text-zinc-500">团队状态覆盖率</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
</div>
<div className="portal-status-card text-sm">
<div className="text-zinc-500">当前公告数</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.announcementCount}</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.announcementCount}</div>
</div>
</div>
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600">
@@ -510,7 +510,7 @@ function SecondaryWorkspacePanel({
<summary className="portal-secondary-summary">
<div>
<div className="text-base font-semibold text-zinc-900">{title}</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">{description}</div>
<div className="mt-1 text-sm text-zinc-500">{description}</div>
</div>
<span className="portal-secondary-summary__toggle">展开 / 收起</span>
</summary>
@@ -549,7 +549,7 @@ function GettingStartedCard({
return (
<SectionPanel
title="快速上手"
description="先完成基础资料和入口熟悉,再进入请假、通知等日常动作。"
description="完成基础资料。"
actions={(
<button
type="button"
@@ -652,7 +652,7 @@ function QuickActionsPanel({
return (
<SectionPanel
title="快捷操作栏"
description="保留每天真正高频的动作,不把所有入口都放在首屏。"
description="高频动作"
className={layout === "rail" ? "portal-panel-fill" : undefined}
>
<div className={layout === "rail" ? "portal-action-stack" : "grid gap-3 sm:grid-cols-2 xl:grid-cols-4"}>
@@ -680,7 +680,7 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
return (
<SectionPanel
title="今日待办聚合"
description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。"
description="今天要处理的事"
className="portal-panel-fill"
>
<div className="space-y-3">
@@ -706,11 +706,11 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
</div>
</div>
</Link>
)) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />}
)) : <EmptyStateCard title="暂无待办" description="没有需要立刻处理的事。" />}
</div>
{hiddenCount > 0 ? (
<div className="portal-panel-footer">
<span>还有 {hiddenCount} 项已收起,避免首页变成待办清单。</span>
<span>还有 {hiddenCount} 项已收起。</span>
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
</div>
) : null}
@@ -726,7 +726,7 @@ function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
return (
<SectionPanel
title="团队全员状态"
description="小团队首页只展示关键状态,完整考勤与在岗信息进入团队页。"
description="今日状态"
actions={<Link to="/team" className="text-sm font-medium text-primary hover:underline">查看团队页</Link>}
className="portal-panel-fill"
>
@@ -743,11 +743,11 @@ function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
<span className={badge.className}>{badge.label}</span>
</div>
);
}) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />}
}) : <EmptyStateCard title="暂无团队状态" description="没有成员状态。" />}
</div>
{hiddenCount > 0 ? (
<div className="portal-panel-footer">
<span>已收起 {hiddenCount} 位成员,避免首页纵向过长。</span>
<span>已收起 {hiddenCount} 位成员。</span>
<Link to="/team" className="font-medium text-primary hover:underline">查看全部</Link>
</div>
) : null}
@@ -785,7 +785,7 @@ function ActivityAnnouncementsPanel({
return (
<SectionPanel
title="近期动态流"
description="动态优先,公告进入同一张右侧阅读卡,减少首页白卡堆叠。"
description="动态和公告"
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">公告页</Link>}
className="portal-panel-fill"
>
@@ -959,7 +959,7 @@ export function PortalHomePage() {
href: payload.todayPresence.href,
label: "今日状态",
value: payload.todayPresence.label,
note: payload.todayPresence.note ?? "可在团队页手动更新在岗、居家、外出或出差状态。",
note: payload.todayPresence.note ?? "可在团队页更新。",
tone: payload.todayPresence.status === "leave" ? "warning" as const : "normal" as const,
}
: null,
@@ -971,7 +971,7 @@ export function PortalHomePage() {
href: payload.teamPresence?.href ?? "/team",
label: "今日团队状态",
value: payload.teamPresence ? `在岗 ${payload.teamPresence.office}/${payload.teamPresence.total}` : "待启用",
note: payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态板可展示在岗、居家、外出、出差与请假。",
note: payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态待启用。",
tone: payload.teamPresence && (payload.teamPresence.leave > 0 || payload.teamPresence.unspecified > 0) ? "warning" as const : "normal" as const,
},
payload.weeklySummary
@@ -1039,9 +1039,7 @@ export function PortalHomePage() {
eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
title={`你好,${payload.greeting.name}`}
className="workbench-hero--compact"
subtitle={isManagerView
? "先处理审批、团队状态和周报缺口,再进入项目与组织协同。"
: "先处理今天必须完成的事,再继续查看项目、周报和公告。"}
subtitle={isManagerView ? "审批、团队、周报。" : "待办、打卡、申请。"}
actions={
!payload.workspace.persisted && canSetupWorkspace ? (
<Button asChild variant="secondary" type="button">
@@ -1076,7 +1074,7 @@ export function PortalHomePage() {
fallback={(
<PortalSectionFallback
title="快速上手"
description="先完成基础资料和入口熟悉,再进入请假、通知等日常动作。"
description="完成基础资料。"
/>
)}
>
@@ -1098,7 +1096,7 @@ export function PortalHomePage() {
fallback={(
<PortalSectionFallback
title="团队全员状态"
description="同时看人在不在岗、有没有请假,以及今天是否已打卡。"
description="今日团队状态。"
/>
)}
>
@@ -1108,7 +1106,7 @@ export function PortalHomePage() {
fallback={(
<PortalSectionFallback
title="近期动态流"
description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。"
description="最近业务动作。"
/>
)}
>
@@ -1120,7 +1118,7 @@ export function PortalHomePage() {
<>
<SecondaryWorkspacePanel
title="今日管理重点 / 常用入口"
description="默认收起第二阅读层;需要巡检项目、组织协同或常用入口时再展开。"
description="按需展开。"
>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),360px]">
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
@@ -1129,7 +1127,7 @@ export function PortalHomePage() {
<SecondaryCardsPanel
title="项目跟进与组织协同"
description="把项目、组织和风险类信息下移到第二阅读层。"
description="项目、组织和风险。"
cards={secondaryCards}
/>
</SecondaryWorkspacePanel>
@@ -1139,7 +1137,7 @@ export function PortalHomePage() {
) : (
<SecondaryWorkspacePanel
title="我的申请与资产 / 继续工作"
description="默认收起低频参考信息,首页先服务今天要处理的事。"
description="按需展开。"
>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
@@ -1150,7 +1148,7 @@ export function PortalHomePage() {
<QuickLinksPanel items={quickLinkItems} />
<SecondaryCardsPanel
title="项目跟进与个人协同"
description="项目、资产和其他参考指标保留在第二阅读层。"
description="项目和参考指标。"
cards={secondaryCards}
/>
</div>
+10 -10
查看文件
@@ -801,7 +801,7 @@ export function ReimbursementDetailPage() {
<PageScaffold>
<EntityOverviewHero
title={`报销详情 ${detail.formNumber ?? detail.id}`}
subtitle="在同一工作台内查看概览、处理明细、跟进风险并进入财务桥接。"
subtitle="查看、编辑、提交。"
meta={(
<>
<StatusBadge status={detail.status} />
@@ -825,7 +825,7 @@ export function ReimbursementDetailPage() {
<div className="detail-shell-grid">
<div className="space-y-6">
<SectionPanel title="概览与关联" description="先看主档和关联项目,再决定是否继续补明细或提交流程。">
<SectionPanel title="概览与关联">
<div className="space-y-6" data-testid="reimbursement-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
@@ -922,7 +922,7 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="执行 · 明细与票据" description="明细、票据和编辑器在同一层处理,避免长页面往返。">
<SectionPanel title="明细与票据">
<div className="space-y-6">
<div className="space-y-4" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -1149,7 +1149,7 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="风险与桥接" description="预算提示、风险信号、AI 建议与财务桥接统一查看。">
<SectionPanel title="风险与同步">
<div className="space-y-6">
{budgetHint ? (
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
@@ -1180,7 +1180,7 @@ export function ReimbursementDetailPage() {
{canManageIntegration ? (
<SectionPanel
title="ERP 同步状态"
description="已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP,同步状态、错误摘要和凭证预览入口统一收口在此。"
description="付款后同步到 ERP。"
actions={
<div className="flex gap-2">
<Button
@@ -1226,7 +1226,7 @@ export function ReimbursementDetailPage() {
<div className="mt-1 font-medium text-zinc-900">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">Outbox</div>
<div className="text-xs text-zinc-500">同步队列</div>
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
@@ -1247,7 +1247,7 @@ export function ReimbursementDetailPage() {
</div>
<div className="space-y-6">
<DetailSideMeta title="报销摘要" description="帮助处理人先判断单据状态、关联量和当前审批位置。">
<DetailSideMeta title="报销摘要">
<div>报销编号:{detail.formNumber ?? detail.id}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
@@ -1461,7 +1461,7 @@ export function ReimbursementDetailPage() {
{pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别中,系统会在可绑定后自动刷新列表。
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后自动刷新。
</p>
) : null}
</div>
@@ -1473,7 +1473,7 @@ export function ReimbursementDetailPage() {
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>ERP 凭证预览</DialogTitle>
<DialogDescription>展示当前报销单将发送到 ERP 的凭证切片与最近一次导出状态。</DialogDescription>
<DialogDescription>查看凭证内容和最近同步状态。</DialogDescription>
</DialogHeader>
{erpPreviewQuery.data ? (
@@ -1492,7 +1492,7 @@ export function ReimbursementDetailPage() {
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>同步状态:{erpSyncStatus}</div>
<div>外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列 ID:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
+6 -6
查看文件
@@ -185,7 +185,7 @@ export function WorkflowTaskDetailPage() {
<PageScaffold>
<EntityOverviewHero
title={`任务详情 ${detail.task.stepName}`}
subtitle="在同一工作台内查看审批对象摘要、处理动作和历史留痕。"
subtitle="查看摘要并处理。"
meta={(
<>
<StatusBadge status={detail.task.status} />
@@ -203,7 +203,7 @@ export function WorkflowTaskDetailPage() {
<div className="detail-shell-grid">
<div className="space-y-6">
<SectionPanel title="审批对象摘要" description="先看对象主信息,再决定通过、驳回、退回或转审。">
<SectionPanel title="审批对象摘要">
<div className="space-y-4" data-testid="workflow-task-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
@@ -291,7 +291,7 @@ export function WorkflowTaskDetailPage() {
{detail.workflowInstance?.entityType === "reimbursement" &&
detail.workflowInstance.contextData.standardViolations.length ? (
<SectionPanel title="费用标准提示" description="以下明细在提交时已被标记为超出费用标准,供审批判断时参考。">
<SectionPanel title="费用标准提示">
<div className="space-y-2">
{detail.workflowInstance.contextData.standardViolations.map((item) => (
<div
@@ -314,7 +314,7 @@ export function WorkflowTaskDetailPage() {
</SectionPanel>
) : null}
<SectionPanel title="审批动作" description="把通过、驳回、退回和转审统一收口在同一块操作区。">
<SectionPanel title="审批动作">
<div className="space-y-4">
{actionError ? (
<p
@@ -415,7 +415,7 @@ export function WorkflowTaskDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="处理历史" description="所有审批动作、意见和处理人统一留痕。">
<SectionPanel title="处理历史">
<div className="space-y-2">
{detail.history.length ? (
detail.history.map((item) => (
@@ -435,7 +435,7 @@ export function WorkflowTaskDetailPage() {
</div>
<div className="space-y-6">
<DetailSideMeta title="任务摘要" description="帮助审批人先判断节点、对象和时间,再决定处理动作。">
<DetailSideMeta title="任务摘要">
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{detail.workflowInstance?.currentStatus ?? "-"}</div>
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
@@ -33,7 +33,7 @@ export function InvoiceUploadCard({
return (
<div className="card-std p-6 space-y-4">
<h3 className="card-title">上传票据(VAT OCR)</h3>
<h3 className="card-title">添加发票</h3>
{showProxyOwnerSelector ? (
<select
className="select-std"
@@ -76,7 +76,7 @@ export function InvoiceUploadCard({
onClick={onUpload}
type="button"
>
{isUploading ? uploadProgressLabel || "上传中..." : "上传并触发 OCR"}
{isUploading ? uploadProgressLabel || "上传中..." : "上传"}
</Button>
</div>
@@ -183,7 +183,7 @@ export function UserImportDialog({ open, onOpenChange, onImported }: UserImportD
<DialogHeader>
<DialogTitle>批量导入员工</DialogTitle>
<DialogDescription>
导入结果会同时创建员工档案、登录账号和角色。流程固定为 dry-run 预检后再 apply,成功后可下载一次性密码清单。
上传员工表,先预检,再确认导入。成功后可下载一次性密码清单。
</DialogDescription>
</DialogHeader>
@@ -224,7 +224,7 @@ export function UserImportDialog({ open, onOpenChange, onImported }: UserImportD
onClick={() => dryRunMutation.mutate()}
disabled={!selectedFile || dryRunMutation.isPending || applyMutation.isPending}
>
{dryRunMutation.isPending ? "预检中..." : "执行 dry-run"}
{dryRunMutation.isPending ? "预检中..." : "预检"}
</Button>
<Button
type="button"
@@ -232,7 +232,7 @@ export function UserImportDialog({ open, onOpenChange, onImported }: UserImportD
onClick={() => applyMutation.mutate()}
disabled={!canApply}
>
{applyMutation.isPending ? "导入中..." : "执行 apply"}
{applyMutation.isPending ? "导入中..." : "确认导入"}
</Button>
{applyResult ? (
<Button type="button" variant="secondary" onClick={handleDownloadPasswords}>
@@ -264,7 +264,7 @@ export function UserImportDialog({ open, onOpenChange, onImported }: UserImportD
</div>
) : (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-sm text-zinc-500">
下载模板并上传文件后,先执行 dry-run 查看逐行校验结果。
下载模板并上传文件后,先预检逐行结果。
</div>
)}
</div>
@@ -273,7 +273,7 @@ export function UserImportDialog({ open, onOpenChange, onImported }: UserImportD
{dryRunResult ? (
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">dry-run 结果</h3>
<h3 className="text-base font-semibold text-zinc-900">预检结果</h3>
<div className="mt-1 text-sm text-zinc-500">{dryRunResult.sourceFilename}</div>
</div>
@@ -0,0 +1,9 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
U1,页面精简规则冻结,冻结小团队 OA 的页面文案和布局规则并确认本轮只处理高频入口与通用密度,规则覆盖登录 Portal 移动首页 票据 审批 管理术语且 CSV 校验通过,命令 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_16-24-02-r96-ui-simplification.csv 业务复核本轮范围不扩展业务能力,manual,DONE,DONE,DONE,issues/2026-05-06_16-24-02-r96-ui-simplification.csv,[],2026-05-06 已校验通过 回滚路径 删除本合同
U2,登录页极简化,重构登录页为企业微信优先账号密码备用的单路径入口,企业微信环境只突出进入工作台 普通浏览器显示账号密码和企微登录 无重复品牌说明,Playwright 检查 mobile desktop 登录页截图 按钮数量 文案密度,playwright,DONE,DONE,DONE,client/src/layouts/AuthLayout.tsx|client/src/pages/LoginPage.tsx,["U1"],2026-05-06 已完成 不改认证逻辑只改展示 回滚路径 恢复登录页和认证布局
U3,通用标题和说明文字收敛,调整高频页面 PageHeader 和 SectionPanel 的说明文字密度,高频页面标题不再解释实现细节 错误和空状态文案保留 页面首屏文案明显减少,命令 cd client && npm run typecheck && npm run build 脚本扫描 subtitle description 人工复核 P0 页面截图,typescript|playwright,DONE,DONE,DONE,client/src/components/common/PageHeader.tsx|client/src/components/patterns/SectionPanel.tsx|client/src/pages,["U1"],2026-05-06 typecheck build 通过 回滚路径 恢复页面文案改动
U4,移动首页任务化,把移动首页改为今日任务入口并减少长页面堆叠,首屏可完成待审批 上传票据 看报销 打卡 低频内容下沉 禁用入口不占位,Playwright 360 390px 视觉测试 检查首屏高度 按钮可点击 无出屏,playwright,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/components/mobile/MobileBottomNav.tsx,["U1"],2026-05-06 r89 移动关键路径通过 不改业务 API 回滚路径 恢复移动首页组件
U5,Portal 首页工作台精简,把桌面 Portal 改成今日待办 快捷动作 团队通知摘要的低噪声工作台,首屏信息少于当前版本 管理员观测默认折叠 高频动作突出,Playwright desktop mobile 截图对比 Portal E2E 通过,playwright,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx|client/src/components/patterns,"[""U1"",""U3""]",2026-05-06 visual-shell 通过 保留已有数据源 回滚路径 恢复 Portal 组件改动
U6,高频业务页去说明书化,票据 报销 审批详情采用更直接的标题和操作文案并减少说明卡片,票据上传 报销详情 审批详情主操作更清楚 移动端无出屏,Playwright 跑 invoice reimbursement workflow 流程和视觉巡检,playwright,DONE,DONE,DONE,client/src/pages/InvoicePoolPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx|client/src/pages/MobileApprovalsPage.tsx,"[""U3"",""U4""]",2026-05-06 r92 手机票据回归通过 未改状态机 回滚路径 恢复文案与布局改动
U7,管理页术语降噪,把 ERP 功能开关 流程定义 通知渠道等页面中的技术词降级为管理员可理解的运营语言,普通用户不可见技术词 管理员页保留必要诊断信息但默认弱化,权限账号截图复核 搜索技术词清单,playwright|manual,DONE,DONE,DONE,client/src/pages/SystemSettingsPage.tsx|client/src/pages/ErpIntegrationPage.tsx|client/src/pages/FeatureFlagsPage.tsx|client/src/pages/WorkflowDefinitionsPage.tsx|client/src/pages/NotificationChannelsPage.tsx,"[""U1"",""U3""]",2026-05-06 已完成首轮术语降噪 不改后端配置语义 回滚路径 恢复管理页文案
U8,视觉回归门禁,为 UI 精简专项补截图和 DOM 指标门禁,登录 Portal 移动首页 票据 审批在 360 390 1440 下有截图证据且无横向溢出,Playwright visual audit 输出到 artifacts/r96-ui 并运行 cd client && npm run typecheck && npm run build,playwright|typescript,DONE,DONE,DONE,client/tests/e2e|artifacts/r96-ui,"[""U2"",""U4"",""U5"",""U6"",""U7""]",2026-05-06 typecheck build r89 r92 visual-shell git diff check 均通过 截图产物不纳入 git 报告纳入 artifacts 回滚路径 删除新增报告和测试改动
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 377 列。