- Feature Flag 变更历史查看与影响范围预览 - Feature Flag 批量操作(启用/禁用/继承) - Cutover 控制台页面增强(摘要卡片、时间线、延迟生效) - 单测覆盖 changelog、batch、权限边界
831 行
33 KiB
TypeScript
831 行
33 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { fetchDepartments } from "../api/departments";
|
||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||
import {
|
||
batchUpdateFeatureFlags,
|
||
fetchFeatureFlagChangelog,
|
||
fetchFeatureFlagImpact,
|
||
fetchFeatureFlags,
|
||
updateFeatureFlag,
|
||
type FeatureFlagItem,
|
||
type FeatureFlagKey,
|
||
type FeatureFlagOverrideMode,
|
||
type FeatureFlagRolloutType,
|
||
type FeatureFlagSection,
|
||
type FeatureFlagSnapshot,
|
||
} from "../api/featureFlags";
|
||
import { isApiError } from "../api/types";
|
||
import { PageHeader } from "../components/common/PageHeader";
|
||
import { Pagination } from "../components/common/Pagination";
|
||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||
import { Button } from "../components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../components/ui/dialog";
|
||
import { Label } from "../components/ui/label";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||
|
||
const OVERRIDE_MODE_OPTIONS: Array<{ value: FeatureFlagOverrideMode; label: string }> = [
|
||
{ value: "force_off", label: "强制关闭" },
|
||
{ value: "inherit", label: "继承 Env" },
|
||
{ value: "force_on", label: "强制开启" },
|
||
];
|
||
|
||
const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string }> = [
|
||
{ value: "global", label: "全量" },
|
||
{ value: "department_allowlist", label: "部门白名单" },
|
||
];
|
||
|
||
function toDisplayName(flagKey: string) {
|
||
if (flagKey === "ai_assist") return "AI 辅助";
|
||
if (flagKey === "erp_sync") return "ERP 同步";
|
||
if (flagKey === "invoice_verification") return "发票验真";
|
||
if (flagKey === "leave_holiday_calendar") return "请假日历增强";
|
||
if (flagKey === "budget_precheck_v1") return "预算预校验";
|
||
if (flagKey === "payment_batch_v1") return "支付批次";
|
||
if (flagKey === "portal_home_v1") return "Portal 首页";
|
||
if (flagKey === "global_search_v1") return "全局搜索";
|
||
if (flagKey === "announcements_v1") return "公告中心";
|
||
if (flagKey === "workflow_definition_admin_v1") return "流程定义治理";
|
||
if (flagKey === "cutover_console_v1") return "切换控制台";
|
||
if (flagKey === "presence_board_v1") return "团队状态板";
|
||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||
if (flagKey === "todo_board_v1") return "待办工作台";
|
||
if (flagKey === "wecom_entry_v1") return "企业微信入口";
|
||
if (flagKey === "project_domain_v1") return "项目主域";
|
||
if (flagKey === "project_linking_v1") return "项目联动";
|
||
if (flagKey === "project_communication_log_v1") return "项目沟通回填";
|
||
if (flagKey === "project_closure_checklist_v1") return "项目收口清单";
|
||
if (flagKey === "confidential_doc_refs_v1") return "涉密资料引用";
|
||
if (flagKey === "public_security_hardening_v1") return "公网安全强化";
|
||
return flagKey;
|
||
}
|
||
|
||
function formatOverrideModeLabel(value: FeatureFlagOverrideMode) {
|
||
return OVERRIDE_MODE_OPTIONS.find((option) => option.value === value)?.label ?? value;
|
||
}
|
||
|
||
function formatRolloutTypeLabel(value: FeatureFlagRolloutType) {
|
||
return ROLLOUT_TYPE_OPTIONS.find((option) => option.value === value)?.label ?? value;
|
||
}
|
||
|
||
function formatSectionLabel(value: FeatureFlagSection) {
|
||
if (value === "main") return "主导航";
|
||
if (value === "admin") return "管理入口";
|
||
if (value === "backup") return "备用入口";
|
||
return value;
|
||
}
|
||
|
||
function formatDateTime(value: string | null) {
|
||
if (!value) return "-";
|
||
return value.slice(0, 19).replace("T", " ");
|
||
}
|
||
|
||
function dedupeIds(items: string[]) {
|
||
return Array.from(new Set(items.filter(Boolean)));
|
||
}
|
||
|
||
function resolveErrorMessage(error: unknown) {
|
||
if (isApiError(error)) {
|
||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
}
|
||
if (error instanceof Error) {
|
||
return error.message;
|
||
}
|
||
return "请求失败,请稍后重试。";
|
||
}
|
||
|
||
function summarizeSnapshot(snapshot: FeatureFlagSnapshot | null) {
|
||
if (!snapshot) {
|
||
return "无快照";
|
||
}
|
||
|
||
const parts = [
|
||
`override:${formatOverrideModeLabel(snapshot.overrideMode)}`,
|
||
`rollout:${formatRolloutTypeLabel(snapshot.rolloutType)}`,
|
||
`白名单部门:${snapshot.departmentIds.length}`,
|
||
`说明:${snapshot.note || "-"}`,
|
||
`version:${snapshot.version}`,
|
||
];
|
||
return parts.join(" | ");
|
||
}
|
||
|
||
type DraftState = {
|
||
overrideMode: FeatureFlagOverrideMode;
|
||
rolloutType: FeatureFlagRolloutType;
|
||
departmentIds: string[];
|
||
note: string;
|
||
version: number;
|
||
};
|
||
|
||
function buildDraft(item: FeatureFlagItem): DraftState {
|
||
return {
|
||
overrideMode: item.overrideMode,
|
||
rolloutType: item.rolloutType,
|
||
departmentIds: item.departmentIds,
|
||
note: item.note ?? "",
|
||
version: item.version,
|
||
};
|
||
}
|
||
|
||
export function FeatureFlagsPage() {
|
||
const queryClient = useQueryClient();
|
||
const [error, setError] = useState("");
|
||
const [drafts, setDrafts] = useState<Record<string, DraftState>>({});
|
||
const [batchMode, setBatchMode] = useState(false);
|
||
const [selectedFlagKeys, setSelectedFlagKeys] = useState<FeatureFlagKey[]>([]);
|
||
const [batchAction, setBatchAction] = useState<FeatureFlagOverrideMode | null>(null);
|
||
const [batchNote, setBatchNote] = useState("");
|
||
const [historyFlagKey, setHistoryFlagKey] = useState<FeatureFlagKey | null>(null);
|
||
const [historyPage, setHistoryPage] = useState(1);
|
||
const [impactFlagKey, setImpactFlagKey] = useState<FeatureFlagKey | null>(null);
|
||
|
||
const flagsQuery = useQuery({
|
||
queryKey: ["feature-flags"],
|
||
queryFn: () => fetchFeatureFlags(),
|
||
});
|
||
|
||
const departmentsQuery = useQuery({
|
||
queryKey: ["departments", "feature-flags", "active"],
|
||
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||
});
|
||
|
||
const historyQuery = useQuery({
|
||
enabled: Boolean(historyFlagKey),
|
||
queryKey: ["feature-flag-changelog", historyFlagKey, historyPage],
|
||
queryFn: () =>
|
||
fetchFeatureFlagChangelog(historyFlagKey!, {
|
||
page: historyPage,
|
||
pageSize: 10,
|
||
}),
|
||
});
|
||
|
||
const impactQuery = useQuery({
|
||
enabled: Boolean(impactFlagKey),
|
||
queryKey: ["feature-flag-impact", impactFlagKey],
|
||
queryFn: () => fetchFeatureFlagImpact(impactFlagKey!),
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: async (flagKey: FeatureFlagKey) => {
|
||
const draft = drafts[flagKey];
|
||
if (!draft) {
|
||
throw new Error("未找到待提交草稿");
|
||
}
|
||
|
||
return updateFeatureFlag(flagKey, {
|
||
overrideMode: draft.overrideMode,
|
||
rolloutType: draft.rolloutType,
|
||
departmentIds:
|
||
draft.rolloutType === "department_allowlist" ? dedupeIds(draft.departmentIds) : [],
|
||
note: draft.note.trim(),
|
||
expectedVersion: draft.version,
|
||
});
|
||
},
|
||
onSuccess: async (result, flagKey) => {
|
||
setDrafts((prev) => ({
|
||
...prev,
|
||
[flagKey]: buildDraft(result.item),
|
||
}));
|
||
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
|
||
setError("");
|
||
toast.success("开关已更新");
|
||
},
|
||
onError: (err) => {
|
||
const message = resolveErrorMessage(err);
|
||
setError(message);
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
const batchMutation = useMutation({
|
||
mutationFn: () => {
|
||
const selectedItems = items.filter((item) => selectedFlagKeys.includes(item.flagKey));
|
||
if (!batchAction) {
|
||
throw new Error("未选择批量动作");
|
||
}
|
||
if (!selectedItems.length || selectedItems.length !== selectedFlagKeys.length) {
|
||
throw new Error("批量选择项已过期,请刷新后重试");
|
||
}
|
||
|
||
return batchUpdateFeatureFlags({
|
||
items: selectedItems.map((item) => ({
|
||
flagKey: item.flagKey,
|
||
overrideMode: batchAction,
|
||
expectedVersion: item.version,
|
||
})),
|
||
note: batchNote.trim(),
|
||
});
|
||
},
|
||
onSuccess: async (result) => {
|
||
setDrafts((prev) => {
|
||
const next = { ...prev };
|
||
for (const flagKey of selectedFlagKeys) {
|
||
delete next[flagKey];
|
||
}
|
||
return next;
|
||
});
|
||
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
|
||
setError("");
|
||
setBatchMode(false);
|
||
setSelectedFlagKeys([]);
|
||
setBatchAction(null);
|
||
setBatchNote("");
|
||
toast.success(`已批量更新 ${result.count} 个开关`);
|
||
},
|
||
onError: (err) => {
|
||
const message = resolveErrorMessage(err);
|
||
setError(message);
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
const departmentsById = useMemo(() => {
|
||
const map = new Map<string, string>();
|
||
for (const item of departmentsQuery.data?.items ?? []) {
|
||
map.set(item.id, item.name);
|
||
}
|
||
return map;
|
||
}, [departmentsQuery.data?.items]);
|
||
|
||
const items = flagsQuery.data?.items ?? [];
|
||
const selectedItems = useMemo(
|
||
() => items.filter((item) => selectedFlagKeys.includes(item.flagKey)),
|
||
[items, selectedFlagKeys],
|
||
);
|
||
const historyTotalPages = Math.max(
|
||
1,
|
||
Math.ceil((historyQuery.data?.total ?? 0) / (historyQuery.data?.pageSize ?? 10)),
|
||
);
|
||
|
||
const ensureDraft = (item: FeatureFlagItem) => {
|
||
if (drafts[item.flagKey]) {
|
||
return drafts[item.flagKey];
|
||
}
|
||
return buildDraft(item);
|
||
};
|
||
|
||
const patchDraft = (flagKey: FeatureFlagKey, updater: (prev: DraftState) => DraftState) => {
|
||
setDrafts((prev) => {
|
||
const base = prev[flagKey];
|
||
const fallback = items.find((item) => item.flagKey === flagKey);
|
||
if (!fallback) {
|
||
return prev;
|
||
}
|
||
|
||
const next = updater(base ?? buildDraft(fallback));
|
||
return {
|
||
...prev,
|
||
[flagKey]: next,
|
||
};
|
||
});
|
||
};
|
||
|
||
const handleBatchModeChange = () => {
|
||
if (batchMode) {
|
||
setBatchMode(false);
|
||
setSelectedFlagKeys([]);
|
||
setBatchAction(null);
|
||
setBatchNote("");
|
||
return;
|
||
}
|
||
|
||
setBatchMode(true);
|
||
};
|
||
|
||
const handleBatchAction = (overrideMode: FeatureFlagOverrideMode) => {
|
||
if (!selectedItems.length) {
|
||
toast.error("请先选择至少一个功能开关");
|
||
return;
|
||
}
|
||
|
||
setBatchAction(overrideMode);
|
||
setBatchNote("");
|
||
};
|
||
|
||
return (
|
||
<div className={batchMode ? "space-y-6 pb-32" : "space-y-6"}>
|
||
<PageHeader
|
||
title="功能开关"
|
||
subtitle="统一治理外部桥接、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||
/>
|
||
|
||
{error ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||
) : null}
|
||
|
||
<SectionPanel
|
||
title="开关列表"
|
||
description="单条编辑保留原能力;批量模式只统一切换 overrideMode,并要求共享变更说明。"
|
||
actions={
|
||
<Button
|
||
type="button"
|
||
variant={batchMode ? "secondary" : "default"}
|
||
onClick={handleBatchModeChange}
|
||
>
|
||
{batchMode ? "退出批量" : "批量操作"}
|
||
</Button>
|
||
}
|
||
>
|
||
{flagsQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={4} /> : null}
|
||
|
||
{flagsQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{resolveErrorMessage(flagsQuery.error)}
|
||
</p>
|
||
) : null}
|
||
|
||
{!flagsQuery.isLoading && !flagsQuery.isError ? (
|
||
<div className="rounded-lg border border-zinc-200">
|
||
<Table data-testid="feature-flags-table">
|
||
<TableHeader>
|
||
<tr>
|
||
{batchMode ? <TableHead className="w-14">选择</TableHead> : null}
|
||
<TableHead>功能</TableHead>
|
||
<TableHead>当前状态</TableHead>
|
||
<TableHead>覆盖策略</TableHead>
|
||
<TableHead>灰度范围</TableHead>
|
||
<TableHead>备注</TableHead>
|
||
<TableHead className="w-56">操作</TableHead>
|
||
</tr>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((item) => {
|
||
const draft = ensureDraft(item);
|
||
const isPending = updateMutation.isPending && updateMutation.variables === item.flagKey;
|
||
const isSelected = selectedFlagKeys.includes(item.flagKey);
|
||
const singleEditDisabled = batchMode || isPending || batchMutation.isPending;
|
||
|
||
return (
|
||
<TableRow key={item.flagKey} data-testid={`feature-flag-row-${item.flagKey}`}>
|
||
{batchMode ? (
|
||
<TableCell>
|
||
<input
|
||
aria-label={`select-${item.flagKey}`}
|
||
checked={isSelected}
|
||
className="h-4 w-4 rounded border border-zinc-300"
|
||
onChange={(event) => {
|
||
setSelectedFlagKeys((prev) => {
|
||
if (event.target.checked) {
|
||
return dedupeIds([...prev, item.flagKey]) as FeatureFlagKey[];
|
||
}
|
||
return prev.filter((value) => value !== item.flagKey);
|
||
});
|
||
}}
|
||
type="checkbox"
|
||
/>
|
||
</TableCell>
|
||
) : null}
|
||
|
||
<TableCell>
|
||
<div className="font-medium text-zinc-900">{toDisplayName(item.flagKey)}</div>
|
||
<div className="font-mono text-xs text-zinc-500">{item.flagKey}</div>
|
||
</TableCell>
|
||
|
||
<TableCell className="text-sm">
|
||
<div>
|
||
Env:
|
||
<span className={item.envEnabled ? "text-emerald-600" : "text-red-600"}>
|
||
{item.envEnabled ? "ON" : "OFF"}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
Effective:
|
||
<span className={item.effectiveEnabled ? "text-emerald-600" : "text-red-600"}>
|
||
{item.effectiveEnabled ? "ON" : "OFF"}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs text-zinc-500">reason: {item.effectiveReason}</div>
|
||
</TableCell>
|
||
|
||
<TableCell className="space-y-2 text-sm">
|
||
<div>
|
||
<Label className="mb-1 block text-xs text-zinc-500">override</Label>
|
||
<select
|
||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||
disabled={singleEditDisabled}
|
||
value={draft.overrideMode}
|
||
onChange={(event) =>
|
||
patchDraft(item.flagKey, (prev) => ({
|
||
...prev,
|
||
overrideMode: event.target.value as FeatureFlagOverrideMode,
|
||
}))
|
||
}
|
||
>
|
||
{OVERRIDE_MODE_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="mb-1 block text-xs text-zinc-500">rollout</Label>
|
||
<select
|
||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||
disabled={singleEditDisabled}
|
||
value={draft.rolloutType}
|
||
onChange={(event) =>
|
||
patchDraft(item.flagKey, (prev) => ({
|
||
...prev,
|
||
rolloutType: event.target.value as FeatureFlagRolloutType,
|
||
departmentIds:
|
||
event.target.value === "department_allowlist" ? prev.departmentIds : [],
|
||
}))
|
||
}
|
||
>
|
||
{ROLLOUT_TYPE_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</TableCell>
|
||
|
||
<TableCell className="text-sm">
|
||
{draft.rolloutType === "department_allowlist" ? (
|
||
<div className="space-y-2">
|
||
<select
|
||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||
disabled={singleEditDisabled}
|
||
value=""
|
||
onChange={(event) => {
|
||
const selected = event.target.value;
|
||
if (!selected) return;
|
||
patchDraft(item.flagKey, (prev) => ({
|
||
...prev,
|
||
departmentIds: dedupeIds([...prev.departmentIds, selected]),
|
||
}));
|
||
}}
|
||
>
|
||
<option value="">添加部门</option>
|
||
{(departmentsQuery.data?.items ?? []).map((department) => (
|
||
<option key={department.id} value={department.id}>
|
||
{department.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
<div className="max-h-24 overflow-auto rounded border border-zinc-200 p-2 text-xs">
|
||
{draft.departmentIds.length ? (
|
||
draft.departmentIds.map((departmentId) => (
|
||
<div
|
||
key={departmentId}
|
||
className="mb-1 flex items-center justify-between rounded bg-zinc-50 px-2 py-1"
|
||
>
|
||
<span>{departmentsById.get(departmentId) ?? departmentId}</span>
|
||
<button
|
||
className="text-red-600 disabled:text-zinc-400"
|
||
disabled={singleEditDisabled}
|
||
type="button"
|
||
onClick={() =>
|
||
patchDraft(item.flagKey, (prev) => ({
|
||
...prev,
|
||
departmentIds: prev.departmentIds.filter((id) => id !== departmentId),
|
||
}))
|
||
}
|
||
>
|
||
移除
|
||
</button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-zinc-500">暂无白名单部门</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<span className="text-zinc-500">全量</span>
|
||
)}
|
||
</TableCell>
|
||
|
||
<TableCell>
|
||
<textarea
|
||
className="min-h-24 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500"
|
||
disabled={singleEditDisabled}
|
||
placeholder="填写本次变更说明"
|
||
value={draft.note}
|
||
onChange={(event) =>
|
||
patchDraft(item.flagKey, (prev) => ({
|
||
...prev,
|
||
note: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
<p className="mt-1 text-xs text-zinc-500">version: {draft.version}</p>
|
||
</TableCell>
|
||
|
||
<TableCell>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setHistoryPage(1);
|
||
setHistoryFlagKey(item.flagKey);
|
||
}}
|
||
>
|
||
变更历史
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => setImpactFlagKey(item.flagKey)}
|
||
>
|
||
影响范围
|
||
</Button>
|
||
<Button
|
||
data-testid={`feature-flag-save-${item.flagKey}`}
|
||
disabled={singleEditDisabled}
|
||
size="sm"
|
||
onClick={() => updateMutation.mutate(item.flagKey)}
|
||
type="button"
|
||
>
|
||
{isPending ? "保存中..." : "保存"}
|
||
</Button>
|
||
<Button
|
||
data-testid={`feature-flag-reset-${item.flagKey}`}
|
||
disabled={singleEditDisabled}
|
||
size="sm"
|
||
variant="outline"
|
||
type="button"
|
||
onClick={() =>
|
||
setDrafts((prev) => ({
|
||
...prev,
|
||
[item.flagKey]: buildDraft(item),
|
||
}))
|
||
}
|
||
>
|
||
重置
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
) : 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 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>
|
||
);
|
||
}
|