重构移动端轻动作页面(05-30-mobile-light-action-surfaces)

这个提交包含在:
2026-05-30 13:52:20 +08:00
父节点 fe0df2397c
当前提交 695f5c2c75
共修改 8 个文件,包含 138 行新增和 308 行删除
@@ -39,10 +39,10 @@
## Acceptance Criteria
- [ ] 移动端所有核心页面都符合焦点卡/卡片列表/底部动作的统一结构。
- [ ] 小屏按钮文字完整可读,底部动作避开安全区和键盘遮挡。
- [ ] 移动通知不复制待办处理列表。
- [ ] 移动 HR/考勤不误导为 OA 日常提交入口。
- [x] 移动端所有核心页面都符合焦点卡/卡片列表/底部动作的统一结构。
- [x] 小屏按钮文字完整可读,底部动作避开安全区和键盘遮挡。
- [x] 移动通知不复制待办处理列表。
- [x] 移动 HR/考勤不误导为 OA 日常提交入口。
## Test Plan
@@ -56,3 +56,5 @@
- 这是跨多个移动页面的统一任务,实施时应分页面小步提交或至少分阶段验证截图。
- 若发现 PC 与移动共享组件冲突,优先保留移动轻动作语义,不把 PC 管理壳搬到移动端。
- 2026-05-30 实施:移动首页请假入口改为请假记录/企微主办;移动审批去掉批量处理,只保留列表筛选和详情底部处理;移动报销/采购列表补搜索;移动我的去掉没有移动页承接的项目动态入口;移动视觉测试改为验证请假只读记录页。
- 验证:`cd client && npm run typecheck`、`cd client && npm run build`、`cd client && npx playwright test tests/e2e/r6-mobile-unified-design-rollout.spec.ts --workers=1 --reporter=list`、`python3 ./.trellis/scripts/task.py validate 05-30-mobile-light-action-surfaces`、`node scripts/qa/check-trellis-governance.mjs`、`git diff --check`。
@@ -3,7 +3,7 @@
"name": "mobile-light-action-surfaces",
"title": "移动端轻动作页面统一重构",
"description": "统一移动首页、移动审批、移动报销、移动采购、移动通知、移动我的的焦点卡、卡片列表和底部动作规则。",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": "frontend-page-redesign",
"package": null,
+9 -261
查看文件
@@ -388,9 +388,6 @@ export function MobileApprovalsPage() {
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
const [searchInput, setSearchInput] = useState("");
const deferredSearch = useDeferredValue(searchInput);
const [batchMode, setBatchMode] = useState(false);
const [selectedBatchTaskIds, setSelectedBatchTaskIds] = useState<string[]>([]);
const [batchRejectComment, setBatchRejectComment] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [comment, setComment] = useState("");
@@ -441,14 +438,6 @@ export function MobileApprovalsPage() {
setTaskTab(resolveTaskTab(searchParams.get("taskTab")));
}, [searchParams]);
useEffect(() => {
if (taskTab === "completed") {
setBatchMode(false);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
}
}, [taskTab]);
function handleTaskSelection(taskId: string) {
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`, { state: { from: currentMobileSource } });
}
@@ -569,11 +558,6 @@ export function MobileApprovalsPage() {
[baseTaskCards, deferredSearch, entityFilter],
);
const pendingTaskMap = useMemo(
() => new Map(filteredTaskCards.map((task) => [task.id, task])),
[filteredTaskCards],
);
const selectedCachedTaskDetail = useMemo(
() => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null,
[filteredTaskCards, selectedTaskId],
@@ -627,11 +611,6 @@ export function MobileApprovalsPage() {
const employeeProgressError =
reimbursementProgressQuery.error ?? leaveProgressQuery.error ?? purchaseProgressQuery.error ?? null;
useEffect(() => {
const visibleIds = new Set(filteredTaskCards.map((task) => task.id));
setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId)));
}, [filteredTaskCards]);
const canTransfer = Boolean(
canUseRemote &&
selectedTaskAllowsActions &&
@@ -785,100 +764,6 @@ export function MobileApprovalsPage() {
},
});
const batchActionMutation = useMutation({
mutationFn: async (input: { action: WorkflowDraftAction; taskIds: string[]; comment?: string }) => {
if (!input.taskIds.length) {
throw new Error("请先选择至少一条待办");
}
if (input.action === "reject" && !input.comment?.trim()) {
throw new Error("批量驳回必须填写共享意见");
}
let succeeded = 0;
let failed = 0;
let skipped = 0;
const failures: string[] = [];
for (const taskId of input.taskIds) {
const taskCard = pendingTaskMap.get(taskId);
if (!taskCard) {
skipped += 1;
failures.push(`${taskId}: 当前筛选结果中找不到该待办`);
continue;
}
if (!taskCard.allowedActions.includes(input.action)) {
skipped += 1;
failures.push(`${taskId}: 当前待办不允许${input.action === "approve" ? "通过" : "驳回"}`);
continue;
}
try {
if (canUseRemote) {
if (input.action === "approve") {
await approveWorkflowTask(taskId);
} else {
await rejectWorkflowTask(taskId, input.comment!.trim());
}
} else {
if (!taskCard.detail) {
throw new Error("当前待办尚未缓存,请联网后重试");
}
await enqueueWorkflowActionDraft({
taskId,
action: input.action,
comment: input.action === "reject" ? input.comment?.trim() ?? null : null,
ownerEmployeeId,
detail: taskCard.detail,
});
}
succeeded += 1;
} catch (batchError) {
failed += 1;
failures.push(`${taskId}: ${resolveErrorMessage(batchError)}`);
}
}
return { ...input, succeeded, failed, skipped, failures };
},
onSuccess: async (result) => {
if (canUseRemote) {
await invalidateWorkflowQueries(selectedTaskId);
} else {
await refreshDraftQueueCache();
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] });
}
const actionLabel = result.action === "approve" ? "通过" : "驳回";
const summary = canUseRemote
? `批量${actionLabel}完成:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`
: `离线批量${actionLabel}已入队:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`;
setBatchMode(false);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
setComment("");
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
setMessage(summary);
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
navigate("/mobile/approvals", { replace: true });
}
if (result.failed > 0) {
toast.error(summary);
return;
}
toast.success(summary);
},
onError: (batchError) => {
const nextError = resolveErrorMessage(batchError);
setError(nextError);
setMessage("");
toast.error(nextError);
},
});
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
const syncMutation = useMutation({
@@ -967,33 +852,7 @@ export function MobileApprovalsPage() {
}
}, [canUseRemote, retryableQueueItems, syncMutation]);
function toggleBatchTask(taskId: string) {
setSelectedBatchTaskIds((current) =>
current.includes(taskId) ? current.filter((id) => id !== taskId) : [...current, taskId],
);
}
function toggleSelectAllVisibleTasks() {
const visibleIds = filteredTaskCards.map((task) => task.id);
const allSelected = visibleIds.length > 0 && visibleIds.every((taskId) => selectedBatchTaskIds.includes(taskId));
if (allSelected) {
setSelectedBatchTaskIds([]);
return;
}
setSelectedBatchTaskIds(visibleIds);
}
function handleBatchAction(action: WorkflowDraftAction) {
batchActionMutation.mutate({
action,
taskIds: selectedBatchTaskIds,
comment: action === "reject" ? batchRejectComment : undefined,
});
}
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
const allVisibleSelected =
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
const listTitle = taskTab === "pending" ? "待我处理" : "已处理";
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
const approvalFocusText = taskTab === "pending"
@@ -1004,7 +863,7 @@ export function MobileApprovalsPage() {
const selectedActionText = selectedTask
? selectedTaskAllowsActions
? canUseRemote
? "核对摘要和历史后,可在底部直接通过、驳回或退回。"
? "核对摘要和历史后,可在底部通过、驳回或退回。"
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
: "当前任务已处理,本页只展示业务摘要和处理历史。"
: "";
@@ -1033,7 +892,7 @@ export function MobileApprovalsPage() {
/>
) : (
<PageHeader
title={canAccessRemoteApprovals ? "待办" : "进度"}
title={canAccessRemoteApprovals ? "待我处理" : "进度"}
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
/>
)}
@@ -1127,8 +986,8 @@ export function MobileApprovalsPage() {
<div className="mt-1 text-xl font-semibold text-zinc-900">{currentQueueItems.length}</div>
</div>
<div>
<div className="text-xs text-zinc-500">批量模式</div>
<div className="mt-1 text-sm font-semibold text-zinc-900">{batchMode ? `已选 ${selectedBatchTaskIds.length}` : "未开启"}</div>
<div className="text-xs text-zinc-500">处理方式</div>
<div className="mt-1 text-sm font-semibold text-zinc-900">逐条核对</div>
</div>
</div>
</section>
@@ -1161,7 +1020,7 @@ export function MobileApprovalsPage() {
<details className="mobile-subcard bg-white text-sm">
<summary className="cursor-pointer select-none font-medium text-zinc-700">
筛选与批量
筛选
</summary>
<div className="mt-3 space-y-3">
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
@@ -1174,82 +1033,13 @@ export function MobileApprovalsPage() {
</TabsList>
</Tabs>
{taskTab === "pending" ? (
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
<Button
type="button"
size="sm"
variant={batchMode ? "default" : "secondary"}
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={() => {
setBatchMode((current) => !current);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
}}
>
{batchMode ? "退出批量" : "批量"}
</Button>
{batchMode ? (
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={toggleSelectAllVisibleTasks}
>
{allVisibleSelected ? "清空选择" : "全选当前"}
</Button>
) : null}
<span className="text-sm text-zinc-500 sm:ml-1">
当前 {filteredTaskCards.length} 条
{batchMode ? `,已选 ${selectedBatchTaskIds.length}` : ""}
</span>
</div>
) : (
<p className="text-sm text-zinc-500">仅显示最近 30 天。</p>
)}
<p className="text-sm text-zinc-500">
{taskTab === "pending" ? `当前 ${filteredTaskCards.length} 条` : "仅显示最近 30 天。"}
</p>
</div>
</details>
</div>
{batchMode ? (
<div className="mobile-subcard bg-zinc-50 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-zinc-700">已选 {selectedBatchTaskIds.length} 条</div>
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
onClick={() => handleBatchAction("approve")}
>
{canUseRemote ? "批量通过" : "保存通过草稿"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
onClick={() => handleBatchAction("reject")}
>
{canUseRemote ? "批量驳回" : "保存驳回草稿"}
</Button>
</div>
</div>
<Textarea
className="mt-3"
rows={3}
value={batchRejectComment}
onChange={(event) => setBatchRejectComment(event.target.value)}
placeholder="驳回意见。"
/>
</div>
) : null}
{canUseRemote && tasksQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Spinner size="sm" />
@@ -1282,48 +1072,6 @@ export function MobileApprovalsPage() {
<div className="mobile-page-stack">
{filteredTaskCards.map((task) => {
const isSelected = selectedTaskId === task.id;
const isBatchSelected = selectedBatchTaskIds.includes(task.id);
if (batchMode && taskTab === "pending") {
return (
<div
key={task.id}
className={`mobile-list-card grid grid-cols-[auto_minmax(0,1fr)] gap-3 ${
isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`}
>
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-zinc-300"
checked={isBatchSelected}
onChange={() => toggleBatchTask(task.id)}
aria-label={`选择${entityLabel(task.entityType)}待办`}
/>
<button
type="button"
className="min-h-16 min-w-0 flex-1 text-left"
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)}
>
<div className="mobile-list-card__header mobile-list-card__header--approval">
<div className="mobile-list-card__title">{task.title}</div>
<span className="mobile-status-slot mobile-status-slot--approval">
<StatusBadge
className="mobile-status-badge"
status={task.status}
label={workflowStatusLabel(task.status)}
/>
</span>
</div>
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
</div>
);
}
return (
<button
-2
查看文件
@@ -54,7 +54,6 @@ function MobileActionLink({
export function MobileMePage() {
const { me, permissions, refreshMe, logout } = useAuth();
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const canAccessFinance = hasFinanceRole(me?.roleCodes, permissions);
@@ -120,7 +119,6 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待办" : "进度"} icon="fact_check" />
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与付款状态" icon="account_balance_wallet" /> : null}
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
</div>
</SectionPanel>
+18 -16
查看文件
@@ -223,10 +223,10 @@ export function MobilePortalPage() {
canAccessLeave
? {
key: "leave",
label: "请假",
meta: "提交申请",
icon: "event_available",
to: "/mobile/leave?view=form",
label: "请假记录",
meta: "企微主办",
icon: "event_note",
to: "/mobile/leave",
}
: null,
canAccessPurchase
@@ -330,8 +330,8 @@ export function MobilePortalPage() {
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">台账/导出</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">可用</div>
<div className="text-xs text-zinc-500">电脑端</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">处理台账</div>
</div>
</div>
) : null}
@@ -344,16 +344,18 @@ export function MobilePortalPage() {
</section>
) : null}
<section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
<div className="mobile-section-header">
<div className="mobile-section-title">常用动作</div>
</div>
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
{actions.map((action) => (
<ActionTile key={action.key} action={action} />
))}
</div>
</section>
{actions.length ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
<div className="mobile-section-header">
<div className="mobile-section-title">常用动作</div>
</div>
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
{actions.map((action) => (
<ActionTile key={action.key} action={action} />
))}
</div>
</section>
) : null}
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
<div className="mobile-section-header">
@@ -9,6 +9,7 @@ import {
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Input } from "../components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { joinBusinessMeta } from "../lib/businessDisplay";
@@ -63,6 +64,29 @@ function resolveMobileEmptyState(status: PurchaseRequestStatus) {
return "暂无已取消采购";
}
function normalizeSearchValue(value: string | null | undefined) {
return String(value ?? "").trim().toLowerCase();
}
function matchesPurchaseSearch(item: PurchaseRequestSummary, keyword: string) {
const searchValue = normalizeSearchValue(keyword);
if (!searchValue) {
return true;
}
const haystack = [
resolvePurchaseDisplayCode(item),
item.title,
item.reason,
item.totalAmount,
item.applicant?.name,
item.supplier?.name,
item.project?.name,
].map(normalizeSearchValue).join(" ");
return haystack.includes(searchValue);
}
function PurchaseRequestCard({
item,
currentMobileSource,
@@ -114,6 +138,7 @@ export function MobilePurchaseRequestsPage() {
const queryStatus = searchParams.get("status");
return isPurchaseStatus(queryStatus) ? queryStatus : "draft";
});
const [searchInput, setSearchInput] = useState("");
const currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search);
@@ -138,9 +163,13 @@ export function MobilePurchaseRequestsPage() {
}
const items = listQuery.data?.items ?? [];
const visibleItems = useMemo(
() => items.filter((item) => matchesPurchaseSearch(item, searchInput)),
[items, searchInput],
);
const totalAmount = useMemo(
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[items],
() => visibleItems.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[visibleItems],
);
return (
@@ -149,11 +178,11 @@ export function MobilePurchaseRequestsPage() {
<section className="mobile-card">
<div className="text-sm font-semibold text-zinc-900">当前采购任务</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">手机端只看状态、材料摘要和下一步;台账筛选、全范围和导出留在电脑端。</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">先看材料是否齐,再进详情核对采购依据。</div>
<div className="mobile-info-grid">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{listQuery.data?.total ?? 0}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{visibleItems.length}</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
@@ -163,6 +192,14 @@ export function MobilePurchaseRequestsPage() {
</section>
<section className="mobile-card" data-testid="mobile-purchase-requests-list">
<div className="mobile-command-surface mb-4">
<Input
aria-label="搜索采购"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="搜索编号、标题、供应商"
/>
</div>
<Tabs value={status} onValueChange={handleStatusChange}>
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 min-[390px]:grid-cols-5">
{STATUS_TABS.map((item) => (
@@ -180,10 +217,12 @@ export function MobilePurchaseRequestsPage() {
{resolveErrorMessage(listQuery.error)}
</div>
) : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="mobile-empty-state">{resolveMobileEmptyState(status)}</div>
{!listQuery.isLoading && !listQuery.isError && !visibleItems.length ? (
<div className="mobile-empty-state">
{searchInput.trim() ? "没有符合搜索的采购记录" : resolveMobileEmptyState(status)}
</div>
) : null}
{items.map((item) => (
{visibleItems.map((item) => (
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
))}
</div>
+50 -16
查看文件
@@ -18,6 +18,7 @@ import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { Input } from "../components/ui/input";
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
{ value: "draft", label: "草稿" },
@@ -63,6 +64,28 @@ function reimbursementListNextStep(status: ReimbursementStatus) {
return "查看当前报销状态和可执行操作。";
}
function normalizeSearchValue(value: string | null | undefined) {
return String(value ?? "").trim().toLowerCase();
}
function matchesReimbursementSearch(item: ReimbursementListItem, keyword: string) {
const searchValue = normalizeSearchValue(keyword);
if (!searchValue) {
return true;
}
const haystack = [
resolveReimbursementDisplayCode(item),
item.description,
item.totalAmount,
item.owner?.name,
item.submitter?.name,
item.project?.name,
].map(normalizeSearchValue).join(" ");
return haystack.includes(searchValue);
}
function ReimbursementCard({
item,
canDeleteDraft,
@@ -132,6 +155,7 @@ export function MobileReimbursementsPage() {
const queryStatus = searchParams.get("status");
return isReimbursementStatus(queryStatus) ? queryStatus : "draft";
});
const [searchInput, setSearchInput] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const createIntentHandledRef = useRef(false);
const canCreateExpense = hasPermission(permissions, "expense.create");
@@ -207,17 +231,21 @@ export function MobileReimbursementsPage() {
});
const items = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const visibleItems = useMemo(
() => items.filter((item) => matchesReimbursementSearch(item, searchInput)),
[items, searchInput],
);
const total = visibleItems.length;
const totalAmount = useMemo(
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[items],
() => visibleItems.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[visibleItems],
);
return (
<PageScaffold className="space-y-4">
<PageHeader
title="移动报销"
subtitle="手机端只处理新建、补材料、查状态和草稿删除。"
subtitle="新建、补材料、查状态。"
meta={<span className="badge-neutral">{STATUS_LABEL[status]}</span>}
actions={
canCreateExpense ? (
@@ -265,15 +293,21 @@ export function MobileReimbursementsPage() {
</div>
</div>
<div className="mobile-command-surface">
<Tabs value={status} onValueChange={handleStatusChange}>
<TabsList className="grid h-auto w-full grid-cols-4">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-reimbursements-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Input
aria-label="搜索报销"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="搜索编号、说明、金额"
/>
<Tabs value={status} onValueChange={handleStatusChange}>
<TabsList className="grid h-auto w-full grid-cols-4">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-reimbursements-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="mobile-page-stack">
@@ -283,10 +317,10 @@ export function MobileReimbursementsPage() {
{resolveErrorMessage(listQuery.error)}
</div>
) : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="mobile-empty-state">暂无报销记录</div>
{!listQuery.isLoading && !listQuery.isError && !visibleItems.length ? (
<div className="mobile-empty-state">{searchInput.trim() ? "没有符合搜索的报销记录" : "暂无报销记录"}</div>
) : null}
{items.map((item) => (
{visibleItems.map((item) => (
<ReimbursementCard
canDeleteDraft={canDeleteExpense && item.status === "draft"}
currentMobileSource={currentMobileSource}
@@ -36,7 +36,7 @@ const MOBILE_PAGES = [
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
{ key: "notifications", path: "/mobile/notifications", marker: "mobile-notifications-page", deep: false },
{ key: "capture", path: "/mobile/capture", marker: "mobile-capture-file", deep: false },
{ key: "leave", path: "/mobile/leave?view=form", marker: "mobile-leave-form", deep: false },
{ key: "leave", path: "/mobile/leave", marker: "mobile-leave-records", deep: false },
{ key: "asset-scan", path: "/mobile/assets/scan", marker: "barcode-scanner-panel", deep: false },
] as const;
@@ -626,6 +626,13 @@ test.describe("MUD6 专用移动页面统一设计", () => {
for (const mobilePage of MOBILE_PAGES) {
await page.goto(mobilePage.path);
await expect(page.getByTestId(mobilePage.marker)).toBeVisible();
if (mobilePage.key === "leave") {
await expect(page.getByTestId("mobile-leave-form")).toHaveCount(0);
await expect(page.getByText("企业微信主办")).toBeVisible();
}
if (mobilePage.key === "approvals") {
await expect(page.getByText("批量")).toHaveCount(0);
}
await expectUnifiedMobilePage(page, `${device.label} ${mobilePage.key}`, `${device.key}-${mobilePage.key}`, {
deep: mobilePage.deep,
});