feat(08-05-interaction-pattern-unification): 统一前端交互与反馈模式
这个提交包含在:
@@ -1,8 +1,8 @@
|
||||
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
||||
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
|
||||
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
|
||||
@@ -25,8 +25,10 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Spinner } from "../components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
@@ -171,13 +173,7 @@ function publicReference(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "动作执行失败";
|
||||
return resolveUserErrorMessage(error, "动作执行失败");
|
||||
}
|
||||
|
||||
function isAlreadyProcessedTaskError(error: unknown) {
|
||||
@@ -643,10 +639,10 @@ export function MobileApprovalsPage() {
|
||||
throw new Error("请选择任务");
|
||||
}
|
||||
if (!selectedTask) {
|
||||
throw new Error("当前待办尚未缓存,请联网加载后再操作");
|
||||
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
|
||||
}
|
||||
if (!selectedTask.allowedActions.includes(action)) {
|
||||
throw new Error("当前待办不允许该动作");
|
||||
throw new Error("当前审批任务不允许该动作");
|
||||
}
|
||||
if (action === "reject" && !comment.trim()) {
|
||||
throw new Error("驳回必须填写意见");
|
||||
@@ -685,10 +681,10 @@ export function MobileApprovalsPage() {
|
||||
throw new Error("请选择任务");
|
||||
}
|
||||
if (!selectedTask) {
|
||||
throw new Error("当前待办尚未缓存,请联网加载后再操作");
|
||||
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
|
||||
}
|
||||
if (!selectedTask.allowedActions.includes(action)) {
|
||||
throw new Error("当前待办不允许该动作");
|
||||
throw new Error("当前审批任务不允许该动作");
|
||||
}
|
||||
|
||||
if (action === "approve") {
|
||||
@@ -744,6 +740,11 @@ export function MobileApprovalsPage() {
|
||||
});
|
||||
|
||||
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
|
||||
const transferDisabledReason = !transferToApproverId
|
||||
? "请先选择目标审批人。"
|
||||
: transferReason.trim().length < 10
|
||||
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
|
||||
: "转审理由已满足要求。";
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: async (items: WorkflowDraftQueueItem[]) => {
|
||||
@@ -831,7 +832,7 @@ export function MobileApprovalsPage() {
|
||||
}
|
||||
}, [canUseRemote, retryableQueueItems, syncMutation]);
|
||||
|
||||
const listTitle = taskTab === "pending" ? "待我处理" : "已处理";
|
||||
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
|
||||
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
||||
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
|
||||
const approvalFocusText = taskTab === "pending"
|
||||
@@ -852,9 +853,7 @@ export function MobileApprovalsPage() {
|
||||
<PageScaffold
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
showMobileApprovalActions
|
||||
? "mobile-fixed-action-page pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
|
||||
: "",
|
||||
showMobileApprovalActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "",
|
||||
)}
|
||||
>
|
||||
{isPushDetail ? (
|
||||
@@ -871,14 +870,14 @@ export function MobileApprovalsPage() {
|
||||
/>
|
||||
) : (
|
||||
<PageHeader
|
||||
title={canAccessRemoteApprovals ? "待我处理" : "进度"}
|
||||
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
|
||||
title={canAccessRemoteApprovals ? "待我审批" : "进度"}
|
||||
subtitle={canAccessRemoteApprovals ? "处理待审批任务,查看已处理记录。" : "查看我发起的申请进度。"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOnline && canAccessRemoteApprovals ? (
|
||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -985,7 +984,7 @@ export function MobileApprovalsPage() {
|
||||
<div className="mobile-command-surface">
|
||||
<Tabs value={taskTab} onValueChange={handleTaskTabChange}>
|
||||
<TabsList className="flex w-full justify-start gap-1">
|
||||
<TabsTrigger value="pending">待我处理</TabsTrigger>
|
||||
<TabsTrigger value="pending">待我审批</TabsTrigger>
|
||||
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -1041,7 +1040,7 @@ export function MobileApprovalsPage() {
|
||||
{taskTab === "completed"
|
||||
? "最近 30 天暂无符合筛选条件的已处理任务。"
|
||||
: !canAccessRemoteApprovals && isOnline
|
||||
? "暂无待我处理;收到企业微信审批消息后可从这里查看。"
|
||||
? "暂无待我审批;收到企业微信审批消息后可从这里查看。"
|
||||
: canUseRemote
|
||||
? "暂无符合筛选条件的审批任务。"
|
||||
: "暂无符合筛选条件的已缓存审批。"}
|
||||
@@ -1102,7 +1101,7 @@ export function MobileApprovalsPage() {
|
||||
|
||||
{isOfflineUncachedDeepLink ? (
|
||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
||||
离线未缓存该待办,请联网后重试。
|
||||
离线未缓存该审批任务,请联网后重试。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1223,7 +1222,14 @@ export function MobileApprovalsPage() {
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="mobile-workflow-action-consequence-help">
|
||||
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
|
||||
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要重新发起。</div>
|
||||
</div>
|
||||
|
||||
<Label className="sr-only" htmlFor="mobile-approvals-comment">审批意见</Label>
|
||||
<Textarea
|
||||
id="mobile-approvals-comment"
|
||||
data-testid="mobile-approvals-comment"
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="意见(驳回/退回必填)"
|
||||
@@ -1235,11 +1241,10 @@ export function MobileApprovalsPage() {
|
||||
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
|
||||
data-testid="mobile-approvals-sticky-actions"
|
||||
<MobileStickyActionBar
|
||||
contentClassName="mobile-approval-action-grid"
|
||||
testId="mobile-approvals-sticky-actions"
|
||||
>
|
||||
<div className="mobile-sticky-action-grid mobile-approval-action-grid">
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
@@ -1269,13 +1274,14 @@ export function MobileApprovalsPage() {
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</MobileStickyActionBar>
|
||||
|
||||
{canTransfer ? (
|
||||
<div className="mobile-list-card space-y-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<Label htmlFor="mobile-approvals-transfer-to">目标审批人</Label>
|
||||
<select
|
||||
id="mobile-approvals-transfer-to"
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
@@ -1289,18 +1295,34 @@ export function MobileApprovalsPage() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Label htmlFor="mobile-approvals-transfer-reason">转审理由</Label>
|
||||
<Textarea
|
||||
id="mobile-approvals-transfer-reason"
|
||||
aria-describedby="mobile-approvals-transfer-help"
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<p
|
||||
id="mobile-approvals-transfer-help"
|
||||
className={transferReason.trim().length >= 10 && transferToApproverId ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
|
||||
data-testid="mobile-approvals-transfer-disabled-reason"
|
||||
>
|
||||
{transferDisabledReason}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
aria-describedby="mobile-approvals-transfer-help"
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
||||
disabled={
|
||||
isActionSubmitting ||
|
||||
!selectedTask.allowedActions.includes("transfer") ||
|
||||
!transferToApproverId ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
||||
在新工单中引用
屏蔽一个用户