import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { createCutoverScope, dryRunMigration, fetchCutoverScopes, fetchMigrationRuns, updateCutoverScope, type CutoverScope, } from "../api/cutover"; import { PageHeader } from "../components/common/PageHeader"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; const SCOPE_TYPE_OPTIONS: Array<{ value: CutoverScope["scopeType"]; label: string }> = [ { value: "department", label: "department" }, { value: "legal_entity", label: "legal_entity" }, { value: "domain", label: "domain" }, ]; type ScopeView = "list" | "timeline"; type ScopeFormState = { scopeType: CutoverScope["scopeType"]; scopeKey: string; enabled: boolean; startsAt: string; notes: string; }; function resolveErrorMessage(error: unknown) { if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } function formatDateTime(value: string | null) { if (!value) return "-"; return value.slice(0, 19).replace("T", " "); } function formatRelativeTime(value: string | null) { if (!value) return "-"; const target = new Date(value).getTime(); if (!Number.isFinite(target)) return "-"; const diffSeconds = Math.round((target - Date.now()) / 1000); const abs = Math.abs(diffSeconds); if (abs < 60) { return diffSeconds >= 0 ? `${abs} 秒后` : `${abs} 秒前`; } const diffMinutes = Math.round(diffSeconds / 60); if (Math.abs(diffMinutes) < 60) { return diffMinutes >= 0 ? `${diffMinutes} 分钟后` : `${Math.abs(diffMinutes)} 分钟前`; } const diffHours = Math.round(diffMinutes / 60); if (Math.abs(diffHours) < 24) { return diffHours >= 0 ? `${diffHours} 小时后` : `${Math.abs(diffHours)} 小时前`; } const diffDays = Math.round(diffHours / 24); return diffDays >= 0 ? `${diffDays} 天后` : `${Math.abs(diffDays)} 天前`; } function toDateTimeLocalValue(value: string | null) { if (!value) return ""; const date = new Date(value); if (!Number.isFinite(date.getTime())) return ""; const pad = (input: number) => String(input).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function toIsoDateTime(value: string) { if (!value.trim()) return null; const parsed = new Date(value); if (!Number.isFinite(parsed.getTime())) return null; return parsed.toISOString(); } function buildDefaultScopeForm(): ScopeFormState { return { scopeType: "department", scopeKey: "", enabled: false, startsAt: "", notes: "", }; } function buildScopeForm(item: CutoverScope): ScopeFormState { return { scopeType: item.scopeType, scopeKey: item.scopeKey, enabled: item.enabled, startsAt: toDateTimeLocalValue(item.startsAt), notes: item.notes ?? "", }; } function getScopePhase(item: CutoverScope) { const startsAt = item.startsAt ? new Date(item.startsAt).getTime() : null; const now = Date.now(); if (!item.enabled) { return "disabled"; } if (startsAt && startsAt > now) { return "pending"; } return "active"; } function getScopePhaseLabel(item: CutoverScope) { const phase = getScopePhase(item); if (phase === "active") return "已启用"; if (phase === "pending") return "待生效"; return "已禁用"; } function getScopePhaseClassName(item: CutoverScope) { const phase = getScopePhase(item); if (phase === "active") { return "border-emerald-200 bg-emerald-50 text-emerald-700"; } if (phase === "pending") { return "border-amber-200 bg-amber-50 text-amber-700"; } return "border-zinc-200 bg-zinc-50 text-zinc-700"; } function getScopeCountdown(item: CutoverScope) { if (!item.startsAt) { return item.enabled ? "立即生效" : "-"; } if (!item.enabled) { return "未启用"; } const target = new Date(item.startsAt).getTime(); if (!Number.isFinite(target)) return "-"; if (target > Date.now()) { return `${formatRelativeTime(item.startsAt)}生效`; } return `已于 ${formatRelativeTime(item.startsAt)} 生效`; } export function CutoverConsolePage() { const queryClient = useQueryClient(); const [view, setView] = useState("list"); const [dryRunType, setDryRunType] = useState<"employees" | "departments" | "budgets" | "masterdata_validation">("employees"); const [dryRunFile, setDryRunFile] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [editingScope, setEditingScope] = useState(null); const [scopeForm, setScopeForm] = useState(buildDefaultScopeForm()); 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: scopeForm.scopeType, scopeKey: scopeForm.scopeKey.trim(), enabled: scopeForm.enabled, startsAt: toIsoDateTime(scopeForm.startsAt), notes: scopeForm.notes.trim() || null, }), onSuccess: async () => { setDialogOpen(false); setEditingScope(null); setScopeForm(buildDefaultScopeForm()); await refresh(); toast.success("切换范围已创建"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const editScopeMutation = useMutation({ mutationFn: () => { if (!editingScope) { throw new Error("未找到要编辑的切换范围"); } return updateCutoverScope(editingScope.id, { enabled: scopeForm.enabled, startsAt: toIsoDateTime(scopeForm.startsAt), notes: scopeForm.notes.trim() || null, }); }, onSuccess: async () => { setDialogOpen(false); setEditingScope(null); setScopeForm(buildDefaultScopeForm()); await refresh(); toast.success("切换范围已更新"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const toggleScopeMutation = useMutation({ mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateCutoverScope(id, { enabled }), onSuccess: async () => { await refresh(); toast.success("切换范围已更新"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); 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("演练已完成"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const scopes = scopesQuery.data?.items ?? []; const summary = { active: scopes.filter((item) => getScopePhase(item) === "active").length, disabled: scopes.filter((item) => getScopePhase(item) === "disabled").length, pending: scopes.filter((item) => getScopePhase(item) === "pending").length, }; const timelineItems = scopes .flatMap((item) => { const nodes = [ { id: `${item.id}-created`, at: item.createdAt, title: "创建范围", description: `${item.scopeType} · ${item.scopeKey}`, }, ]; if (item.startsAt) { nodes.push({ id: `${item.id}-startsAt`, at: item.startsAt, title: item.enabled ? "计划生效" : "预设开始时间", description: `${item.scopeType} · ${item.scopeKey}`, }); } if (item.updatedAt && item.updatedAt !== item.createdAt) { nodes.push({ id: `${item.id}-updated`, at: item.updatedAt, title: "最近更新", description: `${item.scopeType} · ${item.scopeKey}`, }); } return nodes; }) .filter((item) => item.at) .sort((left, right) => new Date(right.at ?? 0).getTime() - new Date(left.at ?? 0).getTime()); const openCreateDialog = () => { setEditingScope(null); setScopeForm(buildDefaultScopeForm()); setDialogOpen(true); }; const openEditDialog = (item: CutoverScope) => { setEditingScope(item); setScopeForm(buildScopeForm(item)); setDialogOpen(true); }; const submitScopeForm = () => { if (!editingScope && !scopeForm.scopeKey.trim()) { toast.error("请填写范围 Key"); return; } if (editingScope) { editScopeMutation.mutate(); return; } createScopeMutation.mutate(); }; const scopeDialogPending = createScopeMutation.isPending || editScopeMutation.isPending; return (
已启用
{summary.active}
已到生效时间
已禁用
{summary.disabled}
手动关闭
待生效
{summary.pending}
等待生效时间
新增范围 } > setView(value as ScopeView)}> 列表 时间线 {scopesQuery.isLoading ? : null} {scopesQuery.isError ? (

{resolveErrorMessage(scopesQuery.error)}

) : null} {!scopesQuery.isLoading && !scopesQuery.isError ? ( scopes.length ? (
类型范围标识状态生效时间倒计时备注操作 {scopes.map((item) => ( {item.scopeType} {item.scopeKey} {getScopePhaseLabel(item)} {formatDateTime(item.startsAt)} {getScopeCountdown(item)} {item.notes || "-"}
))}
) : (
暂无切换范围。
) ) : null}
{scopesQuery.isLoading ? : null} {scopesQuery.isError ? (

{resolveErrorMessage(scopesQuery.error)}

) : null} {!scopesQuery.isLoading && !scopesQuery.isError ? ( timelineItems.length ? (
{timelineItems.map((item) => (
{item.title}
{formatDateTime(item.at)} · {formatRelativeTime(item.at)}
{item.description}
))}
) : (
当前没有可展示的时间线节点。
) ) : null}
setDryRunFile(event.target.files?.[0] ?? null)} />
{runsQuery.isLoading ? : null} {runsQuery.isError ? (

{resolveErrorMessage(runsQuery.error)}

) : null} {!runsQuery.isLoading && !runsQuery.isError ? (
{(runsQuery.data?.items ?? []).map((item) => (
{item.runType} · {item.status}
{item.sourceFilename}
{formatDateTime(item.createdAt)}
))} {!runsQuery.data?.items?.length ? (
暂无演练记录
) : null}
) : null}
{ if (!scopeDialogPending) { setDialogOpen(open); if (!open) { setEditingScope(null); setScopeForm(buildDefaultScopeForm()); } } }} > {editingScope ? "编辑切换范围" : "新增切换范围"} 创建时可设置 startsAt;编辑时仅允许调整 enabled、startsAt 和 notes。
setScopeForm((prev) => ({ ...prev, scopeKey: event.target.value, })) } placeholder="部门 ID / 法体 ID / 业务域" />
setScopeForm((prev) => ({ ...prev, startsAt: event.target.value, })) } />