R54 A1-A6:Cutover 控制台与特性开关增强
- Feature Flag 变更历史查看与影响范围预览 - Feature Flag 批量操作(启用/禁用/继承) - Cutover 控制台页面增强(摘要卡片、时间线、延迟生效) - 单测覆盖 changelog、batch、权限边界
这个提交包含在:
+103
-1
@@ -1,8 +1,32 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1" | "presence_board_v1" | "weekly_report_v1" | "employee_lifecycle_v1" | "todo_board_v1" | "project_domain_v1" | "project_linking_v1" | "project_communication_log_v1" | "project_closure_checklist_v1" | "confidential_doc_refs_v1" | "public_security_hardening_v1";
|
||||
export type FeatureFlagKey =
|
||||
| "ai_assist"
|
||||
| "erp_sync"
|
||||
| "invoice_verification"
|
||||
| "leave_holiday_calendar"
|
||||
| "budget_precheck_v1"
|
||||
| "payment_batch_v1"
|
||||
| "portal_home_v1"
|
||||
| "global_search_v1"
|
||||
| "announcements_v1"
|
||||
| "workflow_definition_admin_v1"
|
||||
| "cutover_console_v1"
|
||||
| "presence_board_v1"
|
||||
| "weekly_report_v1"
|
||||
| "employee_lifecycle_v1"
|
||||
| "todo_board_v1"
|
||||
| "wecom_entry_v1"
|
||||
| "project_domain_v1"
|
||||
| "project_linking_v1"
|
||||
| "project_communication_log_v1"
|
||||
| "project_closure_checklist_v1"
|
||||
| "confidential_doc_refs_v1"
|
||||
| "public_security_hardening_v1";
|
||||
|
||||
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
|
||||
export type FeatureFlagRolloutType = "global" | "department_allowlist";
|
||||
export type FeatureFlagSection = "main" | "admin" | "backup";
|
||||
|
||||
export type FeatureFlagItem = {
|
||||
id: string | null;
|
||||
@@ -23,10 +47,51 @@ export type FeatureFlagItem = {
|
||||
requiresDepartment: boolean;
|
||||
};
|
||||
|
||||
export type FeatureFlagSnapshot = {
|
||||
overrideMode: FeatureFlagOverrideMode;
|
||||
rolloutType: FeatureFlagRolloutType;
|
||||
departmentIds: string[];
|
||||
note: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type FeatureFlagChangelogItem = {
|
||||
id: string;
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
createdAt: string | null;
|
||||
from: FeatureFlagSnapshot | null;
|
||||
to: FeatureFlagSnapshot | null;
|
||||
};
|
||||
|
||||
export type FeatureFlagImpactNavigationItem = {
|
||||
label: string;
|
||||
path: string;
|
||||
section: FeatureFlagSection;
|
||||
};
|
||||
|
||||
export type FeatureFlagImpactBackendModule = {
|
||||
label: string;
|
||||
routes: string[];
|
||||
};
|
||||
|
||||
export type FeatureFlagListResponse = {
|
||||
items: FeatureFlagItem[];
|
||||
};
|
||||
|
||||
export type FeatureFlagChangelogResponse = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: FeatureFlagChangelogItem[];
|
||||
};
|
||||
|
||||
export type FeatureFlagImpactResponse = {
|
||||
flagKey: FeatureFlagKey;
|
||||
navigationItems: FeatureFlagImpactNavigationItem[];
|
||||
backendModules: FeatureFlagImpactBackendModule[];
|
||||
};
|
||||
|
||||
export type FeatureFlagUpdatePayload = {
|
||||
overrideMode: FeatureFlagOverrideMode;
|
||||
rolloutType: FeatureFlagRolloutType;
|
||||
@@ -35,13 +100,50 @@ export type FeatureFlagUpdatePayload = {
|
||||
expectedVersion: number;
|
||||
};
|
||||
|
||||
export type FeatureFlagBatchUpdatePayload = {
|
||||
items: Array<{
|
||||
flagKey: FeatureFlagKey;
|
||||
overrideMode: FeatureFlagOverrideMode;
|
||||
expectedVersion: number;
|
||||
}>;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export async function fetchFeatureFlags() {
|
||||
return apiRequestData<FeatureFlagListResponse>("/api/system/feature-flags");
|
||||
}
|
||||
|
||||
export async function fetchFeatureFlagChangelog(
|
||||
flagKey: FeatureFlagKey,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) {
|
||||
searchParams.set("page", String(params.page));
|
||||
}
|
||||
if (params?.pageSize) {
|
||||
searchParams.set("pageSize", String(params.pageSize));
|
||||
}
|
||||
const suffix = searchParams.toString() ? `?${searchParams.toString()}` : "";
|
||||
return apiRequestData<FeatureFlagChangelogResponse>(
|
||||
`/api/system/feature-flags/${flagKey}/changelog${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchFeatureFlagImpact(flagKey: FeatureFlagKey) {
|
||||
return apiRequestData<FeatureFlagImpactResponse>(`/api/system/feature-flags/${flagKey}/impact`);
|
||||
}
|
||||
|
||||
export async function updateFeatureFlag(flagKey: FeatureFlagKey, payload: FeatureFlagUpdatePayload) {
|
||||
return apiRequestData<{ item: FeatureFlagItem }>(`/api/system/feature-flags/${flagKey}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchUpdateFeatureFlags(payload: FeatureFlagBatchUpdatePayload) {
|
||||
return apiRequestData<{ count: number }>("/api/system/feature-flags/batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,43 +1,237 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createCutoverScope, dryRunMigration, fetchCutoverScopes, fetchMigrationRuns, updateCutoverScope } from "../api/cutover";
|
||||
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 { toast } from "sonner";
|
||||
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 [scopeType, setScopeType] = useState<"department" | "legal_entity" | "domain">("department");
|
||||
const [scopeKey, setScopeKey] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
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 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 }),
|
||||
mutationFn: () =>
|
||||
createCutoverScope({
|
||||
scopeType: scopeForm.scopeType,
|
||||
scopeKey: scopeForm.scopeKey.trim(),
|
||||
enabled: scopeForm.enabled,
|
||||
startsAt: toIsoDateTime(scopeForm.startsAt),
|
||||
notes: scopeForm.notes.trim() || null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setScopeKey("");
|
||||
setNotes("");
|
||||
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 }),
|
||||
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({
|
||||
@@ -51,65 +245,391 @@ export function CutoverConsolePage() {
|
||||
toast.success("dry-run 已完成");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "dry-run 失败");
|
||||
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="试点范围、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>
|
||||
<PageHeader title="切换控制台" subtitle="聚焦范围开关、延迟生效和 dry-run 验证,不改 Cutover 核心模型。" />
|
||||
|
||||
<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 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">enabled=true 且已到生效时间</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">enabled=false</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">enabled=true 且 startsAt > now</div>
|
||||
</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>
|
||||
<SectionPanel
|
||||
title="切换范围"
|
||||
description="列表视图看当前配置,时间线视图看创建、计划生效与最近更新时间。"
|
||||
actions={
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
新增 scope
|
||||
</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>范围 Key</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>startsAt</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>
|
||||
</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>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">最近 dry-run</h3>
|
||||
<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="上传 dry-run CSV" description="导入 CSV 前先做演练,减少正式迁移时的意外。">
|
||||
<div className="space-y-4">
|
||||
<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"
|
||||
disabled={dryRunMutation.isPending}
|
||||
onClick={() => dryRunMutation.mutate()}
|
||||
>
|
||||
{dryRunMutation.isPending ? "执行中..." : "执行 dry-run"}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="最近 dry-run" 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="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 className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
{!runsQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无 dry-run 记录</div> : null}
|
||||
{!runsQuery.data?.items?.length ? (
|
||||
<div className="text-sm text-zinc-500">暂无 dry-run 记录</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</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>范围 Key</Label>
|
||||
<Input
|
||||
disabled={Boolean(editingScope)}
|
||||
value={scopeForm.scopeKey}
|
||||
onChange={(event) =>
|
||||
setScopeForm((prev) => ({
|
||||
...prev,
|
||||
scopeKey: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="部门 ID / 法体 ID / domain key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>startsAt</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"
|
||||
/>
|
||||
启用该 scope
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchDepartments } from "../api/departments";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
batchUpdateFeatureFlags,
|
||||
fetchFeatureFlagChangelog,
|
||||
fetchFeatureFlagImpact,
|
||||
fetchFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
type FeatureFlagItem,
|
||||
type FeatureFlagKey,
|
||||
type FeatureFlagOverrideMode,
|
||||
type FeatureFlagRolloutType,
|
||||
type FeatureFlagSection,
|
||||
type FeatureFlagSnapshot,
|
||||
} from "../api/featureFlags";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
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 { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
|
||||
const OVERRIDE_MODE_OPTIONS: Array<{ value: FeatureFlagOverrideMode; label: string }> = [
|
||||
{ value: "force_off", label: "强制关闭" },
|
||||
{ value: "inherit", label: "继承Env" },
|
||||
{ value: "inherit", label: "继承 Env" },
|
||||
{ value: "force_on", label: "强制开启" },
|
||||
];
|
||||
|
||||
@@ -30,6 +45,7 @@ const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string
|
||||
];
|
||||
|
||||
function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "ai_assist") return "AI 辅助";
|
||||
if (flagKey === "erp_sync") return "ERP 同步";
|
||||
if (flagKey === "invoice_verification") return "发票验真";
|
||||
if (flagKey === "leave_holiday_calendar") return "请假日历增强";
|
||||
@@ -44,6 +60,7 @@ function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||||
if (flagKey === "todo_board_v1") return "待办工作台";
|
||||
if (flagKey === "wecom_entry_v1") return "企业微信入口";
|
||||
if (flagKey === "project_domain_v1") return "项目主域";
|
||||
if (flagKey === "project_linking_v1") return "项目联动";
|
||||
if (flagKey === "project_communication_log_v1") return "项目沟通回填";
|
||||
@@ -53,10 +70,55 @@ function toDisplayName(flagKey: string) {
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
function formatOverrideModeLabel(value: FeatureFlagOverrideMode) {
|
||||
return OVERRIDE_MODE_OPTIONS.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function formatRolloutTypeLabel(value: FeatureFlagRolloutType) {
|
||||
return ROLLOUT_TYPE_OPTIONS.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function formatSectionLabel(value: FeatureFlagSection) {
|
||||
if (value === "main") return "主导航";
|
||||
if (value === "admin") return "管理入口";
|
||||
if (value === "backup") return "备用入口";
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function dedupeIds(items: string[]) {
|
||||
return Array.from(new Set(items.filter(Boolean)));
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function summarizeSnapshot(snapshot: FeatureFlagSnapshot | null) {
|
||||
if (!snapshot) {
|
||||
return "无快照";
|
||||
}
|
||||
|
||||
const parts = [
|
||||
`override:${formatOverrideModeLabel(snapshot.overrideMode)}`,
|
||||
`rollout:${formatRolloutTypeLabel(snapshot.rolloutType)}`,
|
||||
`白名单部门:${snapshot.departmentIds.length}`,
|
||||
`说明:${snapshot.note || "-"}`,
|
||||
`version:${snapshot.version}`,
|
||||
];
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
type DraftState = {
|
||||
overrideMode: FeatureFlagOverrideMode;
|
||||
rolloutType: FeatureFlagRolloutType;
|
||||
@@ -79,6 +141,13 @@ export function FeatureFlagsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState("");
|
||||
const [drafts, setDrafts] = useState<Record<string, DraftState>>({});
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [selectedFlagKeys, setSelectedFlagKeys] = useState<FeatureFlagKey[]>([]);
|
||||
const [batchAction, setBatchAction] = useState<FeatureFlagOverrideMode | null>(null);
|
||||
const [batchNote, setBatchNote] = useState("");
|
||||
const [historyFlagKey, setHistoryFlagKey] = useState<FeatureFlagKey | null>(null);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [impactFlagKey, setImpactFlagKey] = useState<FeatureFlagKey | null>(null);
|
||||
|
||||
const flagsQuery = useQuery({
|
||||
queryKey: ["feature-flags"],
|
||||
@@ -90,14 +159,30 @@ export function FeatureFlagsPage() {
|
||||
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||||
});
|
||||
|
||||
const historyQuery = useQuery({
|
||||
enabled: Boolean(historyFlagKey),
|
||||
queryKey: ["feature-flag-changelog", historyFlagKey, historyPage],
|
||||
queryFn: () =>
|
||||
fetchFeatureFlagChangelog(historyFlagKey!, {
|
||||
page: historyPage,
|
||||
pageSize: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
const impactQuery = useQuery({
|
||||
enabled: Boolean(impactFlagKey),
|
||||
queryKey: ["feature-flag-impact", impactFlagKey],
|
||||
queryFn: () => fetchFeatureFlagImpact(impactFlagKey!),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (flagKey: string) => {
|
||||
mutationFn: async (flagKey: FeatureFlagKey) => {
|
||||
const draft = drafts[flagKey];
|
||||
if (!draft) {
|
||||
throw new Error("未找到待提交草稿");
|
||||
}
|
||||
|
||||
return updateFeatureFlag(flagKey as FeatureFlagKey, {
|
||||
return updateFeatureFlag(flagKey, {
|
||||
overrideMode: draft.overrideMode,
|
||||
rolloutType: draft.rolloutType,
|
||||
departmentIds:
|
||||
@@ -106,15 +191,61 @@ export function FeatureFlagsPage() {
|
||||
expectedVersion: draft.version,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (result, flagKey) => {
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[flagKey]: buildDraft(result.item),
|
||||
}));
|
||||
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
|
||||
setError("");
|
||||
toast.success("开关已更新");
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新开关失败";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
const message = resolveErrorMessage(err);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const batchMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const selectedItems = items.filter((item) => selectedFlagKeys.includes(item.flagKey));
|
||||
if (!batchAction) {
|
||||
throw new Error("未选择批量动作");
|
||||
}
|
||||
if (!selectedItems.length || selectedItems.length !== selectedFlagKeys.length) {
|
||||
throw new Error("批量选择项已过期,请刷新后重试");
|
||||
}
|
||||
|
||||
return batchUpdateFeatureFlags({
|
||||
items: selectedItems.map((item) => ({
|
||||
flagKey: item.flagKey,
|
||||
overrideMode: batchAction,
|
||||
expectedVersion: item.version,
|
||||
})),
|
||||
note: batchNote.trim(),
|
||||
});
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setDrafts((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const flagKey of selectedFlagKeys) {
|
||||
delete next[flagKey];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
|
||||
setError("");
|
||||
setBatchMode(false);
|
||||
setSelectedFlagKeys([]);
|
||||
setBatchAction(null);
|
||||
setBatchNote("");
|
||||
toast.success(`已批量更新 ${result.count} 个开关`);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = resolveErrorMessage(err);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,6 +258,14 @@ export function FeatureFlagsPage() {
|
||||
}, [departmentsQuery.data?.items]);
|
||||
|
||||
const items = flagsQuery.data?.items ?? [];
|
||||
const selectedItems = useMemo(
|
||||
() => items.filter((item) => selectedFlagKeys.includes(item.flagKey)),
|
||||
[items, selectedFlagKeys],
|
||||
);
|
||||
const historyTotalPages = Math.max(
|
||||
1,
|
||||
Math.ceil((historyQuery.data?.total ?? 0) / (historyQuery.data?.pageSize ?? 10)),
|
||||
);
|
||||
|
||||
const ensureDraft = (item: FeatureFlagItem) => {
|
||||
if (drafts[item.flagKey]) {
|
||||
@@ -135,10 +274,15 @@ export function FeatureFlagsPage() {
|
||||
return buildDraft(item);
|
||||
};
|
||||
|
||||
const patchDraft = (flagKey: string, updater: (prev: DraftState) => DraftState) => {
|
||||
const patchDraft = (flagKey: FeatureFlagKey, updater: (prev: DraftState) => DraftState) => {
|
||||
setDrafts((prev) => {
|
||||
const base = prev[flagKey];
|
||||
const next = updater(base ?? buildDraft(items.find((item) => item.flagKey === flagKey)!));
|
||||
const fallback = items.find((item) => item.flagKey === flagKey);
|
||||
if (!fallback) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = updater(base ?? buildDraft(fallback));
|
||||
return {
|
||||
...prev,
|
||||
[flagKey]: next,
|
||||
@@ -146,8 +290,30 @@ export function FeatureFlagsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchModeChange = () => {
|
||||
if (batchMode) {
|
||||
setBatchMode(false);
|
||||
setSelectedFlagKeys([]);
|
||||
setBatchAction(null);
|
||||
setBatchNote("");
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchMode(true);
|
||||
};
|
||||
|
||||
const handleBatchAction = (overrideMode: FeatureFlagOverrideMode) => {
|
||||
if (!selectedItems.length) {
|
||||
toast.error("请先选择至少一个功能开关");
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchAction(overrideMode);
|
||||
setBatchNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className={batchMode ? "space-y-6 pb-32" : "space-y-6"}>
|
||||
<PageHeader
|
||||
title="功能开关"
|
||||
subtitle="统一治理外部桥接、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
@@ -157,20 +323,24 @@ export function FeatureFlagsPage() {
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">开关列表</h3>
|
||||
|
||||
{flagsQuery.isLoading ? <TableSkeleton columns={6} rows={2} /> : null}
|
||||
<SectionPanel
|
||||
title="开关列表"
|
||||
description="单条编辑保留原能力;批量模式只统一切换 overrideMode,并要求共享变更说明。"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant={batchMode ? "secondary" : "default"}
|
||||
onClick={handleBatchModeChange}
|
||||
>
|
||||
{batchMode ? "退出批量" : "批量操作"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{flagsQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={4} /> : null}
|
||||
|
||||
{flagsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(flagsQuery.error)
|
||||
? `${flagsQuery.error.code}: ${mapApiErrorMessage(
|
||||
flagsQuery.error.code,
|
||||
flagsQuery.error.details,
|
||||
flagsQuery.error.message,
|
||||
)}`
|
||||
: "加载功能开关失败"}
|
||||
{resolveErrorMessage(flagsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -179,21 +349,43 @@ export function FeatureFlagsPage() {
|
||||
<Table data-testid="feature-flags-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
{batchMode ? <TableHead className="w-14">选择</TableHead> : null}
|
||||
<TableHead>功能</TableHead>
|
||||
<TableHead>当前状态</TableHead>
|
||||
<TableHead>覆盖策略</TableHead>
|
||||
<TableHead>灰度范围</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead className="w-40">操作</TableHead>
|
||||
<TableHead className="w-56">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const draft = ensureDraft(item);
|
||||
const isPending = updateMutation.isPending && updateMutation.variables === item.flagKey;
|
||||
const isSelected = selectedFlagKeys.includes(item.flagKey);
|
||||
const singleEditDisabled = batchMode || isPending || batchMutation.isPending;
|
||||
|
||||
return (
|
||||
<TableRow key={item.flagKey} data-testid={`feature-flag-row-${item.flagKey}`}>
|
||||
{batchMode ? (
|
||||
<TableCell>
|
||||
<input
|
||||
aria-label={`select-${item.flagKey}`}
|
||||
checked={isSelected}
|
||||
className="h-4 w-4 rounded border border-zinc-300"
|
||||
onChange={(event) => {
|
||||
setSelectedFlagKeys((prev) => {
|
||||
if (event.target.checked) {
|
||||
return dedupeIds([...prev, item.flagKey]) as FeatureFlagKey[];
|
||||
}
|
||||
return prev.filter((value) => value !== item.flagKey);
|
||||
});
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{toDisplayName(item.flagKey)}</div>
|
||||
<div className="font-mono text-xs text-zinc-500">{item.flagKey}</div>
|
||||
@@ -220,6 +412,7 @@ export function FeatureFlagsPage() {
|
||||
<Label className="mb-1 block text-xs text-zinc-500">override</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
disabled={singleEditDisabled}
|
||||
value={draft.overrideMode}
|
||||
onChange={(event) =>
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
@@ -240,15 +433,14 @@ export function FeatureFlagsPage() {
|
||||
<Label className="mb-1 block text-xs text-zinc-500">rollout</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
disabled={singleEditDisabled}
|
||||
value={draft.rolloutType}
|
||||
onChange={(event) =>
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
...prev,
|
||||
rolloutType: event.target.value as FeatureFlagRolloutType,
|
||||
departmentIds:
|
||||
event.target.value === "department_allowlist"
|
||||
? prev.departmentIds
|
||||
: [],
|
||||
event.target.value === "department_allowlist" ? prev.departmentIds : [],
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -266,6 +458,7 @@ export function FeatureFlagsPage() {
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
disabled={singleEditDisabled}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
const selected = event.target.value;
|
||||
@@ -293,7 +486,8 @@ export function FeatureFlagsPage() {
|
||||
>
|
||||
<span>{departmentsById.get(departmentId) ?? departmentId}</span>
|
||||
<button
|
||||
className="text-red-600"
|
||||
className="text-red-600 disabled:text-zinc-400"
|
||||
disabled={singleEditDisabled}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
@@ -318,7 +512,8 @@ export function FeatureFlagsPage() {
|
||||
|
||||
<TableCell>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
className="min-h-24 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500"
|
||||
disabled={singleEditDisabled}
|
||||
placeholder="填写本次变更说明"
|
||||
value={draft.note}
|
||||
onChange={(event) =>
|
||||
@@ -332,19 +527,40 @@ export function FeatureFlagsPage() {
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setHistoryPage(1);
|
||||
setHistoryFlagKey(item.flagKey);
|
||||
}}
|
||||
>
|
||||
变更历史
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setImpactFlagKey(item.flagKey)}
|
||||
>
|
||||
影响范围
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`feature-flag-save-${item.flagKey}`}
|
||||
disabled={isPending}
|
||||
disabled={singleEditDisabled}
|
||||
size="sm"
|
||||
onClick={() => updateMutation.mutate(item.flagKey)}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
data-testid={`feature-flag-reset-${item.flagKey}`}
|
||||
variant="secondary"
|
||||
disabled={singleEditDisabled}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDrafts((prev) => ({
|
||||
@@ -364,7 +580,251 @@ export function FeatureFlagsPage() {
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(historyFlagKey)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setHistoryFlagKey(null);
|
||||
setHistoryPage(1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>变更历史</DialogTitle>
|
||||
<DialogDescription>
|
||||
{historyFlagKey ? `${toDisplayName(historyFlagKey)} 的历史变更审计记录。` : "查看开关变更轨迹。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{historyQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
|
||||
{historyQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(historyQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{!historyQuery.isLoading && !historyQuery.isError ? (
|
||||
<div className="space-y-4">
|
||||
{historyQuery.data?.items.length ? (
|
||||
historyQuery.data.items.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="text-sm font-medium text-zinc-900">
|
||||
{item.actorName || item.actorId}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-3">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-500">变更前</div>
|
||||
<div className="text-sm text-zinc-700">{summarizeSnapshot(item.from)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-3">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-500">变更后</div>
|
||||
<div className="text-sm text-zinc-700">{summarizeSnapshot(item.to)}</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{historyQuery.data && historyTotalPages > 1 ? (
|
||||
<Pagination page={historyPage} totalPages={historyTotalPages} onPageChange={setHistoryPage} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(impactFlagKey)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setImpactFlagKey(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>影响范围</DialogTitle>
|
||||
<DialogDescription>
|
||||
{impactFlagKey ? `${toDisplayName(impactFlagKey)} 影响的前端导航与后端模块。` : "查看开关影响范围。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{impactQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
|
||||
{impactQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(impactQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{!impactQuery.isLoading && !impactQuery.isError && impactQuery.data ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">前端导航</h4>
|
||||
{impactQuery.data.navigationItems.length ? (
|
||||
impactQuery.data.navigationItems.map((item) => (
|
||||
<div key={`${item.path}-${item.section}`} className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div className="font-medium text-zinc-900">{item.label}</div>
|
||||
<div className="text-sm text-zinc-600">{item.path}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatSectionLabel(item.section)}</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">后端模块</h4>
|
||||
{impactQuery.data.backendModules.length ? (
|
||||
impactQuery.data.backendModules.map((item) => (
|
||||
<div key={item.label} className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div className="font-medium text-zinc-900">{item.label}</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-zinc-600">
|
||||
{item.routes.map((route) => (
|
||||
<div key={route} className="font-mono text-xs text-zinc-500">
|
||||
{route}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(batchAction)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !batchMutation.isPending) {
|
||||
setBatchAction(null);
|
||||
setBatchNote("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{batchAction ? `${formatOverrideModeLabel(batchAction)}所选开关` : "批量更新"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>确认本次批量修改的功能开关,并填写共享变更说明。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||||
{selectedItems.map((item) => (
|
||||
<div key={item.flagKey} className="mb-2 rounded-lg bg-white px-3 py-2 last:mb-0">
|
||||
<div className="font-medium text-zinc-900">{toDisplayName(item.flagKey)}</div>
|
||||
<div className="font-mono text-xs text-zinc-500">
|
||||
{item.flagKey} · version {item.version}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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"
|
||||
placeholder="填写本次批量变更说明"
|
||||
value={batchNote}
|
||||
onChange={(event) => setBatchNote(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={batchMutation.isPending}
|
||||
onClick={() => {
|
||||
setBatchAction(null);
|
||||
setBatchNote("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={batchMutation.isPending || !batchNote.trim() || !selectedItems.length}
|
||||
onClick={() => batchMutation.mutate()}
|
||||
>
|
||||
{batchMutation.isPending ? "提交中..." : "确认执行"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{batchMode ? (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 w-[min(960px,calc(100vw-2rem))] -translate-x-1/2 rounded-2xl border border-zinc-200 bg-white/95 p-4 shadow-xl backdrop-blur">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-zinc-700">
|
||||
已选择 <span className="font-semibold text-zinc-900">{selectedItems.length}</span> 个功能开关
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedFlagKeys(items.map((item) => item.flagKey))}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedFlagKeys([])}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!selectedItems.length || batchMutation.isPending}
|
||||
onClick={() => handleBatchAction("force_on")}
|
||||
>
|
||||
批量启用
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!selectedItems.length || batchMutation.isPending}
|
||||
onClick={() => handleBatchAction("force_off")}
|
||||
>
|
||||
批量禁用
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!selectedItems.length || batchMutation.isPending}
|
||||
onClick={() => handleBatchAction("inherit")}
|
||||
>
|
||||
批量继承
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户