重构移动端轻动作页面(05-30-mobile-light-action-surfaces)
这个提交包含在:
@@ -39,10 +39,10 @@
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] 移动端所有核心页面都符合焦点卡/卡片列表/底部动作的统一结构。
|
- [x] 移动端所有核心页面都符合焦点卡/卡片列表/底部动作的统一结构。
|
||||||
- [ ] 小屏按钮文字完整可读,底部动作避开安全区和键盘遮挡。
|
- [x] 小屏按钮文字完整可读,底部动作避开安全区和键盘遮挡。
|
||||||
- [ ] 移动通知不复制待办处理列表。
|
- [x] 移动通知不复制待办处理列表。
|
||||||
- [ ] 移动 HR/考勤不误导为 OA 日常提交入口。
|
- [x] 移动 HR/考勤不误导为 OA 日常提交入口。
|
||||||
|
|
||||||
## Test Plan
|
## Test Plan
|
||||||
|
|
||||||
@@ -56,3 +56,5 @@
|
|||||||
|
|
||||||
- 这是跨多个移动页面的统一任务,实施时应分页面小步提交或至少分阶段验证截图。
|
- 这是跨多个移动页面的统一任务,实施时应分页面小步提交或至少分阶段验证截图。
|
||||||
- 若发现 PC 与移动共享组件冲突,优先保留移动轻动作语义,不把 PC 管理壳搬到移动端。
|
- 若发现 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",
|
"name": "mobile-light-action-surfaces",
|
||||||
"title": "移动端轻动作页面统一重构",
|
"title": "移动端轻动作页面统一重构",
|
||||||
"description": "统一移动首页、移动审批、移动报销、移动采购、移动通知、移动我的的焦点卡、卡片列表和底部动作规则。",
|
"description": "统一移动首页、移动审批、移动报销、移动采购、移动通知、移动我的的焦点卡、卡片列表和底部动作规则。",
|
||||||
"status": "planning",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": "frontend-page-redesign",
|
"scope": "frontend-page-redesign",
|
||||||
"package": null,
|
"package": null,
|
||||||
|
|||||||
@@ -388,9 +388,6 @@ export function MobileApprovalsPage() {
|
|||||||
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const deferredSearch = useDeferredValue(searchInput);
|
const deferredSearch = useDeferredValue(searchInput);
|
||||||
const [batchMode, setBatchMode] = useState(false);
|
|
||||||
const [selectedBatchTaskIds, setSelectedBatchTaskIds] = useState<string[]>([]);
|
|
||||||
const [batchRejectComment, setBatchRejectComment] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
@@ -441,14 +438,6 @@ export function MobileApprovalsPage() {
|
|||||||
setTaskTab(resolveTaskTab(searchParams.get("taskTab")));
|
setTaskTab(resolveTaskTab(searchParams.get("taskTab")));
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (taskTab === "completed") {
|
|
||||||
setBatchMode(false);
|
|
||||||
setSelectedBatchTaskIds([]);
|
|
||||||
setBatchRejectComment("");
|
|
||||||
}
|
|
||||||
}, [taskTab]);
|
|
||||||
|
|
||||||
function handleTaskSelection(taskId: string) {
|
function handleTaskSelection(taskId: string) {
|
||||||
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`, { state: { from: currentMobileSource } });
|
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`, { state: { from: currentMobileSource } });
|
||||||
}
|
}
|
||||||
@@ -569,11 +558,6 @@ export function MobileApprovalsPage() {
|
|||||||
[baseTaskCards, deferredSearch, entityFilter],
|
[baseTaskCards, deferredSearch, entityFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingTaskMap = useMemo(
|
|
||||||
() => new Map(filteredTaskCards.map((task) => [task.id, task])),
|
|
||||||
[filteredTaskCards],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedCachedTaskDetail = useMemo(
|
const selectedCachedTaskDetail = useMemo(
|
||||||
() => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null,
|
() => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null,
|
||||||
[filteredTaskCards, selectedTaskId],
|
[filteredTaskCards, selectedTaskId],
|
||||||
@@ -627,11 +611,6 @@ export function MobileApprovalsPage() {
|
|||||||
const employeeProgressError =
|
const employeeProgressError =
|
||||||
reimbursementProgressQuery.error ?? leaveProgressQuery.error ?? purchaseProgressQuery.error ?? null;
|
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(
|
const canTransfer = Boolean(
|
||||||
canUseRemote &&
|
canUseRemote &&
|
||||||
selectedTaskAllowsActions &&
|
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 isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
|
||||||
|
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
@@ -967,33 +852,7 @@ export function MobileApprovalsPage() {
|
|||||||
}
|
}
|
||||||
}, [canUseRemote, retryableQueueItems, syncMutation]);
|
}, [canUseRemote, retryableQueueItems, syncMutation]);
|
||||||
|
|
||||||
function toggleBatchTask(taskId: string) {
|
const listTitle = taskTab === "pending" ? "待我处理" : "已处理";
|
||||||
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 shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
||||||
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
|
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
|
||||||
const approvalFocusText = taskTab === "pending"
|
const approvalFocusText = taskTab === "pending"
|
||||||
@@ -1004,7 +863,7 @@ export function MobileApprovalsPage() {
|
|||||||
const selectedActionText = selectedTask
|
const selectedActionText = selectedTask
|
||||||
? selectedTaskAllowsActions
|
? selectedTaskAllowsActions
|
||||||
? canUseRemote
|
? canUseRemote
|
||||||
? "核对摘要和历史后,可在底部直接通过、驳回或退回。"
|
? "核对摘要和历史后,可在底部通过、驳回或退回。"
|
||||||
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
|
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
|
||||||
: "当前任务已处理,本页只展示业务摘要和处理历史。"
|
: "当前任务已处理,本页只展示业务摘要和处理历史。"
|
||||||
: "";
|
: "";
|
||||||
@@ -1033,7 +892,7 @@ export function MobileApprovalsPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={canAccessRemoteApprovals ? "待办" : "进度"}
|
title={canAccessRemoteApprovals ? "待我处理" : "进度"}
|
||||||
subtitle={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 className="mt-1 text-xl font-semibold text-zinc-900">{currentQueueItems.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-zinc-500">批量模式</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="mt-1 text-sm font-semibold text-zinc-900">逐条核对</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1161,7 +1020,7 @@ export function MobileApprovalsPage() {
|
|||||||
|
|
||||||
<details className="mobile-subcard bg-white text-sm">
|
<details className="mobile-subcard bg-white text-sm">
|
||||||
<summary className="cursor-pointer select-none font-medium text-zinc-700">
|
<summary className="cursor-pointer select-none font-medium text-zinc-700">
|
||||||
筛选与批量
|
筛选
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
|
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
|
||||||
@@ -1174,82 +1033,13 @@ export function MobileApprovalsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{taskTab === "pending" ? (
|
<p className="text-sm text-zinc-500">
|
||||||
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
|
{taskTab === "pending" ? `当前 ${filteredTaskCards.length} 条` : "仅显示最近 30 天。"}
|
||||||
<Button
|
</p>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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 ? (
|
{canUseRemote && tasksQuery.isLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
@@ -1282,48 +1072,6 @@ export function MobileApprovalsPage() {
|
|||||||
<div className="mobile-page-stack">
|
<div className="mobile-page-stack">
|
||||||
{filteredTaskCards.map((task) => {
|
{filteredTaskCards.map((task) => {
|
||||||
const isSelected = selectedTaskId === task.id;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ function MobileActionLink({
|
|||||||
export function MobileMePage() {
|
export function MobileMePage() {
|
||||||
const { me, permissions, refreshMe, logout } = useAuth();
|
const { me, permissions, refreshMe, logout } = useAuth();
|
||||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
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 canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
|
||||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||||
const canAccessFinance = hasFinanceRole(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" />
|
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待办" : "进度"} icon="fact_check" />
|
||||||
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与付款状态" icon="account_balance_wallet" /> : null}
|
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与付款状态" icon="account_balance_wallet" /> : null}
|
||||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -223,10 +223,10 @@ export function MobilePortalPage() {
|
|||||||
canAccessLeave
|
canAccessLeave
|
||||||
? {
|
? {
|
||||||
key: "leave",
|
key: "leave",
|
||||||
label: "请假",
|
label: "请假记录",
|
||||||
meta: "提交申请",
|
meta: "企微主办",
|
||||||
icon: "event_available",
|
icon: "event_note",
|
||||||
to: "/mobile/leave?view=form",
|
to: "/mobile/leave",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
canAccessPurchase
|
canAccessPurchase
|
||||||
@@ -330,8 +330,8 @@ export function MobilePortalPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||||
<div className="text-xs text-zinc-500">台账/导出</div>
|
<div className="text-xs text-zinc-500">电脑端</div>
|
||||||
<div className="mt-1 text-xl font-semibold text-zinc-900">可用</div>
|
<div className="mt-1 text-xl font-semibold text-zinc-900">处理台账</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -344,16 +344,18 @@ export function MobilePortalPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
|
{actions.length ? (
|
||||||
<div className="mobile-section-header">
|
<section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
|
||||||
<div className="mobile-section-title">常用动作</div>
|
<div className="mobile-section-header">
|
||||||
</div>
|
<div className="mobile-section-title">常用动作</div>
|
||||||
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
|
</div>
|
||||||
{actions.map((action) => (
|
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
|
||||||
<ActionTile key={action.key} action={action} />
|
{actions.map((action) => (
|
||||||
))}
|
<ActionTile key={action.key} action={action} />
|
||||||
</div>
|
))}
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
|
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
|
||||||
<div className="mobile-section-header">
|
<div className="mobile-section-header">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||||
import { joinBusinessMeta } from "../lib/businessDisplay";
|
import { joinBusinessMeta } from "../lib/businessDisplay";
|
||||||
@@ -63,6 +64,29 @@ function resolveMobileEmptyState(status: PurchaseRequestStatus) {
|
|||||||
return "暂无已取消采购";
|
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({
|
function PurchaseRequestCard({
|
||||||
item,
|
item,
|
||||||
currentMobileSource,
|
currentMobileSource,
|
||||||
@@ -114,6 +138,7 @@ export function MobilePurchaseRequestsPage() {
|
|||||||
const queryStatus = searchParams.get("status");
|
const queryStatus = searchParams.get("status");
|
||||||
return isPurchaseStatus(queryStatus) ? queryStatus : "draft";
|
return isPurchaseStatus(queryStatus) ? queryStatus : "draft";
|
||||||
});
|
});
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
|
||||||
const currentMobileSource = useMemo(() => {
|
const currentMobileSource = useMemo(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@@ -138,9 +163,13 @@ export function MobilePurchaseRequestsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const items = listQuery.data?.items ?? [];
|
const items = listQuery.data?.items ?? [];
|
||||||
|
const visibleItems = useMemo(
|
||||||
|
() => items.filter((item) => matchesPurchaseSearch(item, searchInput)),
|
||||||
|
[items, searchInput],
|
||||||
|
);
|
||||||
const totalAmount = useMemo(
|
const totalAmount = useMemo(
|
||||||
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
() => visibleItems.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
||||||
[items],
|
[visibleItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -149,11 +178,11 @@ export function MobilePurchaseRequestsPage() {
|
|||||||
|
|
||||||
<section className="mobile-card">
|
<section className="mobile-card">
|
||||||
<div className="text-sm font-semibold text-zinc-900">当前采购任务</div>
|
<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 className="mobile-info-grid">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-zinc-500">当前列表</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>
|
<div>
|
||||||
<div className="text-xs text-zinc-500">当前金额</div>
|
<div className="text-xs text-zinc-500">当前金额</div>
|
||||||
@@ -163,6 +192,14 @@ export function MobilePurchaseRequestsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mobile-card" data-testid="mobile-purchase-requests-list">
|
<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}>
|
<Tabs value={status} onValueChange={handleStatusChange}>
|
||||||
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 min-[390px]:grid-cols-5">
|
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 min-[390px]:grid-cols-5">
|
||||||
{STATUS_TABS.map((item) => (
|
{STATUS_TABS.map((item) => (
|
||||||
@@ -180,10 +217,12 @@ export function MobilePurchaseRequestsPage() {
|
|||||||
{resolveErrorMessage(listQuery.error)}
|
{resolveErrorMessage(listQuery.error)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
|
{!listQuery.isLoading && !listQuery.isError && !visibleItems.length ? (
|
||||||
<div className="mobile-empty-state">{resolveMobileEmptyState(status)}</div>
|
<div className="mobile-empty-state">
|
||||||
|
{searchInput.trim() ? "没有符合搜索的采购记录" : resolveMobileEmptyState(status)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{items.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
|
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PageHeader } from "../components/common/PageHeader";
|
|||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
|
||||||
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
|
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||||||
{ value: "draft", label: "草稿" },
|
{ value: "draft", label: "草稿" },
|
||||||
@@ -63,6 +64,28 @@ function reimbursementListNextStep(status: ReimbursementStatus) {
|
|||||||
return "查看当前报销状态和可执行操作。";
|
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({
|
function ReimbursementCard({
|
||||||
item,
|
item,
|
||||||
canDeleteDraft,
|
canDeleteDraft,
|
||||||
@@ -132,6 +155,7 @@ export function MobileReimbursementsPage() {
|
|||||||
const queryStatus = searchParams.get("status");
|
const queryStatus = searchParams.get("status");
|
||||||
return isReimbursementStatus(queryStatus) ? queryStatus : "draft";
|
return isReimbursementStatus(queryStatus) ? queryStatus : "draft";
|
||||||
});
|
});
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
||||||
const createIntentHandledRef = useRef(false);
|
const createIntentHandledRef = useRef(false);
|
||||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||||
@@ -207,17 +231,21 @@ export function MobileReimbursementsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const items = listQuery.data?.items ?? [];
|
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(
|
const totalAmount = useMemo(
|
||||||
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
() => visibleItems.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
||||||
[items],
|
[visibleItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="移动报销"
|
title="移动报销"
|
||||||
subtitle="手机端只处理新建、补材料、查状态和草稿删除。"
|
subtitle="新建、补材料、查状态。"
|
||||||
meta={<span className="badge-neutral">{STATUS_LABEL[status]}</span>}
|
meta={<span className="badge-neutral">{STATUS_LABEL[status]}</span>}
|
||||||
actions={
|
actions={
|
||||||
canCreateExpense ? (
|
canCreateExpense ? (
|
||||||
@@ -265,15 +293,21 @@ export function MobileReimbursementsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-command-surface">
|
<div className="mobile-command-surface">
|
||||||
<Tabs value={status} onValueChange={handleStatusChange}>
|
<Input
|
||||||
<TabsList className="grid h-auto w-full grid-cols-4">
|
aria-label="搜索报销"
|
||||||
{STATUS_TABS.map((item) => (
|
value={searchInput}
|
||||||
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-reimbursements-tab-${item.value}`}>
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
{item.label}
|
placeholder="搜索编号、说明、金额"
|
||||||
</TabsTrigger>
|
/>
|
||||||
))}
|
<Tabs value={status} onValueChange={handleStatusChange}>
|
||||||
</TabsList>
|
<TabsList className="grid h-auto w-full grid-cols-4">
|
||||||
</Tabs>
|
{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>
|
||||||
|
|
||||||
<div className="mobile-page-stack">
|
<div className="mobile-page-stack">
|
||||||
@@ -283,10 +317,10 @@ export function MobileReimbursementsPage() {
|
|||||||
{resolveErrorMessage(listQuery.error)}
|
{resolveErrorMessage(listQuery.error)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
|
{!listQuery.isLoading && !listQuery.isError && !visibleItems.length ? (
|
||||||
<div className="mobile-empty-state">暂无报销记录</div>
|
<div className="mobile-empty-state">{searchInput.trim() ? "没有符合搜索的报销记录" : "暂无报销记录"}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{items.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<ReimbursementCard
|
<ReimbursementCard
|
||||||
canDeleteDraft={canDeleteExpense && item.status === "draft"}
|
canDeleteDraft={canDeleteExpense && item.status === "draft"}
|
||||||
currentMobileSource={currentMobileSource}
|
currentMobileSource={currentMobileSource}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const MOBILE_PAGES = [
|
|||||||
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
|
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
|
||||||
{ key: "notifications", path: "/mobile/notifications", marker: "mobile-notifications-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: "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 },
|
{ key: "asset-scan", path: "/mobile/assets/scan", marker: "barcode-scanner-panel", deep: false },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -626,6 +626,13 @@ test.describe("MUD6 专用移动页面统一设计", () => {
|
|||||||
for (const mobilePage of MOBILE_PAGES) {
|
for (const mobilePage of MOBILE_PAGES) {
|
||||||
await page.goto(mobilePage.path);
|
await page.goto(mobilePage.path);
|
||||||
await expect(page.getByTestId(mobilePage.marker)).toBeVisible();
|
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}`, {
|
await expectUnifiedMobilePage(page, `${device.label} ${mobilePage.key}`, `${device.key}-${mobilePage.key}`, {
|
||||||
deep: mobilePage.deep,
|
deep: mobilePage.deep,
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户