feat(P25-2/P25-3/P26-2/P28-1/P24-2/P29-3): 落地内部OA够用版前端入口与运营页面

关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
这个提交包含在:
2026-03-21 18:03:48 +08:00
父节点 e39e41287e
当前提交 9905c1c79d
共修改 44 个文件,包含 2029 行新增和 169 行删除
+115
查看文件
@@ -0,0 +1,115 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createCutoverScope, dryRunMigration, fetchCutoverScopes, fetchMigrationRuns, updateCutoverScope } from "../api/cutover";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { toast } from "sonner";
export function CutoverConsolePage() {
const queryClient = useQueryClient();
const [scopeType, setScopeType] = useState<"department" | "legal_entity" | "domain">("department");
const [scopeKey, setScopeKey] = useState("");
const [notes, setNotes] = useState("");
const [dryRunType, setDryRunType] = useState<"employees" | "departments" | "budgets" | "masterdata_validation">("employees");
const [dryRunFile, setDryRunFile] = useState<File | null>(null);
const scopesQuery = useQuery({ queryKey: ["cutover-scopes"], queryFn: () => fetchCutoverScopes() });
const runsQuery = useQuery({ queryKey: ["migration-runs"], queryFn: () => fetchMigrationRuns() });
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["cutover-scopes"] });
await queryClient.invalidateQueries({ queryKey: ["migration-runs"] });
};
const createScopeMutation = useMutation({
mutationFn: () => createCutoverScope({ scopeType, scopeKey, notes, enabled: false }),
onSuccess: async () => {
setScopeKey("");
setNotes("");
await refresh();
toast.success("切换范围已创建");
},
});
const toggleScopeMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateCutoverScope(id, { enabled }),
onSuccess: async () => {
await refresh();
toast.success("切换范围已更新");
},
});
const dryRunMutation = useMutation({
mutationFn: async () => {
if (!dryRunFile) throw new Error("请先选择 CSV 文件");
return dryRunMigration({ runType: dryRunType, file: dryRunFile });
},
onSuccess: async () => {
setDryRunFile(null);
await refresh();
toast.success("dry-run 已完成");
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "dry-run 失败");
},
});
return (
<div className="space-y-6">
<PageHeader title="切换控制台" subtitle="试点范围、dry-run 差异和回退前置准备。" />
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<div className="space-y-6">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新增切换范围</h3>
<div className="space-y-2"><Label>范围类型</Label><select className="select-std" value={scopeType} onChange={(event) => setScopeType(event.target.value as "department" | "legal_entity" | "domain")}><option value="department">department</option><option value="legal_entity">legal_entity</option><option value="domain">domain</option></select></div>
<div className="space-y-2"><Label>范围 Key</Label><Input value={scopeKey} onChange={(event) => setScopeKey(event.target.value)} placeholder="部门 ID / 法体 ID / expense" /></div>
<div className="space-y-2"><Label>备注</Label><Input value={notes} onChange={(event) => setNotes(event.target.value)} /></div>
<Button type="button" onClick={() => createScopeMutation.mutate()}>创建范围</Button>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">上传 dry-run CSV</h3>
<div className="space-y-2"><Label>runType</Label><select className="select-std" value={dryRunType} onChange={(event) => setDryRunType(event.target.value as typeof dryRunType)}><option value="employees">employees</option><option value="departments">departments</option><option value="budgets">budgets</option><option value="masterdata_validation">masterdata_validation</option></select></div>
<Input type="file" accept=".csv,text/csv" onChange={(event) => setDryRunFile(event.target.files?.[0] ?? null)} />
<Button type="button" onClick={() => dryRunMutation.mutate()} disabled={dryRunMutation.isPending}>{dryRunMutation.isPending ? "执行中..." : "执行 dry-run"}</Button>
</div>
</div>
<div className="space-y-6">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">切换范围</h3>
<div className="space-y-3">
{(scopesQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.scopeType} · {item.scopeKey}</div>
<div className="text-sm text-zinc-600">{item.notes ?? "-"}</div>
<div className="text-xs text-zinc-500">{item.enabled ? "enabled" : "disabled"} · startsAt={item.startsAt ?? "-"}</div>
</div>
<Button type="button" variant="secondary" onClick={() => toggleScopeMutation.mutate({ id: item.id, enabled: !item.enabled })}>{item.enabled ? "关闭" : "启用"}</Button>
</div>
</div>
))}
</div>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">最近 dry-run</h3>
<div className="space-y-3">
{(runsQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="font-medium text-zinc-900">{item.runType} · {item.status}</div>
<div className="text-sm text-zinc-600">{item.sourceFilename}</div>
<div className="mt-1 text-xs text-zinc-500">{item.createdAt ?? "-"}</div>
</div>
))}
{!runsQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无 dry-run 记录</div> : null}
</div>
</div>
</div>
</div>
</div>
);
}