636 行
22 KiB
TypeScript
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>
|
|
);
|
|
}
|