R54 A1-A6:Cutover 控制台与特性开关增强

- Feature Flag 变更历史查看与影响范围预览
- Feature Flag 批量操作(启用/禁用/继承)
- Cutover 控制台页面增强(摘要卡片、时间线、延迟生效)
- 单测覆盖 changelog、batch、权限边界
这个提交包含在:
2026-04-07 21:35:39 +08:00
父节点 7cc6dc3397
当前提交 3e694fa60c
共修改 7 个文件,包含 2143 行新增和 122 行删除
+103 -1
查看文件
@@ -1,8 +1,32 @@
import { apiRequestData } from "./http"; 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 FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
export type FeatureFlagRolloutType = "global" | "department_allowlist"; export type FeatureFlagRolloutType = "global" | "department_allowlist";
export type FeatureFlagSection = "main" | "admin" | "backup";
export type FeatureFlagItem = { export type FeatureFlagItem = {
id: string | null; id: string | null;
@@ -23,10 +47,51 @@ export type FeatureFlagItem = {
requiresDepartment: boolean; 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 = { export type FeatureFlagListResponse = {
items: FeatureFlagItem[]; 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 = { export type FeatureFlagUpdatePayload = {
overrideMode: FeatureFlagOverrideMode; overrideMode: FeatureFlagOverrideMode;
rolloutType: FeatureFlagRolloutType; rolloutType: FeatureFlagRolloutType;
@@ -35,13 +100,50 @@ export type FeatureFlagUpdatePayload = {
expectedVersion: number; expectedVersion: number;
}; };
export type FeatureFlagBatchUpdatePayload = {
items: Array<{
flagKey: FeatureFlagKey;
overrideMode: FeatureFlagOverrideMode;
expectedVersion: number;
}>;
note: string;
};
export async function fetchFeatureFlags() { export async function fetchFeatureFlags() {
return apiRequestData<FeatureFlagListResponse>("/api/system/feature-flags"); 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) { export async function updateFeatureFlag(flagKey: FeatureFlagKey, payload: FeatureFlagUpdatePayload) {
return apiRequestData<{ item: FeatureFlagItem }>(`/api/system/feature-flags/${flagKey}`, { return apiRequestData<{ item: FeatureFlagItem }>(`/api/system/feature-flags/${flagKey}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), 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),
});
}
+563 -43
查看文件
@@ -1,43 +1,237 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; 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() { export function CutoverConsolePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [scopeType, setScopeType] = useState<"department" | "legal_entity" | "domain">("department"); const [view, setView] = useState<ScopeView>("list");
const [scopeKey, setScopeKey] = useState("");
const [notes, setNotes] = useState("");
const [dryRunType, setDryRunType] = useState<"employees" | "departments" | "budgets" | "masterdata_validation">("employees"); const [dryRunType, setDryRunType] = useState<"employees" | "departments" | "budgets" | "masterdata_validation">("employees");
const [dryRunFile, setDryRunFile] = useState<File | null>(null); 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 () => { const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["cutover-scopes"] }); await queryClient.invalidateQueries({ queryKey: ["cutover-scopes"] });
await queryClient.invalidateQueries({ queryKey: ["migration-runs"] }); await queryClient.invalidateQueries({ queryKey: ["migration-runs"] });
}; };
const createScopeMutation = useMutation({ 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 () => { onSuccess: async () => {
setScopeKey(""); setDialogOpen(false);
setNotes(""); setEditingScope(null);
setScopeForm(buildDefaultScopeForm());
await refresh(); await refresh();
toast.success("切换范围已创建"); 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({ 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 () => { onSuccess: async () => {
await refresh(); await refresh();
toast.success("切换范围已更新"); toast.success("切换范围已更新");
}, },
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
}); });
const dryRunMutation = useMutation({ const dryRunMutation = useMutation({
@@ -51,65 +245,391 @@ export function CutoverConsolePage() {
toast.success("dry-run 已完成"); toast.success("dry-run 已完成");
}, },
onError: (error) => { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title="切换控制台" subtitle="试点范围、dry-run 差异和回退前置准备。" /> <PageHeader title="切换控制台" subtitle="聚焦范围开关、延迟生效和 dry-run 验证,不改 Cutover 核心模型。" />
<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>
<div className="card-std space-y-4 p-6"> <div className="grid gap-4 md:grid-cols-3">
<h3 className="text-base font-semibold text-zinc-900">上传 dry-run CSV</h3> <div className="card-std p-5">
<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> <div className="text-sm text-zinc-500">已启用</div>
<Input type="file" accept=".csv,text/csv" onChange={(event) => setDryRunFile(event.target.files?.[0] ?? null)} /> <div className="mt-2 text-3xl font-semibold text-emerald-600">{summary.active}</div>
<Button type="button" onClick={() => dryRunMutation.mutate()} disabled={dryRunMutation.isPending}>{dryRunMutation.isPending ? "执行中..." : "执行 dry-run"}</Button> <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 &gt; now</div>
</div> </div>
</div> </div>
<div className="space-y-6"> <SectionPanel
<div className="card-std space-y-4 p-6"> title="切换范围"
<h3 className="text-base font-semibold text-zinc-900">切换范围</h3> 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 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"> <div className="space-y-3">
{(scopesQuery.data?.items ?? []).map((item) => ( {timelineItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4"> <div key={item.id} className="rounded-xl border border-zinc-200 bg-zinc-50 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div className="font-medium text-zinc-900">{item.title}</div>
<div className="font-medium text-zinc-900">{item.scopeType} · {item.scopeKey}</div> <div className="text-xs text-zinc-500">
<div className="text-sm text-zinc-600">{item.notes ?? "-"}</div> {formatDateTime(item.at)} · {formatRelativeTime(item.at)}
<div className="text-xs text-zinc-500">{item.enabled ? "enabled" : "disabled"} · startsAt={item.startsAt ?? "-"}</div>
</div> </div>
<Button type="button" variant="secondary" onClick={() => toggleScopeMutation.mutate({ id: item.id, enabled: !item.enabled })}>{item.enabled ? "关闭" : "启用"}</Button>
</div> </div>
<div className="mt-1 text-sm text-zinc-600">{item.description}</div>
</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> </div>
)
) : null}
</TabsContent>
</Tabs>
</SectionPanel>
<div className="card-std space-y-4 p-6"> <div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<h3 className="text-base font-semibold text-zinc-900">最近 dry-run</h3> <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"> <div className="space-y-3">
{(runsQuery.data?.items ?? []).map((item) => ( {(runsQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4"> <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="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> </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>
) : null}
</SectionPanel>
</div> </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>
<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>
<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> </div>
); );
} }
+493 -33
查看文件
@@ -1,26 +1,41 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments"; import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import { import {
batchUpdateFeatureFlags,
fetchFeatureFlagChangelog,
fetchFeatureFlagImpact,
fetchFeatureFlags, fetchFeatureFlags,
updateFeatureFlag, updateFeatureFlag,
type FeatureFlagItem, type FeatureFlagItem,
type FeatureFlagKey, type FeatureFlagKey,
type FeatureFlagOverrideMode, type FeatureFlagOverrideMode,
type FeatureFlagRolloutType, type FeatureFlagRolloutType,
type FeatureFlagSection,
type FeatureFlagSnapshot,
} from "../api/featureFlags"; } from "../api/featureFlags";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
const OVERRIDE_MODE_OPTIONS: Array<{ value: FeatureFlagOverrideMode; label: string }> = [ const OVERRIDE_MODE_OPTIONS: Array<{ value: FeatureFlagOverrideMode; label: string }> = [
{ value: "force_off", label: "强制关闭" }, { value: "force_off", label: "强制关闭" },
{ value: "inherit", label: "继承Env" }, { value: "inherit", label: "继承 Env" },
{ value: "force_on", label: "强制开启" }, { value: "force_on", label: "强制开启" },
]; ];
@@ -30,6 +45,7 @@ const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string
]; ];
function toDisplayName(flagKey: string) { function toDisplayName(flagKey: string) {
if (flagKey === "ai_assist") return "AI 辅助";
if (flagKey === "erp_sync") return "ERP 同步"; if (flagKey === "erp_sync") return "ERP 同步";
if (flagKey === "invoice_verification") return "发票验真"; if (flagKey === "invoice_verification") return "发票验真";
if (flagKey === "leave_holiday_calendar") return "请假日历增强"; if (flagKey === "leave_holiday_calendar") return "请假日历增强";
@@ -44,6 +60,7 @@ function toDisplayName(flagKey: string) {
if (flagKey === "weekly_report_v1") return "周报工作台"; if (flagKey === "weekly_report_v1") return "周报工作台";
if (flagKey === "employee_lifecycle_v1") return "人员生命周期"; if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
if (flagKey === "todo_board_v1") return "待办工作台"; if (flagKey === "todo_board_v1") return "待办工作台";
if (flagKey === "wecom_entry_v1") return "企业微信入口";
if (flagKey === "project_domain_v1") return "项目主域"; if (flagKey === "project_domain_v1") return "项目主域";
if (flagKey === "project_linking_v1") return "项目联动"; if (flagKey === "project_linking_v1") return "项目联动";
if (flagKey === "project_communication_log_v1") return "项目沟通回填"; if (flagKey === "project_communication_log_v1") return "项目沟通回填";
@@ -53,10 +70,55 @@ function toDisplayName(flagKey: string) {
return flagKey; 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[]) { function dedupeIds(items: string[]) {
return Array.from(new Set(items.filter(Boolean))); 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 = { type DraftState = {
overrideMode: FeatureFlagOverrideMode; overrideMode: FeatureFlagOverrideMode;
rolloutType: FeatureFlagRolloutType; rolloutType: FeatureFlagRolloutType;
@@ -79,6 +141,13 @@ export function FeatureFlagsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [drafts, setDrafts] = useState<Record<string, DraftState>>({}); 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({ const flagsQuery = useQuery({
queryKey: ["feature-flags"], queryKey: ["feature-flags"],
@@ -90,14 +159,30 @@ export function FeatureFlagsPage() {
queryFn: () => fetchDepartments({ includeArchived: false }), 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({ const updateMutation = useMutation({
mutationFn: async (flagKey: string) => { mutationFn: async (flagKey: FeatureFlagKey) => {
const draft = drafts[flagKey]; const draft = drafts[flagKey];
if (!draft) { if (!draft) {
throw new Error("未找到待提交草稿"); throw new Error("未找到待提交草稿");
} }
return updateFeatureFlag(flagKey as FeatureFlagKey, { return updateFeatureFlag(flagKey, {
overrideMode: draft.overrideMode, overrideMode: draft.overrideMode,
rolloutType: draft.rolloutType, rolloutType: draft.rolloutType,
departmentIds: departmentIds:
@@ -106,15 +191,61 @@ export function FeatureFlagsPage() {
expectedVersion: draft.version, expectedVersion: draft.version,
}); });
}, },
onSuccess: async () => { onSuccess: async (result, flagKey) => {
setDrafts((prev) => ({
...prev,
[flagKey]: buildDraft(result.item),
}));
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] }); await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
setError(""); setError("");
toast.success("开关已更新"); toast.success("开关已更新");
}, },
onError: (err) => { onError: (err) => {
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新开关失败"; const message = resolveErrorMessage(err);
setError(msg); setError(message);
toast.error(msg); 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]); }, [departmentsQuery.data?.items]);
const items = flagsQuery.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) => { const ensureDraft = (item: FeatureFlagItem) => {
if (drafts[item.flagKey]) { if (drafts[item.flagKey]) {
@@ -135,10 +274,15 @@ export function FeatureFlagsPage() {
return buildDraft(item); return buildDraft(item);
}; };
const patchDraft = (flagKey: string, updater: (prev: DraftState) => DraftState) => { const patchDraft = (flagKey: FeatureFlagKey, updater: (prev: DraftState) => DraftState) => {
setDrafts((prev) => { setDrafts((prev) => {
const base = prev[flagKey]; 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 { return {
...prev, ...prev,
[flagKey]: next, [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 ( return (
<div className="space-y-6"> <div className={batchMode ? "space-y-6 pb-32" : "space-y-6"}>
<PageHeader <PageHeader
title="功能开关" title="功能开关"
subtitle="统一治理外部桥接、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。" 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> <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null} ) : null}
<div className="card-std p-6 space-y-4"> <SectionPanel
<h3 className="text-base font-semibold text-zinc-900">开关列表</h3> title="开关列表"
description="单条编辑保留原能力;批量模式只统一切换 overrideMode,并要求共享变更说明。"
{flagsQuery.isLoading ? <TableSkeleton columns={6} rows={2} /> : null} actions={
<Button
type="button"
variant={batchMode ? "secondary" : "default"}
onClick={handleBatchModeChange}
>
{batchMode ? "退出批量" : "批量操作"}
</Button>
}
>
{flagsQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={4} /> : null}
{flagsQuery.isError ? ( {flagsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"> <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(flagsQuery.error) {resolveErrorMessage(flagsQuery.error)}
? `${flagsQuery.error.code}: ${mapApiErrorMessage(
flagsQuery.error.code,
flagsQuery.error.details,
flagsQuery.error.message,
)}`
: "加载功能开关失败"}
</p> </p>
) : null} ) : null}
@@ -179,21 +349,43 @@ export function FeatureFlagsPage() {
<Table data-testid="feature-flags-table"> <Table data-testid="feature-flags-table">
<TableHeader> <TableHeader>
<tr> <tr>
{batchMode ? <TableHead className="w-14">选择</TableHead> : null}
<TableHead>功能</TableHead> <TableHead>功能</TableHead>
<TableHead>当前状态</TableHead> <TableHead>当前状态</TableHead>
<TableHead>覆盖策略</TableHead> <TableHead>覆盖策略</TableHead>
<TableHead>灰度范围</TableHead> <TableHead>灰度范围</TableHead>
<TableHead>备注</TableHead> <TableHead>备注</TableHead>
<TableHead className="w-40">操作</TableHead> <TableHead className="w-56">操作</TableHead>
</tr> </tr>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((item) => { {items.map((item) => {
const draft = ensureDraft(item); const draft = ensureDraft(item);
const isPending = updateMutation.isPending && updateMutation.variables === item.flagKey; const isPending = updateMutation.isPending && updateMutation.variables === item.flagKey;
const isSelected = selectedFlagKeys.includes(item.flagKey);
const singleEditDisabled = batchMode || isPending || batchMutation.isPending;
return ( return (
<TableRow key={item.flagKey} data-testid={`feature-flag-row-${item.flagKey}`}> <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> <TableCell>
<div className="font-medium text-zinc-900">{toDisplayName(item.flagKey)}</div> <div className="font-medium text-zinc-900">{toDisplayName(item.flagKey)}</div>
<div className="font-mono text-xs text-zinc-500">{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> <Label className="mb-1 block text-xs text-zinc-500">override</Label>
<select <select
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm" className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
disabled={singleEditDisabled}
value={draft.overrideMode} value={draft.overrideMode}
onChange={(event) => onChange={(event) =>
patchDraft(item.flagKey, (prev) => ({ patchDraft(item.flagKey, (prev) => ({
@@ -240,15 +433,14 @@ export function FeatureFlagsPage() {
<Label className="mb-1 block text-xs text-zinc-500">rollout</Label> <Label className="mb-1 block text-xs text-zinc-500">rollout</Label>
<select <select
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm" className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
disabled={singleEditDisabled}
value={draft.rolloutType} value={draft.rolloutType}
onChange={(event) => onChange={(event) =>
patchDraft(item.flagKey, (prev) => ({ patchDraft(item.flagKey, (prev) => ({
...prev, ...prev,
rolloutType: event.target.value as FeatureFlagRolloutType, rolloutType: event.target.value as FeatureFlagRolloutType,
departmentIds: departmentIds:
event.target.value === "department_allowlist" event.target.value === "department_allowlist" ? prev.departmentIds : [],
? prev.departmentIds
: [],
})) }))
} }
> >
@@ -266,6 +458,7 @@ export function FeatureFlagsPage() {
<div className="space-y-2"> <div className="space-y-2">
<select <select
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm" className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
disabled={singleEditDisabled}
value="" value=""
onChange={(event) => { onChange={(event) => {
const selected = event.target.value; const selected = event.target.value;
@@ -293,7 +486,8 @@ export function FeatureFlagsPage() {
> >
<span>{departmentsById.get(departmentId) ?? departmentId}</span> <span>{departmentsById.get(departmentId) ?? departmentId}</span>
<button <button
className="text-red-600" className="text-red-600 disabled:text-zinc-400"
disabled={singleEditDisabled}
type="button" type="button"
onClick={() => onClick={() =>
patchDraft(item.flagKey, (prev) => ({ patchDraft(item.flagKey, (prev) => ({
@@ -318,7 +512,8 @@ export function FeatureFlagsPage() {
<TableCell> <TableCell>
<textarea <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="填写本次变更说明" placeholder="填写本次变更说明"
value={draft.note} value={draft.note}
onChange={(event) => onChange={(event) =>
@@ -332,19 +527,40 @@ export function FeatureFlagsPage() {
</TableCell> </TableCell>
<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 <Button
data-testid={`feature-flag-save-${item.flagKey}`} data-testid={`feature-flag-save-${item.flagKey}`}
disabled={isPending} disabled={singleEditDisabled}
size="sm"
onClick={() => updateMutation.mutate(item.flagKey)} onClick={() => updateMutation.mutate(item.flagKey)}
type="button" type="button"
> >
{isPending ? "保存中..." : "保存"} {isPending ? "保存中..." : "保存"}
</Button> </Button>
<Button <Button
data-testid={`feature-flag-reset-${item.flagKey}`} data-testid={`feature-flag-reset-${item.flagKey}`}
variant="secondary" disabled={singleEditDisabled}
size="sm"
variant="outline"
type="button" type="button"
onClick={() => onClick={() =>
setDrafts((prev) => ({ setDrafts((prev) => ({
@@ -364,7 +580,251 @@ export function FeatureFlagsPage() {
</Table> </Table>
</div> </div>
) : null} ) : null}
</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>
<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> </div>
); );
} }
@@ -0,0 +1,7 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
"A1","R54 执行合同建档与范围冻结","创建并校验 R54 Cutover 控制台与特性开关增强执行合同 CSV,冻结范围仅包含 Feature Flag 变更历史/影响预览、批量 override 操作、Cutover 控制台运维体验增强;明确本轮不新增 Prisma 迁移、不修改 evaluateFeatureFlag 核心逻辑、不修改 CutoverScope 模型。","issues/2026-04-07_r54-cutover-feature-flags.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R54 A1-A6,不混入核心 flag 评估逻辑调整、CutoverScope schema 变更或新增审计模型。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-07_r54-cutover-feature-flags.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend/frontend→reviewer→qa 收口;3) 方案明确批量乐观锁采用每项 expectedVersion,Cutover 时间线仅基于当前记录生成。","manual","DONE","DONE","DONE","issues/2026-04-07_r54-cutover-feature-flags.csv","[]","回滚路径:删除本次 CSV 不影响业务。2026-04-07:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。"
"A2","Feature Flag 变更历史与影响范围预览","在 featureFlags.js 新增 changelog/impact 端点,并在 FeatureFlagsPage 每行增加变更历史与影响范围弹窗。","GET /api/system/feature-flags/:key/changelog 支持 page/pageSize 分页,返回变更人、时间与 from/to 快照;GET /api/system/feature-flags/:key/impact 返回受影响导航项与后端模块;FeatureFlagsPage 可打开历史时间线与影响范围弹窗,空结果有明确状态。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/featureFlags.js && npm test -- tests/unit/routes/featureFlags.changelog.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 每个 flag 行可查看变更历史;2) impact 弹窗可看到前端导航 label/path/section 与后端模块;3) changelog 空历史返回空列表而非错误。","manual","DONE","DONE","DONE","server/src/routes/featureFlags.js|server/tests/unit/routes/featureFlags.changelog.test.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx","[""A1""]","回滚路径:移除 changelog/impact 端点与对应前端弹窗入口。2026-04-07:changelog/impact 端点与 FeatureFlagsPage 双弹窗已完成,prisma validate、node --check、featureFlags.changelog.test.js、client typecheck/build 通过。"
"A3","Feature Flag 批量 override 操作","新增批量更新多个 flag overrideMode 的后端事务接口,并在 FeatureFlagsPage 增加多选批量操作模式。","POST /api/system/feature-flags/batch 使用 items[{flagKey,overrideMode,expectedVersion}] 与共享 note,在同一事务内更新多个 flag;任一版本冲突整批回滚且不写 audit;成功时每个 flag 均写独立 FEATURE_FLAG_UPDATED 审计;前端支持多选、全选/取消全选、批量启用/禁用/继承和说明确认弹窗。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/featureFlags.js && npm test -- tests/unit/routes/featureFlags.batch.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 批量模式下可选择多个 flag 并统一切换 override;2) 任一 flag 版本冲突时整批失败;3) 成功后退出多选模式并刷新列表。","manual","DONE","DONE","DONE","server/src/routes/featureFlags.js|server/tests/unit/routes/featureFlags.batch.test.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx","[""A1""]","回滚路径:移除 batch 端点与批量 UI,恢复单条更新模式。2026-04-07:batch 端点与 FeatureFlagsPage 多选批量模式已完成,featureFlags.batch.test.js、client typecheck/build 通过。"
"A4","Cutover 控制台页面增强","增强现有 CutoverConsolePage,加入摘要卡片、列表/时间线双视图、startsAt 倒计时和统一创建/编辑弹窗,同时保留 dry-run 区域。","/system/cutover 展示启用/禁用/待生效摘要;scope 列表表格展示状态、startsAt、倒计时和操作;时间线视图基于当前 CutoverScope 记录展示创建/计划生效/最近更新节点;创建与编辑均支持 startsAt 日期时间选择;快速切换 enabled 后摘要与列表同步刷新。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/cutover.js && cd ../client && npm run typecheck && npm run build。业务:1) 已启用/已禁用/待生效统计正确;2) 列表视图可看到 startsAt 倒计时;3) 时间线视图显示创建、计划生效、最近更新节点;4) 创建与编辑 scope 时可设置 startsAt 且不回归 dry-run 上传。","manual","DONE","DONE","DONE","client/src/pages/CutoverConsolePage.tsx|client/src/api/cutover.ts","[""A1""]","回滚路径:恢复现有切换范围卡片列表与内联创建表单,不改 dry-run API。2026-04-07:CutoverConsolePage 已增强为摘要卡片 + 列表/时间线双视图 + 统一 scope 弹窗,client typecheck/build 通过。"
"A5","R54 reviewer 权限与事务边界复核","按 reviewer 清单复核 changelog 只读性、impact 映射一致性、batch 事务回滚与审计完整性,以及 Cutover 页面权限边界。","review 结论覆盖:changelog 仅读 audit_logs;impact 映射与 NAVIGATION_REGISTRY 及后端常量一致;batch 在单事务内整批回滚;每个成功变更 flag 都写独立 audit;/system/cutover 与 /api/cutover/* 继续仅收口 system.settings;无 Prisma 迁移、无 evaluateFeatureFlag 行为漂移。","检查:1) 阅读 featureFlags.js 与 FeatureFlagsPage 变更,确认 changelog/impact 无副作用;2) 验证 batch 任一冲突不产生部分更新;3) 核对 audit 写入次数与 extra.from/to;4) 验证 Cutover 页面仍依赖 system.settings 与 cutover_console_v1 闸门。若发现问题,修复后重跑相关测试与构建。","manual","DONE","DONE","DONE","issues/2026-04-07_r54-cutover-feature-flags.csv|server/src/routes/featureFlags.js|client/src/pages/FeatureFlagsPage.tsx|client/src/pages/CutoverConsolePage.tsx","[""A2"",""A3"",""A4""]","回滚路径:若 reviewer 发现权限或事务问题,先下线相关入口,再回退对应代码。2026-04-07:已完成 reviewer 风格自检,确认 changelog 只读 audit_logs、impact 与 NAVIGATION_REGISTRY/后端常量一致、batch 为单事务整批回滚且每个 flag 单独写 audit。"
"A6","QA、状态回填与提交收口","执行 R54 post-check 与业务回归,回填 CSV 状态并完成提交收口,确保特性开关与 Cutover 运维增强不回归。","后端与前端校验全部通过;Feature Flag 单条更新、changelog/impact、批量 override、Cutover 摘要/列表/时间线/startsAt 编辑均回归通过;issues/2026-04-07_r54-cutover-feature-flags.csv 的 Dev_Status、Review1_Status、Regression_Status 按测试结果回填。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/featureFlags.js && node --check src/routes/cutover.js && npm test -- tests/unit/routes/featureFlags.changelog.test.js tests/unit/routes/featureFlags.batch.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 单条 flag 保存不回归;2) 批量 override 成功与冲突场景都正确;3) changelog 与 impact 弹窗展示正常;4) Cutover 摘要、倒计时、时间线、创建/编辑 startsAt 与 dry-run 均正常。","manual","DONE","DONE","DONE","issues/2026-04-07_r54-cutover-feature-flags.csv|server/src/routes/featureFlags.js|server/tests/unit/routes/featureFlags.changelog.test.js|server/tests/unit/routes/featureFlags.batch.test.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx|client/src/pages/CutoverConsolePage.tsx","[""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码变更。2026-04-07:本地执行 prisma validate、featureFlags/cutover node --check、两组 featureFlags 路由单测、client typecheck/build、git diff --check 全部通过;Vitest 在 server 目录下需使用 tests/unit/... 路径。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R54 执行合同建档与范围冻结 创建并校验 R54 Cutover 控制台与特性开关增强执行合同 CSV,冻结范围仅包含 Feature Flag 变更历史/影响预览、批量 override 操作、Cutover 控制台运维体验增强;明确本轮不新增 Prisma 迁移、不修改 evaluateFeatureFlag 核心逻辑、不修改 CutoverScope 模型。 issues/2026-04-07_r54-cutover-feature-flags.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R54 A1-A6,不混入核心 flag 评估逻辑调整、CutoverScope schema 变更或新增审计模型。 命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-07_r54-cutover-feature-flags.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend/frontend→reviewer→qa 收口;3) 方案明确批量乐观锁采用每项 expectedVersion,Cutover 时间线仅基于当前记录生成。 manual DONE DONE DONE issues/2026-04-07_r54-cutover-feature-flags.csv [] 回滚路径:删除本次 CSV 不影响业务。2026-04-07:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。
3 A2 Feature Flag 变更历史与影响范围预览 在 featureFlags.js 新增 changelog/impact 端点,并在 FeatureFlagsPage 每行增加变更历史与影响范围弹窗。 GET /api/system/feature-flags/:key/changelog 支持 page/pageSize 分页,返回变更人、时间与 from/to 快照;GET /api/system/feature-flags/:key/impact 返回受影响导航项与后端模块;FeatureFlagsPage 可打开历史时间线与影响范围弹窗,空结果有明确状态。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/featureFlags.js && npm test -- tests/unit/routes/featureFlags.changelog.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 每个 flag 行可查看变更历史;2) impact 弹窗可看到前端导航 label/path/section 与后端模块;3) changelog 空历史返回空列表而非错误。 manual DONE DONE DONE server/src/routes/featureFlags.js|server/tests/unit/routes/featureFlags.changelog.test.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx ["A1"] 回滚路径:移除 changelog/impact 端点与对应前端弹窗入口。2026-04-07:changelog/impact 端点与 FeatureFlagsPage 双弹窗已完成,prisma validate、node --check、featureFlags.changelog.test.js、client typecheck/build 通过。
4 A3 Feature Flag 批量 override 操作 新增批量更新多个 flag overrideMode 的后端事务接口,并在 FeatureFlagsPage 增加多选批量操作模式。 POST /api/system/feature-flags/batch 使用 items[{flagKey,overrideMode,expectedVersion}] 与共享 note,在同一事务内更新多个 flag;任一版本冲突整批回滚且不写 audit;成功时每个 flag 均写独立 FEATURE_FLAG_UPDATED 审计;前端支持多选、全选/取消全选、批量启用/禁用/继承和说明确认弹窗。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/featureFlags.js && npm test -- tests/unit/routes/featureFlags.batch.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 批量模式下可选择多个 flag 并统一切换 override;2) 任一 flag 版本冲突时整批失败;3) 成功后退出多选模式并刷新列表。 manual DONE DONE DONE server/src/routes/featureFlags.js|server/tests/unit/routes/featureFlags.batch.test.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx ["A1"] 回滚路径:移除 batch 端点与批量 UI,恢复单条更新模式。2026-04-07:batch 端点与 FeatureFlagsPage 多选批量模式已完成,featureFlags.batch.test.js、client typecheck/build 通过。
5 A4 Cutover 控制台页面增强 增强现有 CutoverConsolePage,加入摘要卡片、列表/时间线双视图、startsAt 倒计时和统一创建/编辑弹窗,同时保留 dry-run 区域。 /system/cutover 展示启用/禁用/待生效摘要;scope 列表表格展示状态、startsAt、倒计时和操作;时间线视图基于当前 CutoverScope 记录展示创建/计划生效/最近更新节点;创建与编辑均支持 startsAt 日期时间选择;快速切换 enabled 后摘要与列表同步刷新。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/cutover.js && cd ../client && npm run typecheck && npm run build。业务:1) 已启用/已禁用/待生效统计正确;2) 列表视图可看到 startsAt 倒计时;3) 时间线视图显示创建、计划生效、最近更新节点;4) 创建与编辑 scope 时可设置 startsAt 且不回归 dry-run 上传。 manual DONE DONE DONE client/src/pages/CutoverConsolePage.tsx|client/src/api/cutover.ts ["A1"] 回滚路径:恢复现有切换范围卡片列表与内联创建表单,不改 dry-run API。2026-04-07:CutoverConsolePage 已增强为摘要卡片 + 列表/时间线双视图 + 统一 scope 弹窗,client typecheck/build 通过。
6 A5 R54 reviewer 权限与事务边界复核 按 reviewer 清单复核 changelog 只读性、impact 映射一致性、batch 事务回滚与审计完整性,以及 Cutover 页面权限边界。 review 结论覆盖:changelog 仅读 audit_logs;impact 映射与 NAVIGATION_REGISTRY 及后端常量一致;batch 在单事务内整批回滚;每个成功变更 flag 都写独立 audit;/system/cutover 与 /api/cutover/* 继续仅收口 system.settings;无 Prisma 迁移、无 evaluateFeatureFlag 行为漂移。 检查:1) 阅读 featureFlags.js 与 FeatureFlagsPage 变更,确认 changelog/impact 无副作用;2) 验证 batch 任一冲突不产生部分更新;3) 核对 audit 写入次数与 extra.from/to;4) 验证 Cutover 页面仍依赖 system.settings 与 cutover_console_v1 闸门。若发现问题,修复后重跑相关测试与构建。 manual DONE DONE DONE issues/2026-04-07_r54-cutover-feature-flags.csv|server/src/routes/featureFlags.js|client/src/pages/FeatureFlagsPage.tsx|client/src/pages/CutoverConsolePage.tsx ["A2","A3","A4"] 回滚路径:若 reviewer 发现权限或事务问题,先下线相关入口,再回退对应代码。2026-04-07:已完成 reviewer 风格自检,确认 changelog 只读 audit_logs、impact 与 NAVIGATION_REGISTRY/后端常量一致、batch 为单事务整批回滚且每个 flag 单独写 audit。
7 A6 QA、状态回填与提交收口 执行 R54 post-check 与业务回归,回填 CSV 状态并完成提交收口,确保特性开关与 Cutover 运维增强不回归。 后端与前端校验全部通过;Feature Flag 单条更新、changelog/impact、批量 override、Cutover 摘要/列表/时间线/startsAt 编辑均回归通过;issues/2026-04-07_r54-cutover-feature-flags.csv 的 Dev_Status、Review1_Status、Regression_Status 按测试结果回填。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/featureFlags.js && node --check src/routes/cutover.js && npm test -- tests/unit/routes/featureFlags.changelog.test.js tests/unit/routes/featureFlags.batch.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 单条 flag 保存不回归;2) 批量 override 成功与冲突场景都正确;3) changelog 与 impact 弹窗展示正常;4) Cutover 摘要、倒计时、时间线、创建/编辑 startsAt 与 dry-run 均正常。 manual DONE DONE DONE issues/2026-04-07_r54-cutover-feature-flags.csv|server/src/routes/featureFlags.js|server/tests/unit/routes/featureFlags.changelog.test.js|server/tests/unit/routes/featureFlags.batch.test.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx|client/src/pages/CutoverConsolePage.tsx ["A5"] 回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码变更。2026-04-07:本地执行 prisma validate、featureFlags/cutover node --check、两组 featureFlags 路由单测、client typecheck/build、git diff --check 全部通过;Vitest 在 server 目录下需使用 tests/unit/... 路径。
+498 -35
查看文件
@@ -13,13 +13,23 @@ const {
ROLLOUT_TYPE_LIST, ROLLOUT_TYPE_LIST,
evaluateFeatureFlag, evaluateFeatureFlag,
listFeatureFlags, listFeatureFlags,
sanitizeDepartmentIds,
updateFeatureFlag, updateFeatureFlag,
} = require("../services/featureFlags"); } = require("../services/featureFlags");
const {
DEFAULT_MODULE_VISIBILITY,
NAVIGATION_REGISTRY,
} = require("../services/workspaceSettings");
const KEY_PARAM_SCHEMA = z.object({ const KEY_PARAM_SCHEMA = z.object({
key: z.enum(FEATURE_FLAG_KEY_LIST), key: z.enum(FEATURE_FLAG_KEY_LIST),
}); });
const PAGE_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const UPDATE_BODY_SCHEMA = z.object({ const UPDATE_BODY_SCHEMA = z.object({
overrideMode: z.enum(OVERRIDE_MODE_LIST), overrideMode: z.enum(OVERRIDE_MODE_LIST),
rolloutType: z.enum(ROLLOUT_TYPE_LIST), rolloutType: z.enum(ROLLOUT_TYPE_LIST),
@@ -28,6 +38,33 @@ const UPDATE_BODY_SCHEMA = z.object({
expectedVersion: z.coerce.number().int().min(1), expectedVersion: z.coerce.number().int().min(1),
}); });
const BATCH_BODY_SCHEMA = z
.object({
items: z
.array(
z.object({
flagKey: z.enum(FEATURE_FLAG_KEY_LIST),
overrideMode: z.enum(OVERRIDE_MODE_LIST),
expectedVersion: z.coerce.number().int().min(1),
}),
)
.min(1),
note: z.string().trim().min(1).max(500),
})
.superRefine((value, ctx) => {
const seen = new Set();
for (const [index, item] of value.items.entries()) {
if (seen.has(item.flagKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["items", index, "flagKey"],
message: "duplicate_flag_key",
});
}
seen.add(item.flagKey);
}
});
const FEATURE_FLAG_ITEM_SCHEMA = { const FEATURE_FLAG_ITEM_SCHEMA = {
type: "object", type: "object",
required: [ required: [
@@ -71,43 +108,334 @@ const FEATURE_FLAG_ITEM_SCHEMA = {
}, },
}; };
const FEATURE_FLAG_SNAPSHOT_SCHEMA = {
type: "object",
required: ["overrideMode", "rolloutType", "departmentIds", "note", "version"],
properties: {
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
departmentIds: {
type: "array",
items: { type: "string", format: "uuid" },
},
note: { type: "string" },
version: { type: "integer" },
},
};
const BACKUP_NAVIGATION_KEYS = new Set([
"purchaseRequests",
"travelRequests",
"budgets",
"paymentBatches",
"apOperations",
"invoicePool",
"inventoryChecks",
"cutover",
"erpIntegrations",
"approvalRules",
"userManagement",
"departments",
"masterData",
"notificationChannels",
"workflowTasks",
"dashboard",
]);
const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
ai_assist: [
{
label: "AI 建议服务",
routes: ["/api/ai/suggestions"],
},
],
erp_sync: [
{
label: "ERP 集成控制台",
routes: [
"/api/integrations/erp/master-data/status",
"/api/integrations/erp/master-data/sync",
"/api/integrations/outbox",
"/api/integrations/bindings",
],
},
{
label: "业务 ERP 同步入口",
routes: [
"/api/integrations/erp/journals/reimbursements/:reimbursementId/preview",
"/api/integrations/erp/journals/reimbursements/:reimbursementId/export",
"/api/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track",
"/api/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue",
"/api/integrations/erp/assets/:assetId/track",
"/api/integrations/erp/assets/:assetId/export",
],
},
],
invoice_verification: [
{
label: "票据验真模块",
routes: ["/api/invoice-pool/verification/*"],
},
],
leave_holiday_calendar: [
{
label: "请假日历增强",
routes: ["/api/leave-requests", "/api/leave-requests/:id", "/api/leave-balance/summary"],
},
],
budget_precheck_v1: [
{
label: "报销预算预校验",
routes: ["/api/reimbursements", "/api/reimbursements/:id"],
},
],
payment_batch_v1: [
{
label: "支付批次与 AP 运营",
routes: ["/api/payment-batches", "/api/ap-operations", "/api/exports/ap-operations"],
},
],
portal_home_v1: [
{
label: "Portal 首页聚合",
routes: ["/api/portal/home"],
},
],
global_search_v1: [
{
label: "全局搜索",
routes: ["/api/search"],
},
],
announcements_v1: [
{
label: "公告中心",
routes: ["/api/announcements", "/api/portal/home"],
},
],
workflow_definition_admin_v1: [
{
label: "流程定义治理",
routes: ["/api/system/workflow-definitions"],
},
],
cutover_console_v1: [
{
label: "切换控制台",
routes: ["/api/cutover/scopes", "/api/migration-runs", "/api/migration-runs/dry-run"],
},
],
presence_board_v1: [
{
label: "团队状态板",
routes: ["/api/presence/team", "/api/portal/home"],
},
],
weekly_report_v1: [
{
label: "周报工作台",
routes: ["/api/weekly-reports", "/api/portal/home", "/api/search"],
},
],
employee_lifecycle_v1: [
{
label: "人员生命周期",
routes: ["/api/employees", "/api/performance/cycles", "/api/performance/reviews"],
},
],
todo_board_v1: [
{
label: "待办工作台",
routes: ["/api/todos", "/api/portal/home", "/api/search"],
},
],
wecom_entry_v1: [
{
label: "企业微信入口",
routes: ["/api/wecom/auth/login", "/api/wecom/auth/bind", "/api/wecom/auth/unbind"],
},
],
project_domain_v1: [
{
label: "项目主域",
routes: ["/api/projects"],
},
],
project_linking_v1: [
{
label: "项目联动与合同台账",
routes: [
"/api/projects/:projectId/contracts",
"/api/projects/:projectId/reimbursements",
"/api/projects/:projectId/travel-requests",
"/api/contracts-registry",
"/api/exports/contracts-registry",
],
},
],
project_communication_log_v1: [
{
label: "项目沟通回填",
routes: [
"/api/projects/:projectId/communication-logs",
"/api/projects/:projectId/communication-logs/:logId",
],
},
],
project_closure_checklist_v1: [
{
label: "项目收口清单",
routes: [
"/api/projects/:projectId/closure-checklist",
"/api/projects/:projectId/closure-checklist/:itemId",
],
},
],
confidential_doc_refs_v1: [
{
label: "涉密资料引用",
routes: ["/api/confidential-doc-refs", "/api/projects/:projectId/confidential-doc-refs", "/api/search"],
},
],
public_security_hardening_v1: [],
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function buildVersionConflictError(flagKey, expectedVersion, currentVersion) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
featureFlag: "version_conflict",
flagKey,
expectedVersion,
currentVersion,
},
});
}
function parseKey(params) { function parseKey(params) {
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {}); const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildValidationError(parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
return parsed.data.key; return parsed.data.key;
} }
function parsePageQuery(query) {
const parsed = PAGE_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function parseUpdateBody(body) { function parseUpdateBody(body) {
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {}); const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildValidationError(parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) { if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) {
throw new AppError({ throw buildValidationError({ departmentIds: "rollout_type_global_disallows_department_ids" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { departmentIds: "rollout_type_global_disallows_department_ids" },
});
} }
return parsed.data; return parsed.data;
} }
function parseBatchBody(body) {
const parsed = BATCH_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeOverrideMode(value) {
return OVERRIDE_MODE_LIST.includes(value) ? value : "force_off";
}
function normalizeRolloutType(value) {
return ROLLOUT_TYPE_LIST.includes(value) ? value : "global";
}
function mapFlagRecord(record) {
if (!record) {
return null;
}
return {
id: record.id,
flagKey: record.flag_key,
overrideMode: normalizeOverrideMode(record.override_mode),
rolloutType: normalizeRolloutType(record.rollout_type),
departmentIds: sanitizeDepartmentIds(record.department_ids),
note: record.note ?? "",
version: Number(record.version ?? 1),
createdAt: record.created_at?.toISOString?.() ?? null,
createdBy: record.created_by ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
updatedBy: record.updated_by ?? null,
};
}
function buildFeatureFlagSnapshot(record) {
return {
overrideMode: record.overrideMode,
rolloutType: record.rolloutType,
departmentIds: record.departmentIds,
note: record.note,
version: record.version,
};
}
function buildFeatureFlagAuditExtra(flagKey, current, updated) {
return {
flagKey,
from: buildFeatureFlagSnapshot(current),
to: buildFeatureFlagSnapshot(updated),
};
}
function resolveNavigationSection(moduleKey) {
if (BACKUP_NAVIGATION_KEYS.has(moduleKey)) {
return "backup";
}
const placement = DEFAULT_MODULE_VISIBILITY[moduleKey] ?? "hidden";
return placement === "admin" ? "admin" : "main";
}
function buildFeatureFlagImpact(flagKey) {
const navigationItems = Object.entries(NAVIGATION_REGISTRY)
.filter(([, item]) => item.featureFlagKey === flagKey)
.map(([moduleKey, item]) => ({
label: item.label,
path: item.href,
section: resolveNavigationSection(moduleKey),
}));
return {
flagKey,
navigationItems,
backendModules: FEATURE_FLAG_BACKEND_IMPACT[flagKey] ?? [],
};
}
async function featureFlagsRoutes(fastify) { async function featureFlagsRoutes(fastify) {
fastify.get( fastify.get(
"/system/feature-flags", "/system/feature-flags",
@@ -145,6 +473,157 @@ async function featureFlagsRoutes(fastify) {
}, },
); );
fastify.get(
"/system/feature-flags/:key/changelog",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const key = parseKey(request.params);
const query = parsePageQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {
action: "FEATURE_FLAG_UPDATED",
target_type: "feature_flag",
extra: {
path: ["flagKey"],
equals: key,
},
};
const [items, total] = await Promise.all([
fastify.prisma.auditLog.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
actor_id: true,
created_at: true,
extra: true,
actor: {
select: {
id: true,
name: true,
},
},
},
}),
fastify.prisma.auditLog.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map((item) => ({
id: item.id,
actorId: item.actor_id,
actorName: item.actor?.name ?? "",
createdAt: item.created_at?.toISOString?.() ?? null,
from: item.extra?.from ?? null,
to: item.extra?.to ?? null,
})),
});
},
);
fastify.get(
"/system/feature-flags/:key/impact",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const key = parseKey(request.params);
return sendData(reply, request, buildFeatureFlagImpact(key));
},
);
fastify.post(
"/system/feature-flags/batch",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const body = parseBatchBody(request.body);
const results = await fastify.prisma.$transaction(async (tx) => {
const updatedFlags = [];
for (const item of body.items) {
const currentRow = await tx.featureFlag.findUnique({
where: { flag_key: item.flagKey },
});
if (!currentRow) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const current = mapFlagRecord(currentRow);
if (item.expectedVersion !== current.version) {
throw buildVersionConflictError(item.flagKey, item.expectedVersion, current.version);
}
const updateResult = await tx.featureFlag.updateMany({
where: {
id: current.id,
version: current.version,
},
data: {
override_mode: item.overrideMode,
rollout_type: current.rolloutType,
department_ids: current.departmentIds,
note: body.note,
version: {
increment: 1,
},
updated_by: request.employee.id,
},
});
if (updateResult.count !== 1) {
const latestRow = await tx.featureFlag.findUnique({
where: { flag_key: item.flagKey },
});
const latest = mapFlagRecord(latestRow);
throw buildVersionConflictError(
item.flagKey,
item.expectedVersion,
latest?.version ?? current.version,
);
}
const updatedRow = await tx.featureFlag.findUnique({
where: { flag_key: item.flagKey },
});
const updated = mapFlagRecord(updatedRow);
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "FEATURE_FLAG_UPDATED",
targetType: "feature_flag",
targetId: updated.id,
extra: buildFeatureFlagAuditExtra(item.flagKey, current, updated),
}),
});
updatedFlags.push(updated);
}
return updatedFlags;
});
return sendData(reply, request, { count: results.length });
},
);
fastify.put( fastify.put(
"/system/feature-flags/:key", "/system/feature-flags/:key",
{ {
@@ -257,23 +736,7 @@ async function featureFlagsRoutes(fastify) {
action: "FEATURE_FLAG_UPDATED", action: "FEATURE_FLAG_UPDATED",
targetType: "feature_flag", targetType: "feature_flag",
targetId: result.updated.id, targetId: result.updated.id,
extra: { extra: buildFeatureFlagAuditExtra(key, result.current, result.updated),
flagKey: key,
from: {
overrideMode: result.current.overrideMode,
rolloutType: result.current.rolloutType,
departmentIds: result.current.departmentIds,
note: result.current.note,
version: result.current.version,
},
to: {
overrideMode: result.updated.overrideMode,
rolloutType: result.updated.rolloutType,
departmentIds: result.updated.departmentIds,
note: result.updated.note,
version: result.updated.version,
},
},
}), }),
}); });
@@ -0,0 +1,290 @@
const Fastify = require("fastify");
const featureFlagsRoutes = require("../../../src/routes/featureFlags");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildFlagRecord(flagKey, overrides = {}) {
return {
id:
flagKey === "payment_batch_v1"
? "11111111-1111-4111-8111-111111111111"
: "22222222-2222-4222-8222-222222222222",
flag_key: flagKey,
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "before batch",
version: flagKey === "payment_batch_v1" ? 2 : 4,
created_at: new Date("2026-04-07T08:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-04-07T08:00:00.000Z"),
updated_by: null,
...overrides,
};
}
function cloneRecord(record) {
return {
...record,
department_ids: Array.isArray(record.department_ids) ? [...record.department_ids] : record.department_ids,
created_at: new Date(record.created_at),
updated_at: new Date(record.updated_at),
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: ACTOR_ID,
permissionCodes: ["system.settings"],
};
const state = {
flags:
options.flags?.map((item) => cloneRecord(item)) ?? [
buildFlagRecord("payment_batch_v1"),
buildFlagRecord("cutover_console_v1"),
],
auditLogs: [],
};
const topLevelFindUnique = vi.fn(async ({ where }) => {
if (where?.flag_key) {
return state.flags.find((item) => item.flag_key === where.flag_key) ?? null;
}
return null;
});
const prisma = {
auditLog: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
featureFlag: {
findUnique: topLevelFindUnique,
},
$transaction: vi.fn(async (callback) => {
const txState = {
flags: state.flags.map((item) => cloneRecord(item)),
auditLogs: state.auditLogs.map((item) => ({ ...item })),
};
const tx = {
featureFlag: {
findUnique: vi.fn(async ({ where }) => {
if (where?.flag_key) {
return txState.flags.find((item) => item.flag_key === where.flag_key) ?? null;
}
return null;
}),
updateMany: vi.fn(async ({ where, data }) => {
const target = txState.flags.find(
(item) => item.id === where.id && item.version === where.version,
);
if (!target) {
return { count: 0 };
}
target.override_mode = data.override_mode;
target.rollout_type = data.rollout_type;
target.department_ids = Array.isArray(data.department_ids)
? [...data.department_ids]
: data.department_ids;
target.note = data.note;
target.version += Number(data.version?.increment ?? 0);
target.updated_by = data.updated_by;
target.updated_at = new Date("2026-04-07T09:00:00.000Z");
return { count: 1 };
}),
},
auditLog: {
create: vi.fn(async ({ data }) => {
txState.auditLogs.push(data);
return { id: `audit-${txState.auditLogs.length}` };
}),
},
};
try {
const result = await callback(tx);
state.flags = txState.flags;
state.auditLogs = txState.auditLogs;
return result;
} catch (error) {
throw error;
}
}),
};
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("config", {});
app.decorate("prisma", prisma);
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(featureFlagsRoutes, { prefix: "/api" });
return { app, state, prisma };
}
describe("feature flag batch route", () => {
it("updates multiple flags in one transaction and writes one audit per flag", async () => {
const { app, state, prisma } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/system/feature-flags/batch",
payload: {
items: [
{
flagKey: "payment_batch_v1",
overrideMode: "force_on",
expectedVersion: 2,
},
{
flagKey: "cutover_console_v1",
overrideMode: "inherit",
expectedVersion: 4,
},
],
note: "R54 批量切换",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.count).toBe(2);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(state.flags).toEqual(
expect.arrayContaining([
expect.objectContaining({
flag_key: "payment_batch_v1",
override_mode: "force_on",
note: "R54 批量切换",
version: 3,
updated_by: ACTOR_ID,
}),
expect.objectContaining({
flag_key: "cutover_console_v1",
override_mode: "inherit",
note: "R54 批量切换",
version: 5,
updated_by: ACTOR_ID,
}),
]),
);
expect(state.auditLogs).toHaveLength(2);
expect(state.auditLogs[0]).toMatchObject({
action: "FEATURE_FLAG_UPDATED",
target_type: "feature_flag",
actor_id: ACTOR_ID,
extra: expect.objectContaining({
flagKey: "payment_batch_v1",
}),
});
expect(state.auditLogs[1]).toMatchObject({
action: "FEATURE_FLAG_UPDATED",
extra: expect.objectContaining({
flagKey: "cutover_console_v1",
}),
});
await app.close();
});
it("rolls back the whole batch when any flag hits a version conflict", async () => {
const { app, state } = await buildApp();
const beforeFlags = state.flags.map((item) => ({
flag_key: item.flag_key,
override_mode: item.override_mode,
note: item.note,
version: item.version,
}));
const response = await app.inject({
method: "POST",
url: "/api/system/feature-flags/batch",
payload: {
items: [
{
flagKey: "payment_batch_v1",
overrideMode: "force_on",
expectedVersion: 2,
},
{
flagKey: "cutover_console_v1",
overrideMode: "force_on",
expectedVersion: 99,
},
],
note: "should rollback",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toMatchObject({
featureFlag: "version_conflict",
flagKey: "cutover_console_v1",
expectedVersion: 99,
currentVersion: 4,
});
expect(
state.flags.map((item) => ({
flag_key: item.flag_key,
override_mode: item.override_mode,
note: item.note,
version: item.version,
})),
).toEqual(beforeFlags);
expect(state.auditLogs).toEqual([]);
await app.close();
});
it("rejects batch updates without system.settings permission", async () => {
const { app, state } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: [],
},
});
const response = await app.inject({
method: "POST",
url: "/api/system/feature-flags/batch",
payload: {
items: [
{
flagKey: "payment_batch_v1",
overrideMode: "force_on",
expectedVersion: 2,
},
],
note: "should fail",
},
});
expect(response.statusCode).toBe(403);
expect(state.auditLogs).toEqual([]);
await app.close();
});
});
@@ -0,0 +1,179 @@
const Fastify = require("fastify");
const featureFlagsRoutes = require("../../../src/routes/featureFlags");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildAuditLog(overrides = {}) {
return {
id: "11111111-1111-4111-8111-111111111111",
actor_id: ACTOR_ID,
created_at: new Date("2026-04-07T10:00:00.000Z"),
extra: {
flagKey: "payment_batch_v1",
from: {
overrideMode: "force_off",
rolloutType: "global",
departmentIds: [],
note: "old note",
version: 1,
},
to: {
overrideMode: "force_on",
rolloutType: "global",
departmentIds: [],
note: "new note",
version: 2,
},
},
actor: {
id: ACTOR_ID,
name: "系统管理员",
},
...overrides,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: ACTOR_ID,
permissionCodes: ["system.settings"],
};
const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildAuditLog()]);
const count = options.count ?? vi.fn().mockResolvedValue(1);
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("config", {});
app.decorate("prisma", {
auditLog: {
findMany,
count,
create: vi.fn(),
},
featureFlag: {
findUnique: vi.fn(),
},
$transaction: vi.fn(),
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(featureFlagsRoutes, { prefix: "/api" });
return { app, findMany, count };
}
describe("feature flag changelog route", () => {
it("lists paginated changelog records for a specific flag", async () => {
const { app, findMany, count } = await buildApp();
const response = await app.inject({
method: "GET",
url: "/api/system/feature-flags/payment_batch_v1/changelog?page=2&pageSize=10",
});
expect(response.statusCode).toBe(200);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
action: "FEATURE_FLAG_UPDATED",
target_type: "feature_flag",
extra: {
path: ["flagKey"],
equals: "payment_batch_v1",
},
},
orderBy: { created_at: "desc" },
skip: 10,
take: 10,
}),
);
expect(count).toHaveBeenCalledWith({
where: {
action: "FEATURE_FLAG_UPDATED",
target_type: "feature_flag",
extra: {
path: ["flagKey"],
equals: "payment_batch_v1",
},
},
});
const payload = response.json();
expect(payload.data.page).toBe(2);
expect(payload.data.pageSize).toBe(10);
expect(payload.data.total).toBe(1);
expect(payload.data.items[0]).toMatchObject({
actorId: ACTOR_ID,
actorName: "系统管理员",
from: {
overrideMode: "force_off",
},
to: {
overrideMode: "force_on",
},
});
await app.close();
});
it("returns an empty list when no changelog records exist", async () => {
const { app } = await buildApp({
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
});
const response = await app.inject({
method: "GET",
url: "/api/system/feature-flags/cutover_console_v1/changelog",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
page: 1,
pageSize: 20,
total: 0,
items: [],
});
await app.close();
});
it("rejects requests without system.settings permission", async () => {
const { app, findMany, count } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: [],
},
});
const response = await app.inject({
method: "GET",
url: "/api/system/feature-flags/payment_batch_v1/changelog",
});
expect(response.statusCode).toBe(403);
expect(findMany).not.toHaveBeenCalled();
expect(count).not.toHaveBeenCalled();
await app.close();
});
});