refactor(ui): [UI7-1] 为其余配置与边缘页面应用新版空壳布局

这个提交包含在:
2026-03-06 09:48:11 +08:00
父节点 c632648e16
当前提交 62fd2a5398
共修改 12 个文件,包含 229 行新增和 234 行删除
+25 -20
查看文件
@@ -16,6 +16,9 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [
{ code: "manager", label: "manager" },
@@ -45,6 +48,7 @@ export function ApprovalRulesPage() {
const [editOpen, setEditOpen] = useState(false);
const [editRule, setEditRule] = useState<ApprovalRule | null>(null);
const [confirmToggleRule, setConfirmToggleRule] = useState<ApprovalRule | null>(null);
const listQuery = useQuery({
queryKey: ["approval-rules"],
@@ -79,13 +83,12 @@ export function ApprovalRulesPage() {
setLevel3Role("");
setEnabled(true);
setError("");
toast.success("规则已创建");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "创建审批规则失败");
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建审批规则失败";
setError(msg);
toast.error(msg);
},
});
@@ -94,13 +97,12 @@ export function ApprovalRulesPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["approval-rules"] });
setError("");
toast.success("状态已更新");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "更新审批规则失败");
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
setError(msg);
toast.error(msg);
},
});
@@ -117,13 +119,12 @@ export function ApprovalRulesPage() {
setEditOpen(false);
setEditRule(null);
setError("");
toast.success("规则已更新");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "更新审批规则失败");
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新审批规则失败";
setError(msg);
toast.error(msg);
},
});
@@ -283,7 +284,7 @@ export function ApprovalRulesPage() {
<Card className="space-y-4 p-6">
<h3 className="text-base font-semibold text-slate-900">规则列表</h3>
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -348,10 +349,7 @@ export function ApprovalRulesPage() {
variant="secondary"
type="button"
disabled={toggleEnabledMutation.isPending}
onClick={() => {
if (!window.confirm(`确认${rule.enabled ? "停用" : "启用"}该审批规则?`)) return;
toggleEnabledMutation.mutate(rule);
}}
onClick={() => setConfirmToggleRule(rule)}
>
{rule.enabled ? "停用" : "启用"}
</Button>
@@ -372,6 +370,13 @@ export function ApprovalRulesPage() {
) : null}
</Card>
<ConfirmDialog
open={confirmToggleRule !== null}
onOpenChange={(open) => { if (!open) setConfirmToggleRule(null); }}
title={`确认${confirmToggleRule?.enabled ? "停用" : "启用"}该审批规则?`}
onConfirm={() => { if (confirmToggleRule) toggleEnabledMutation.mutate(confirmToggleRule); }}
/>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
+3 -2
查看文件
@@ -6,6 +6,7 @@ import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { downloadBlob } from "../lib/download";
import { TableSkeleton } from "../components/common/TableSkeleton";
const DEFAULT_PAGE_SIZE = 20;
@@ -190,8 +191,8 @@ export function AuditLogsPage() {
<tbody>
{loading ? (
<tr>
<td className="px-3 py-3 text-slate-500" colSpan={5}>
加载中...
<td colSpan={5} className="p-4">
<TableSkeleton columns={5} rows={5} />
</td>
</tr>
) : null}
+83 -81
查看文件
@@ -10,6 +10,11 @@ import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { StatusBadge } from "../components/common/StatusBadge";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { Pagination } from "../components/common/Pagination";
const PAGE_SIZE = 20;
@@ -42,6 +47,7 @@ export function BudgetsPage() {
const [warningThresholdPercent, setWarningThresholdPercent] = useState("80");
const [formDepartmentId, setFormDepartmentId] = useState("");
const [error, setError] = useState("");
const [confirmArchiveId, setConfirmArchiveId] = useState<{ id: string; code: string } | null>(null);
const queryInput = useMemo<BudgetListQuery>(
() => ({
@@ -87,9 +93,11 @@ export function BudgetsPage() {
setWarningThresholdPercent("80");
setFormDepartmentId("");
setError("");
toast.success("预算池已创建");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
toast.error(resolveError(mutationError));
},
});
@@ -98,9 +106,11 @@ export function BudgetsPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["budgets"] });
setError("");
toast.success("已归档");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
toast.error(resolveError(mutationError));
},
});
@@ -246,89 +256,81 @@ export function BudgetsPage() {
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">预算池列表</h3>
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>编码</TableHead>
<TableHead>名称</TableHead>
<TableHead>状态</TableHead>
<TableHead>期间</TableHead>
<TableHead>额度</TableHead>
<TableHead>预警阈值</TableHead>
<TableHead className="w-28">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(budgetsQuery.data?.items ?? []).length ? (
(budgetsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-slate-900">{item.code}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")}
</TableCell>
<TableCell>
{item.usedAmount} / {item.allocatedAmount}
<div className="text-xs text-slate-500">剩余 {item.remainingAmount}</div>
</TableCell>
<TableCell>{item.warningThresholdPercent}%</TableCell>
<TableCell>
{canManageBudget && item.status !== "archived" ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={archiveMutation.isPending}
onClick={() => {
if (!window.confirm(`确认归档预算池 ${item.code} ?`)) return;
archiveMutation.mutate(item.id);
}}
>
归档
</Button>
) : (
<span className="text-xs text-slate-400">-</span>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-slate-500" colSpan={7}>
暂无预算池
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{budgetsQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button
variant="secondary"
type="button"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
上一页
</Button>
<Button
variant="secondary"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
下一页
</Button>
</div>
</div>
{!budgetsQuery.isLoading ? (
<>
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>编码</TableHead>
<TableHead>名称</TableHead>
<TableHead>状态</TableHead>
<TableHead>期间</TableHead>
<TableHead>额度</TableHead>
<TableHead>预警阈值</TableHead>
<TableHead className="w-28">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(budgetsQuery.data?.items ?? []).length ? (
(budgetsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-slate-900">{item.code}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")}
</TableCell>
<TableCell>
{item.usedAmount} / {item.allocatedAmount}
<div className="text-xs text-slate-500">剩余 {item.remainingAmount}</div>
</TableCell>
<TableCell>{item.warningThresholdPercent}%</TableCell>
<TableCell>
{canManageBudget && item.status !== "archived" ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={archiveMutation.isPending}
onClick={() => setConfirmArchiveId({ id: item.id, code: item.code })}
>
归档
</Button>
) : (
<span className="text-xs text-slate-400">-</span>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-slate-500" colSpan={7}>
暂无预算池
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</>
) : null}
</Card>
<ConfirmDialog
open={Boolean(confirmArchiveId)}
title="确认归档"
description={`确认归档预算池 ${confirmArchiveId?.code ?? ""} ?`}
variant="destructive"
onConfirm={async () => {
if (confirmArchiveId) archiveMutation.mutate(confirmArchiveId.id);
}}
onOpenChange={(open) => { if (!open) setConfirmArchiveId(null); }}
/>
</div>
);
}
+5
查看文件
@@ -11,6 +11,7 @@ import {
} from "../api/dashboard";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { toast } from "sonner";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
@@ -104,6 +105,10 @@ export function DashboardPage() {
}),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `dashboard-reimbursements.${format}`);
toast.success("导出完成");
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "导出失败");
},
});
+14 -13
查看文件
@@ -1,10 +1,13 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { createDelegation, fetchMyDelegations, revokeDelegation } from "../api/delegations";
import { fetchWorkflowApprovers } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Input } from "../components/ui/input";
@@ -46,13 +49,12 @@ export function DelegationsPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["delegations"] });
setError("");
toast.success("委派已创建");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "创建委派失败");
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建委派失败";
setError(msg);
toast.error(msg);
},
});
@@ -61,13 +63,12 @@ export function DelegationsPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["delegations"] });
setError("");
toast.success("已撤销");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "撤销委派失败");
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "撤销委派失败";
setError(msg);
toast.error(msg);
},
});
@@ -158,7 +159,7 @@ export function DelegationsPage() {
</select>
</div>
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{listQuery.isLoading ? <TableSkeleton columns={5} rows={3} /> : null}
{listQuery.isError ? (
<p className="text-sm text-red-600">
{isApiError(listQuery.error)
@@ -193,7 +194,7 @@ export function DelegationsPage() {
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell className="font-mono text-xs">{item.delegateEmployeeId}</TableCell>
<TableCell>{item.scope}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>
<Button
data-testid="delegations-revoke"
@@ -239,7 +240,7 @@ export function DelegationsPage() {
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell className="font-mono text-xs">{item.principalEmployeeId}</TableCell>
<TableCell>{item.scope}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
</TableRow>
))
) : (
+29 -17
查看文件
@@ -15,6 +15,9 @@ import { Card } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
export function DepartmentsPage() {
const queryClient = useQueryClient();
@@ -26,6 +29,7 @@ export function DepartmentsPage() {
const [name, setName] = useState("");
const [parentId, setParentId] = useState<string>("");
const [error, setError] = useState("");
const [confirmArchive, setConfirmArchive] = useState<{ id: string; name: string } | null>(null);
const listQuery = useQuery({
queryKey: ["departments", includeArchived],
@@ -53,13 +57,14 @@ export function DepartmentsPage() {
setName("");
setParentId("");
setError("");
toast.success("部门已创建");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "创建部门失败");
const msg = isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : "创建部门失败";
setError(msg);
toast.error(msg);
},
});
@@ -68,13 +73,14 @@ export function DepartmentsPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["departments"] });
setError("");
toast.success("已归档");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "归档部门失败");
const msg = isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : "归档部门失败";
setError(msg);
toast.error(msg);
},
});
@@ -161,7 +167,7 @@ export function DepartmentsPage() {
<Card className="space-y-4 p-6">
<h3 className="text-base font-semibold text-slate-900">部门列表</h3>
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{listQuery.isLoading ? <TableSkeleton columns={4} rows={4} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -208,12 +214,7 @@ export function DepartmentsPage() {
variant="secondary"
type="button"
disabled={archiveMutation.isPending}
onClick={() => {
if (!window.confirm(`确认归档部门「${item.name}」?归档后不可用于新员工。`)) {
return;
}
archiveMutation.mutate(item.id);
}}
onClick={() => setConfirmArchive({ id: item.id, name: item.name })}
>
归档
</Button>
@@ -236,6 +237,17 @@ export function DepartmentsPage() {
</div>
) : null}
</Card>
<ConfirmDialog
open={Boolean(confirmArchive)}
title="确认归档"
description={`确认归档部门「${confirmArchive?.name ?? ""}」?归档后不可用于新员工。`}
variant="destructive"
onConfirm={async () => {
if (confirmArchive) archiveMutation.mutate(confirmArchive.id);
}}
onOpenChange={(open) => { if (!open) setConfirmArchive(null); }}
/>
</div>
);
}
+7 -6
查看文件
@@ -8,9 +8,11 @@ import {
type FeatureFlagOverrideMode,
type FeatureFlagRolloutType,
} from "../api/featureFlags";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Label } from "../components/ui/label";
@@ -89,13 +91,12 @@ export function FeatureFlagsPage() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
setError("");
toast.success("开关已更新");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "更新开关失败");
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新开关失败";
setError(msg);
toast.error(msg);
},
});
@@ -141,7 +142,7 @@ export function FeatureFlagsPage() {
<Card className="space-y-4 p-6">
<h3 className="text-base font-semibold text-slate-900">开关列表</h3>
{flagsQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{flagsQuery.isLoading ? <TableSkeleton columns={6} rows={2} /> : null}
{flagsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
+7 -7
查看文件
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
@@ -13,6 +14,7 @@ import {
} from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Spinner } from "../components/ui/spinner";
import { Card } from "../components/ui/card";
import { Textarea } from "../components/ui/textarea";
@@ -85,14 +87,12 @@ export function MobileApprovalsPage() {
setTransferReason("");
setTransferToApproverId("");
setError("");
toast.success("操作成功");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "动作执行失败");
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "动作执行失败";
setError(msg);
toast.error(msg);
},
});
@@ -110,7 +110,7 @@ export function MobileApprovalsPage() {
<Card className="space-y-3 p-4" data-testid="mobile-approvals-list">
<h3 className="text-base font-semibold text-slate-900">待办任务</h3>
{tasksQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{tasksQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-slate-500"><Spinner size="sm" />加载中...</div> : null}
{!tasksQuery.isLoading && pendingTasks.length === 0 ? (
<p className="text-sm text-slate-500">暂无待办任务。</p>
+16 -6
查看文件
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { uploadInvoice } from "../api/invoicePool";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Spinner } from "../components/ui/spinner";
import { Card } from "../components/ui/card";
import {
enqueueInvoiceUpload,
@@ -47,15 +49,20 @@ export function MobileCapturePage() {
onSuccess: () => {
setSelectedFile(null);
setError("");
setMessage("上传成功");
setMessage("");
toast.success("上传成功");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
const msg = `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`;
setError(msg);
toast.error(msg);
return;
}
setError(err instanceof Error ? err.message : "上传失败");
const msg = err instanceof Error ? err.message : "上传失败";
setError(msg);
toast.error(msg);
},
});
@@ -80,10 +87,13 @@ export function MobileCapturePage() {
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
setError("");
setMessage(`队列同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
setMessage("");
toast.success(`同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
},
onError: (err) => {
setError(err instanceof Error ? err.message : "队列同步失败");
const msg = err instanceof Error ? err.message : "队列同步失败";
setError(msg);
toast.error(msg);
},
});
@@ -210,7 +220,7 @@ export function MobileCapturePage() {
<span className="text-sm text-slate-500">{queuedCountLabel}</span>
</div>
{queueQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{queueQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-slate-500"><Spinner size="sm" />加载中...</div> : null}
{!queueQuery.isLoading && queuedItems.length === 0 ? (
<p className="text-sm text-slate-500">暂无待上传文件。</p>
+9 -33
查看文件
@@ -4,6 +4,8 @@ import { fetchNotifications, markNotificationRead } from "../api/notifications";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Pagination } from "../components/common/Pagination";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import {
@@ -105,7 +107,7 @@ export function NotificationsPage() {
</div>
</div>
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{listQuery.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -185,38 +187,12 @@ export function NotificationsPage() {
</div>
) : null}
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button
data-testid="notifications-prev-page"
variant="secondary"
disabled={page <= 1}
onClick={() =>
setSearchParams({ unreadOnly: unreadOnly ? "1" : "0", page: String(Math.max(1, page - 1)) })
}
type="button"
>
上一页
</Button>
<Button
data-testid="notifications-next-page"
variant="secondary"
disabled={page >= totalPages}
onClick={() =>
setSearchParams({
unreadOnly: unreadOnly ? "1" : "0",
page: String(Math.min(totalPages, page + 1)),
})
}
type="button"
>
下一页
</Button>
</div>
</div>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => setSearchParams({ unreadOnly: unreadOnly ? "1" : "0", page: String(p) })}
testIdPrefix="notifications"
/>
</Card>
</div>
);
+16 -16
查看文件
@@ -11,9 +11,12 @@ import {
returnWorkflowTask,
transferWorkflowTask,
} from "../api/workflow";
import { toast } from "sonner";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Textarea } from "../components/ui/textarea";
@@ -70,27 +73,26 @@ export function WorkflowTaskDetailPage() {
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
},
onSuccess: async () => {
onSuccess: async (_data, action) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
setActionError("");
const messages: Record<string, string> = { approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" };
toast.success(messages[action] ?? "操作成功");
},
onError: (error) => {
let msg: string;
if (error instanceof Error && !isApiError(error)) {
setActionError(error.message);
return;
msg = error.message;
} else if (isApiError(error)) {
msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
} else {
msg = "动作执行失败,请稍后重试";
}
if (isApiError(error)) {
setActionError(
`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`,
);
return;
}
setActionError("动作执行失败,请稍后重试");
setActionError(msg);
toast.error(msg);
},
});
@@ -104,9 +106,7 @@ export function WorkflowTaskDetailPage() {
if (detailQuery.isLoading) {
return (
<Card className="p-6">
<p className="text-sm text-slate-500">加载任务详情中...</p>
</Card>
<CardSkeleton lines={8} />
);
}
@@ -148,7 +148,7 @@ export function WorkflowTaskDetailPage() {
/>
<Card className="space-y-2 p-6 text-sm" data-testid="workflow-task-summary">
<div>状态:{detail.task.status}</div>
<div>状态:<StatusBadge status={detail.task.status} /></div>
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
<div>报销单号:{detail.reimbursement?.formNumber ?? "-"}</div>
<div>报销金额:{detail.reimbursement?.totalAmount ?? "-"}</div>
+15 -33
查看文件
@@ -4,6 +4,9 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchWorkflowTasks } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -60,16 +63,16 @@ export function WorkflowTasksPage() {
</TabsList>
</Tabs>
{query.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{query.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
{query.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(query.error)
? `${query.error.code}: ${mapApiErrorMessage(
query.error.code,
query.error.details,
query.error.message,
)}`
query.error.code,
query.error.details,
query.error.message,
)}`
: "加载待办失败,请稍后重试"}
</p>
) : null}
@@ -97,7 +100,7 @@ export function WorkflowTasksPage() {
<TableCell className="font-mono text-xs">
{task.workflowInstance?.entityId}
</TableCell>
<TableCell>{task.status}</TableCell>
<TableCell><StatusBadge status={task.status} /></TableCell>
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
<TableCell>
<Button
@@ -123,33 +126,12 @@ export function WorkflowTasksPage() {
</div>
) : null}
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button
data-testid="workflow-tasks-prev-page"
variant="secondary"
disabled={page <= 1}
onClick={() => setSearchParams({ status, page: String(Math.max(1, page - 1)) })}
type="button"
>
上一页
</Button>
<Button
data-testid="workflow-tasks-next-page"
variant="secondary"
disabled={page >= totalPages}
onClick={() =>
setSearchParams({ status, page: String(Math.min(totalPages, page + 1)) })
}
type="button"
>
下一页
</Button>
</div>
</div>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => setSearchParams({ status, page: String(p) })}
testIdPrefix="workflow-tasks"
/>
</Card>
</div>
);