重构移动端轻动作页面(05-30-mobile-light-action-surfaces)
这个提交包含在:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户