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
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户