文件
hyb-oa/client/src/pages/CutoverConsolePage.tsx
T

636 行
22 KiB
TypeScript

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<ScopeView>("list");
const [dryRunType, setDryRunType] = useState<"employees" | "departments" | "budgets" | "masterdata_validation">("employees");
const [dryRunFile, setDryRunFile] = useState<File | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingScope, setEditingScope] = useState<CutoverScope | null>(null);
const [scopeForm, setScopeForm] = useState<ScopeFormState>(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 (
<div className="space-y-6">
<PageHeader title="切换控制台" subtitle="范围开关和导入演练。" />
<div className="grid gap-4 md:grid-cols-3">
<div className="card-std p-5">
<div className="text-sm text-zinc-500">已启用</div>
<div className="mt-2 text-3xl font-semibold text-emerald-600">{summary.active}</div>
<div className="mt-1 text-xs text-zinc-500">已到生效时间</div>
</div>
<div className="card-std p-5">
<div className="text-sm text-zinc-500">已禁用</div>
<div className="mt-2 text-3xl font-semibold text-zinc-800">{summary.disabled}</div>
<div className="mt-1 text-xs text-zinc-500">手动关闭</div>
</div>
<div className="card-std p-5">
<div className="text-sm text-zinc-500">待生效</div>
<div className="mt-2 text-3xl font-semibold text-amber-600">{summary.pending}</div>
<div className="mt-1 text-xs text-zinc-500">等待生效时间</div>
</div>
</div>
<SectionPanel
title="切换范围"
description="当前配置和时间线。"
actions={
<Button type="button" onClick={openCreateDialog}>
新增范围
</Button>
}
>
<Tabs value={view} onValueChange={(value) => setView(value as ScopeView)}>
<TabsList>
<TabsTrigger value="list">列表</TabsTrigger>
<TabsTrigger value="timeline">时间线</TabsTrigger>
</TabsList>
<TabsContent value="list">
{scopesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{scopesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(scopesQuery.error)}
</p>
) : null}
{!scopesQuery.isLoading && !scopesQuery.isError ? (
scopes.length ? (
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>类型</TableHead>
<TableHead>范围标识</TableHead>
<TableHead>状态</TableHead>
<TableHead>生效时间</TableHead>
<TableHead>倒计时</TableHead>
<TableHead>备注</TableHead>
<TableHead className="w-40">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{scopes.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.scopeType}</TableCell>
<TableCell className="font-mono text-xs text-zinc-600">{item.scopeKey}</TableCell>
<TableCell>
<span
className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${getScopePhaseClassName(item)}`}
>
{getScopePhaseLabel(item)}
</span>
</TableCell>
<TableCell>{formatDateTime(item.startsAt)}</TableCell>
<TableCell className="text-sm text-zinc-600">{getScopeCountdown(item)}</TableCell>
<TableCell className="text-sm text-zinc-600">{item.notes || "-"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
variant="secondary"
disabled={toggleScopeMutation.isPending}
onClick={() => toggleScopeMutation.mutate({ id: item.id, enabled: !item.enabled })}
>
{item.enabled ? "关闭" : "启用"}
</Button>
<Button size="sm" type="button" variant="outline" onClick={() => openEditDialog(item)}>
编辑
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-sm text-zinc-500">
暂无切换范围。
</div>
)
) : null}
</TabsContent>
<TabsContent value="timeline">
{scopesQuery.isLoading ? <TableSkeleton columns={1} rows={4} /> : null}
{scopesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(scopesQuery.error)}
</p>
) : null}
{!scopesQuery.isLoading && !scopesQuery.isError ? (
timelineItems.length ? (
<div className="space-y-3">
{timelineItems.map((item) => (
<div key={item.id} className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">
{formatDateTime(item.at)} · {formatRelativeTime(item.at)}
</div>
</div>
<div className="mt-1 text-sm text-zinc-600">{item.description}</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-sm text-zinc-500">
当前没有可展示的时间线节点。
</div>
)
) : null}
</TabsContent>
</Tabs>
</SectionPanel>
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<SectionPanel title="上传演练 CSV" description="导入前先演练。">
<div className="space-y-4">
<div className="space-y-2">
<Label>演练类型</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"
disabled={dryRunMutation.isPending}
onClick={() => dryRunMutation.mutate()}
>
{dryRunMutation.isPending ? "执行中..." : "执行演练"}
</Button>
</div>
</SectionPanel>
<SectionPanel title="最近演练" description="核对上传文件和结果。">
{runsQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
{runsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(runsQuery.error)}
</p>
) : null}
{!runsQuery.isLoading && !runsQuery.isError ? (
<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">{formatDateTime(item.createdAt)}</div>
</div>
))}
{!runsQuery.data?.items?.length ? (
<div className="text-sm text-zinc-500">暂无演练记录</div>
) : null}
</div>
) : null}
</SectionPanel>
</div>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
if (!scopeDialogPending) {
setDialogOpen(open);
if (!open) {
setEditingScope(null);
setScopeForm(buildDefaultScopeForm());
}
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingScope ? "编辑切换范围" : "新增切换范围"}</DialogTitle>
<DialogDescription>
创建时可设置 startsAt;编辑时仅允许调整 enabled、startsAt 和 notes。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>范围类型</Label>
<select
className="select-std"
disabled={Boolean(editingScope)}
value={scopeForm.scopeType}
onChange={(event) =>
setScopeForm((prev) => ({
...prev,
scopeType: event.target.value as CutoverScope["scopeType"],
}))
}
>
{SCOPE_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>范围标识</Label>
<Input
disabled={Boolean(editingScope)}
value={scopeForm.scopeKey}
onChange={(event) =>
setScopeForm((prev) => ({
...prev,
scopeKey: event.target.value,
}))
}
placeholder="部门 ID / 法体 ID / 业务域"
/>
</div>
<div className="space-y-2">
<Label>生效时间</Label>
<Input
type="datetime-local"
value={scopeForm.startsAt}
onChange={(event) =>
setScopeForm((prev) => ({
...prev,
startsAt: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label>备注</Label>
<textarea
className="min-h-28 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
value={scopeForm.notes}
onChange={(event) =>
setScopeForm((prev) => ({
...prev,
notes: event.target.value,
}))
}
placeholder="说明本次切换范围用途"
/>
</div>
<label className="flex items-center gap-3 rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-700">
<input
checked={scopeForm.enabled}
className="h-4 w-4 rounded border border-zinc-300"
onChange={(event) =>
setScopeForm((prev) => ({
...prev,
enabled: event.target.checked,
}))
}
type="checkbox"
/>
启用该范围
</label>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={scopeDialogPending}
onClick={() => {
setDialogOpen(false);
setEditingScope(null);
setScopeForm(buildDefaultScopeForm());
}}
>
取消
</Button>
<Button type="button" disabled={scopeDialogPending} onClick={submitScopeForm}>
{scopeDialogPending ? "提交中..." : editingScope ? "保存修改" : "创建范围"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}