From 3e694fa60cfa9fb40871bc9fb44d33d4096ca744 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 7 Apr 2026 21:35:39 +0800 Subject: [PATCH] =?UTF-8?q?R54=20A1-A6=EF=BC=9ACutover=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E4=B8=8E=E7=89=B9=E6=80=A7=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feature Flag 变更历史查看与影响范围预览 - Feature Flag 批量操作(启用/禁用/继承) - Cutover 控制台页面增强(摘要卡片、时间线、延迟生效) - 单测覆盖 changelog、batch、权限边界 --- client/src/api/featureFlags.ts | 104 ++- client/src/pages/CutoverConsolePage.tsx | 624 ++++++++++++++++-- client/src/pages/FeatureFlagsPage.tsx | 528 ++++++++++++++- .../2026-04-07_r54-cutover-feature-flags.csv | 7 + server/src/routes/featureFlags.js | 533 ++++++++++++++- .../unit/routes/featureFlags.batch.test.js | 290 ++++++++ .../routes/featureFlags.changelog.test.js | 179 +++++ 7 files changed, 2143 insertions(+), 122 deletions(-) create mode 100644 issues/2026-04-07_r54-cutover-feature-flags.csv create mode 100644 server/tests/unit/routes/featureFlags.batch.test.js create mode 100644 server/tests/unit/routes/featureFlags.changelog.test.js diff --git a/client/src/api/featureFlags.ts b/client/src/api/featureFlags.ts index 6538d19a..8a877441 100644 --- a/client/src/api/featureFlags.ts +++ b/client/src/api/featureFlags.ts @@ -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("/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( + `/api/system/feature-flags/${flagKey}/changelog${suffix}`, + ); +} + +export async function fetchFeatureFlagImpact(flagKey: FeatureFlagKey) { + return apiRequestData(`/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), + }); +} diff --git a/client/src/pages/CutoverConsolePage.tsx b/client/src/pages/CutoverConsolePage.tsx index 04f6b90c..071dab1f 100644 --- a/client/src/pages/CutoverConsolePage.tsx +++ b/client/src/pages/CutoverConsolePage.tsx @@ -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("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 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 (
- -
-
-
-

新增切换范围

-
-
setScopeKey(event.target.value)} placeholder="部门 ID / 法体 ID / expense" />
-
setNotes(event.target.value)} />
- -
+ -
-

上传 dry-run CSV

-
- setDryRunFile(event.target.files?.[0] ?? null)} /> - -
+
+
+
已启用
+
{summary.active}
+
enabled=true 且已到生效时间
+
+
已禁用
+
{summary.disabled}
+
enabled=false
+
+
+
待生效
+
{summary.pending}
+
enabled=true 且 startsAt > now
+
+
-
-
-

切换范围

-
- {(scopesQuery.data?.items ?? []).map((item) => ( -
-
-
-
{item.scopeType} · {item.scopeKey}
-
{item.notes ?? "-"}
-
{item.enabled ? "enabled" : "disabled"} · startsAt={item.startsAt ?? "-"}
-
- -
+ + 新增 scope + + } + > + setView(value as ScopeView)}> + + 列表 + 时间线 + + + + {scopesQuery.isLoading ? : null} + {scopesQuery.isError ? ( +

+ {resolveErrorMessage(scopesQuery.error)} +

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

最近 dry-run

+ + {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.runType} · {item.status} +
{item.sourceFilename}
-
{item.createdAt ?? "-"}
+
{formatDateTime(item.createdAt)}
))} - {!runsQuery.data?.items?.length ?
暂无 dry-run 记录
: null} + {!runsQuery.data?.items?.length ? ( +
暂无 dry-run 记录
+ ) : 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 / domain key" + /> +
+ +
+ + + setScopeForm((prev) => ({ + ...prev, + startsAt: event.target.value, + })) + } + /> +
+ +
+ +