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