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>({}); const [batchMode, setBatchMode] = useState(false); const [selectedFlagKeys, setSelectedFlagKeys] = useState([]); const [batchAction, setBatchAction] = useState(null); const [batchNote, setBatchNote] = useState(""); const [historyFlagKey, setHistoryFlagKey] = useState(null); const [historyPage, setHistoryPage] = useState(1); const [impactFlagKey, setImpactFlagKey] = useState(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(); 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 (
{error ? (

{error}

) : null} {batchMode ? "退出批量" : "批量操作"} } > {flagsQuery.isLoading ? : null} {flagsQuery.isError ? (

{resolveErrorMessage(flagsQuery.error)}

) : null} {!flagsQuery.isLoading && !flagsQuery.isError ? (
{batchMode ? 选择 : null} 功能当前状态覆盖策略灰度范围备注操作 {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 ( {batchMode ? ( { setSelectedFlagKeys((prev) => { if (event.target.checked) { return dedupeIds([...prev, item.flagKey]) as FeatureFlagKey[]; } return prev.filter((value) => value !== item.flagKey); }); }} type="checkbox" /> ) : null}
{toDisplayName(item.flagKey)}
{item.flagKey}
Env: {item.envEnabled ? "ON" : "OFF"}
Effective: {item.effectiveEnabled ? "ON" : "OFF"}
reason: {item.effectiveReason}
{draft.rolloutType === "department_allowlist" ? (
{draft.departmentIds.length ? ( draft.departmentIds.map((departmentId) => (
{departmentsById.get(departmentId) ?? departmentId}
)) ) : (

暂无白名单部门

)}
) : ( 全量 )}