diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 065a204..80de59d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,7 +4,8 @@ // ============================================================ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { DueRow, FlexCapacityData, FlexGanttData, GanttData, LoadData, ViewportCommand, ViewportMode, WorldSummary } from './api/types'; -import { createWorkspaceFile, fetchDue, fetchFlexCapacity, fetchFlexGantt, fetchGantt, fetchLoad, fetchSummary, fetchTimeline } from './api/client'; +import { createWorkspaceFile, fetchDue, fetchFeatures, fetchFlexCapacity, fetchFlexGantt, fetchGantt, fetchLoad, fetchSummary, fetchTimeline } from './api/client'; +import { featureOn, toEnabledMap, type FeatureEnabledMap } from './features/flags'; import ChatPanel, { type ChatHandle } from './chat/ChatPanel'; import TimelineRail from './timeline/TimelineRail'; import type { InspectTarget } from './viewport/WorldDiffView'; @@ -115,6 +116,19 @@ export default function App() { const [masterRefreshKey, setMasterRefreshKey] = useState(0); const [projData, setProjData] = useState(() => loadStore()); const [workspaceReady, setWorkspaceReady] = useState(false); + // 功能开关(文件化配置):null = 未加载,fail-open 全部视为开启 + const [featureMap, setFeatureMap] = useState(null); + useEffect(() => { + let cancelled = false; + void fetchFeatures() + .then(resp => { if (!cancelled) setFeatureMap(toEnabledMap(resp)); }) + .catch(() => { /* 拉取失败 fail-open:全部功能保持可见 */ }); + return () => { cancelled = true; }; + }, []); + const featOn = useCallback( + (key: string) => featureOn(featureMap, key), + [featureMap], + ); // 治理控制台(Codex 设置页式覆盖层)与命令面板 const [govOpen, setGovOpen] = useState(false); const [govSection, setGovSection] = useState('gate.pending'); @@ -238,7 +252,7 @@ export default function App() { const hasVisualResult = !!activeResult?.world.summary?.hasVersion || !!activeResult?.world.flexGantt?.workOrders?.length || !!(activeResult?.world.flexCapacity?.pools?.length); - const rightVisible = rightOpen && !!activeSession && ( + const rightVisible = rightOpen && !!activeSession && featOn(rightTool) && ( rightTool === 'orders' || rightTool === 'master' || rightTool === 'flex' || rightTool === 'timeline' || rightTool === 'mesh' || rightTool === 'drawing' || (rightTool === 'visual' && hasVisualResult) @@ -436,35 +450,41 @@ export default function App() { }, []); // 命令面板条目:口令(发对话)/ 面板(布局开关)/ 视图(视口切换) - const paletteItems: PaletteItem[] = [ - ...[ - '多策略方案对比', '试排一版交期优先', '产能均衡重排', '建一个检查点', - '回滚到上一个检查点', '查看冲突', '标出超期订单', '查看当前KPI', '发布当前排产版本', - ].map(q => ({ - id: `cmd-${q}`, kind: '口令' as const, label: q, hint: '发送到对话', - run: () => { chatRef.current?.send(q); }, - })), - { id: 'panel-orders', kind: '面板', label: '订单管理', hint: rightTool === 'orders' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('orders') }, - { id: 'panel-master', kind: '面板', label: '主数据', hint: rightTool === 'master' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('master') }, - { id: 'panel-flex', kind: '面板', label: '柔性工作台', hint: rightTool === 'flex' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('flex') }, - { id: 'panel-timeline', kind: '面板', label: '时间线', hint: rightTool === 'timeline' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('timeline') }, - { id: 'panel-drawing', kind: '面板', label: '图纸识别', hint: rightTool === 'drawing' && rightOpen ? '当前已展开' : 'DXF → 主数据候选', run: () => openRightTool('drawing') }, - { id: 'panel-settings', kind: '面板', label: '设置', hint: '偏好 / 模型与集成 / 治理', run: () => openGov('settings.personal') }, - { id: 'panel-gate', kind: '面板', label: '设置 · 门禁待审批', hint: '审批 / 审计链 / 权力矩阵', run: () => openGov('gate.pending') }, - { id: 'panel-skills', kind: '面板', label: '设置 · 算法 Skill', hint: 'Skill 管理台(启停/登记/测试/日志)', run: () => openGov('settings.skills') }, - { id: 'panel-knowledge', kind: '面板', label: '设置 · 知识平台', hint: '知识资产 / 检索调试 / 工艺模板库 / 索引', run: () => openGov('knowledge.assets') }, - { id: 'panel-regen', kind: '面板', label: '设置 · 重生中心', hint: '模块注册表 / 黄金测试', run: () => openGov('regen.modules') }, - { id: 'view-gantt', kind: '视图', label: '甘特图', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'gantt' }, issuedBy: 'USER' }) }, - { id: 'view-load', kind: '视图', label: '负荷热力', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'load' }, issuedBy: 'USER' }) }, - { id: 'view-util', kind: '视图', label: '资源利用率', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'util' }, issuedBy: 'USER' }) }, - { id: 'view-plan', kind: '视图', label: '分桶计划', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'plan' }, issuedBy: 'USER' }) }, - { id: 'view-due', kind: '视图', label: '交期承诺看板', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'due' }, issuedBy: 'USER' }) }, - { id: 'view-kpi', kind: '视图', label: 'KPI 仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'kpi' }, issuedBy: 'USER' }) }, - { id: 'view-compare', kind: '视图', label: '方案对比表', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'compare' }, issuedBy: 'USER' }) }, - { id: 'view-diff', kind: '视图', label: '两个世界对比', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'diff' }, issuedBy: 'USER' }) }, - { id: 'view-pool', kind: '视图', label: '产能池仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'pool' }, issuedBy: 'USER' }) }, - { id: 'view-flex', kind: '视图', label: '柔性甘特', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'flex' }, issuedBy: 'USER' }) }, - ]; + // 面板/视图条目按功能开关过滤(featOn fail-open:未加载时全部可见) + const paletteItems: PaletteItem[] = useMemo(() => { + const entries: Array = [ + ...[ + '多策略方案对比', '试排一版交期优先', '产能均衡重排', '建一个检查点', + '回滚到上一个检查点', '查看冲突', '标出超期订单', '查看当前KPI', '发布当前排产版本', + ].map(q => ({ + id: `cmd-${q}`, kind: '口令' as const, label: q, hint: '发送到对话', + run: () => { chatRef.current?.send(q); }, + })), + { id: 'panel-orders', kind: '面板', feature: 'orders', label: '订单管理', hint: rightTool === 'orders' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('orders') }, + { id: 'panel-master', kind: '面板', feature: 'master', label: '主数据', hint: rightTool === 'master' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('master') }, + { id: 'panel-flex', kind: '面板', feature: 'flex', label: '柔性工作台', hint: rightTool === 'flex' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('flex') }, + { id: 'panel-timeline', kind: '面板', feature: 'timeline', label: '时间线', hint: rightTool === 'timeline' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('timeline') }, + { id: 'panel-drawing', kind: '面板', feature: 'drawing', label: '图纸识别', hint: rightTool === 'drawing' && rightOpen ? '当前已展开' : 'DXF → 主数据候选', run: () => openRightTool('drawing') }, + { id: 'panel-settings', kind: '面板', feature: 'settings', label: '设置', hint: '偏好 / 模型与集成 / 治理', run: () => openGov('settings.personal') }, + { id: 'panel-gate', kind: '面板', feature: 'gov', label: '设置 · 门禁待审批', hint: '审批 / 审计链 / 权力矩阵', run: () => openGov('gate.pending') }, + { id: 'panel-skills', kind: '面板', feature: 'skills', label: '设置 · 算法 Skill', hint: 'Skill 管理台(启停/登记/测试/日志)', run: () => openGov('settings.skills') }, + { id: 'panel-knowledge', kind: '面板', feature: 'knowledge', label: '设置 · 知识平台', hint: '知识资产 / 检索调试 / 工艺模板库 / 索引', run: () => openGov('knowledge.assets') }, + { id: 'panel-regen', kind: '面板', feature: 'regen', label: '设置 · 重生中心', hint: '模块注册表 / 黄金测试', run: () => openGov('regen.modules') }, + { id: 'view-gantt', kind: '视图', feature: 'visual', label: '甘特图', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'gantt' }, issuedBy: 'USER' }) }, + { id: 'view-load', kind: '视图', feature: 'visual', label: '负荷热力', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'load' }, issuedBy: 'USER' }) }, + { id: 'view-util', kind: '视图', feature: 'visual', label: '资源利用率', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'util' }, issuedBy: 'USER' }) }, + { id: 'view-plan', kind: '视图', feature: 'visual', label: '分桶计划', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'plan' }, issuedBy: 'USER' }) }, + { id: 'view-due', kind: '视图', feature: 'visual', label: '交期承诺看板', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'due' }, issuedBy: 'USER' }) }, + { id: 'view-kpi', kind: '视图', feature: 'visual', label: 'KPI 仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'kpi' }, issuedBy: 'USER' }) }, + { id: 'view-compare', kind: '视图', feature: 'visual', label: '方案对比表', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'compare' }, issuedBy: 'USER' }) }, + { id: 'view-diff', kind: '视图', feature: 'visual', label: '两个世界对比', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'diff' }, issuedBy: 'USER' }) }, + { id: 'view-pool', kind: '视图', feature: 'visual', label: '产能池仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'pool' }, issuedBy: 'USER' }) }, + { id: 'view-flex', kind: '视图', feature: 'flex', label: '柔性甘特', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'flex' }, issuedBy: 'USER' }) }, + ]; + return entries + .filter(item => !item.feature || featOn(item.feature)) + .map(({ feature: _feature, ...item }) => item); + }, [featOn, rightTool, rightOpen, openRightTool, openGov, executeCommand]); const onAppMenuAction = useCallback((action: AppMenuAction) => { switch (action) { @@ -524,6 +544,7 @@ export default function App() {
{ if (mobileLayout) setMobileSidebarOpen(v => !v); else setSidebarCollapsed(v => !v); @@ -538,6 +559,7 @@ export default function App() { > { if (mobileLayout && ( next.activeProjectId !== projData.activeProjectId @@ -676,6 +698,7 @@ export default function App() {
)} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 7775f7c..a90ebf4 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -73,6 +73,8 @@ async function getJson(url: string): Promise { // KPI 摘要 export const fetchSummary = () => getJson('/api/world/summary'); +// 功能开关(P0 只读):文件化配置哪些功能上线可见、哪些隐藏不用 +export const fetchFeatures = () => getJson('/api/features'); // 甘特视图数据 export const fetchGantt = () => getJson('/api/world/gantt'); // 负荷热力数据 diff --git a/apps/web/src/features/flags.ts b/apps/web/src/features/flags.ts new file mode 100644 index 0000000..3bfb7f3 --- /dev/null +++ b/apps/web/src/features/flags.ts @@ -0,0 +1,32 @@ +// ============================================================ +// 功能开关(Feature Flags)前端类型与判定(moduleId: web-feature-flags, 可重生 ✅) +// 数据源:GET /api/features(文件化配置,server/data/features.json)。 +// 语义:可用性/可见性开关,不是权限边界;拉取失败或未加载时 fail-open +// (全部视为开启),绝不因配置问题把界面锁死。 +// ============================================================ + +export interface FeatureFlagsResponse { + version: number; + source: 'default' | 'file'; + path: string; + error: string | null; + unknown: string[]; + features: Record; +} + +/** key → enabled 的扁平映射(null 表示尚未加载,一律视为开启) */ +export type FeatureEnabledMap = Record; + +export function toEnabledMap(resp: FeatureFlagsResponse): FeatureEnabledMap { + const map: FeatureEnabledMap = {}; + for (const [key, info] of Object.entries(resp.features ?? {})) { + map[key] = info?.enabled !== false; + } + return map; +} + +/** fail-open 判定:未加载 / 键缺失 → 视为开启;仅显式 false 才隐藏 */ +export function featureOn(map: FeatureEnabledMap | null, key: string): boolean { + if (map == null) return true; + return map[key] !== false; +} diff --git a/apps/web/src/projects/ProjectPanel.tsx b/apps/web/src/projects/ProjectPanel.tsx index 62e898c..386d139 100644 --- a/apps/web/src/projects/ProjectPanel.tsx +++ b/apps/web/src/projects/ProjectPanel.tsx @@ -4,6 +4,7 @@ // ============================================================ import { useEffect, useMemo, useRef, useState } from 'react'; import type { ChatSession, Project, ProjectStoreData } from './types'; +import { featureOn, type FeatureEnabledMap } from '../features/flags'; import { addFile, addProject, addSession, removeFile, removeProject, removeSession, PERSONAL_PROJECT_ID, renameProject, renameSession, selectProject, selectSession, @@ -160,6 +161,7 @@ function NewTaskMenu(props: { export default function ProjectPanel(props: { data: ProjectStoreData; + features?: FeatureEnabledMap | null; onChange: (next: ProjectStoreData) => void; onOpenOrders: () => void; onOpenMaster: () => void; @@ -305,42 +307,54 @@ export default function ProjectPanel(props: { }} /> + {featureOn(props.features ?? null, 'timeline') && ( + )} + {featureOn(props.features ?? null, 'orders') && ( + )} + {featureOn(props.features ?? null, 'master') && ( + )} + {featureOn(props.features ?? null, 'flex') && ( + )} + {featureOn(props.features ?? null, 'knowledge') && ( + )} + {featureOn(props.features ?? null, 'gov') && ( + )} {/* ---- 项目(内嵌任务) ---- */} diff --git a/apps/web/src/shell/AppMenuBar.tsx b/apps/web/src/shell/AppMenuBar.tsx index e7b7815..3b8ee7f 100644 --- a/apps/web/src/shell/AppMenuBar.tsx +++ b/apps/web/src/shell/AppMenuBar.tsx @@ -3,6 +3,7 @@ // 桌面端:自绘标题栏(可拖拽)+ 最小化 / 最大化·还原 / 关闭(必须可见) // ============================================================ import { useEffect, useRef, useState } from 'react'; +import { featureOn, type FeatureEnabledMap } from '../features/flags'; export type AppMenuAction = | 'new-chat' @@ -88,8 +89,40 @@ function detectDesktopShell() { ); } +/** 菜单动作 → 功能开关键(fail-open:未登记的动作不受开关控制) */ +const ACTION_FEATURE: Partial> = { + 'open-orders': 'orders', + 'open-master': 'master', + 'open-flex': 'flex', + 'open-timeline': 'timeline', + 'open-knowledge': 'knowledge', + 'open-gate': 'gov', + 'open-settings': 'settings', +}; + +/** 按功能开关过滤菜单:隐藏禁用项、清理多余分隔线、丢弃空菜单组 */ +function filterMenus(menus: MenuGroup[], features: FeatureEnabledMap | null | undefined): MenuGroup[] { + const groups: MenuGroup[] = []; + for (const group of menus) { + const kept: MenuItem[] = []; + for (const item of group.items) { + if (item.kind === 'item') { + const key = ACTION_FEATURE[item.action]; + if (key && !featureOn(features ?? null, key)) continue; + kept.push(item); + } else if (kept.length > 0 && kept[kept.length - 1].kind !== 'sep') { + kept.push(item); // 分隔线仅在两项之间保留 + } + } + while (kept.length > 0 && kept[kept.length - 1].kind === 'sep') kept.pop(); + if (kept.length > 0) groups.push({ ...group, items: kept }); + } + return groups; +} + export default function AppMenuBar(props: { sidebarCollapsed: boolean; + features?: FeatureEnabledMap | null; onToggleSidebar: () => void; onAction: (action: AppMenuAction) => void; }) { @@ -155,7 +188,7 @@ export default function AppMenuBar(props: {