diff --git a/apps/web/src/hooks/usePermFilteredTabs.ts b/apps/web/src/hooks/usePermFilteredTabs.ts new file mode 100644 index 0000000..b78f112 --- /dev/null +++ b/apps/web/src/hooks/usePermFilteredTabs.ts @@ -0,0 +1,38 @@ +import { useAuthStore } from '../stores/auth'; +import { TAB_PERMISSIONS } from '../routeConfig'; + +export interface TabItem { + key: string; + [prop: string]: unknown; +} + +/** + * 根据权限过滤详情页 Tab 列表。 + * + * @param prefix - Tab 权限映射前缀(如 "patient"),对应 routeConfig.ts 中 "patient#tabKey" + * @param tabs - 完整 Tab 列表 + * @returns 过滤后有权限可见的 Tab 列表 + */ +export function usePermFilteredTabs(prefix: string, tabs: T[]): T[] { + const permissions = useAuthStore((s) => s.permissions); + + return tabs.filter((tab) => { + const lookupKey = `${prefix}#${tab.key}`; + const requiredPerm = TAB_PERMISSIONS[lookupKey]; + + // 未在 TAB_PERMISSIONS 中声明的 Tab,安全默认:不显示 + if (requiredPerm === undefined && !(lookupKey in TAB_PERMISSIONS)) { + if (import.meta.env.DEV) { + console.warn( + `[usePermFilteredTabs] Tab "${lookupKey}" 未在 routeConfig.ts TAB_PERMISSIONS 中声明,已隐藏。请添加声明。`, + ); + } + return false; + } + + // 显式声明为 undefined(无需权限)→ 始终可见 + if (requiredPerm === undefined) return true; + + return permissions.includes(requiredPerm); + }); +} diff --git a/apps/web/src/pages/health/PatientDetail.tsx b/apps/web/src/pages/health/PatientDetail.tsx index 4527485..e2b1e6c 100644 --- a/apps/web/src/pages/health/PatientDetail.tsx +++ b/apps/web/src/pages/health/PatientDetail.tsx @@ -17,7 +17,7 @@ import { Tooltip, } from 'antd'; import { ArrowLeftOutlined, EditOutlined, InfoCircleOutlined } from '@ant-design/icons'; -import { useAuthStore } from '../../stores/auth'; +import { usePermFilteredTabs } from '../../hooks/usePermFilteredTabs'; import { patientApi } from '../../api/health/patients'; import { AuthButton } from '../../components/AuthButton'; import { CopilotBadge } from '../../components/Copilot'; @@ -59,22 +59,17 @@ export default function PatientDetail() { const [editModalOpen, setEditModalOpen] = useState(false); const [form] = Form.useForm(); const isDark = useThemeMode(); - const permissions = useAuthStore((s) => s.permissions); - /** Tab 权限映射:无权限码的 tab 始终可见 */ - const TAB_PERMISSIONS: Record = { - info: undefined, - family: 'health.patient.manage', - health: 'health.health-data.list', - followup: 'health.follow-up.list', - points: 'health.points.list', - ai: 'ai.analysis.list', - }; - - const hasPermission = (code: string | undefined): boolean => { - if (!code) return true; - return permissions.includes(code); - }; + // --- Tab 权限过滤(集中配置于 routeConfig.ts) --- + const allTabs = [ + { key: 'info', label: '基本信息' }, + { key: 'family', label: '家属管理' }, + { key: 'health', label: '健康数据' }, + { key: 'followup', label: '随访记录' }, + { key: 'points', label: '积分账户' }, + { key: 'ai', label: 'AI 建议' }, + ]; + const visibleTabKeys = usePermFilteredTabs('patient', allTabs).map((t) => t.key); // --- 加载患者基本信息 --- const fetchPatient = useCallback(async () => { @@ -383,7 +378,7 @@ export default function PatientDetail() { ) : null, }, - ].filter((tab) => hasPermission(TAB_PERMISSIONS[tab.key]))} + ].filter((tab) => visibleTabKeys.includes(tab.key))} /> diff --git a/apps/web/src/routeConfig.ts b/apps/web/src/routeConfig.ts index 8ea9feb..2ecb7c7 100644 --- a/apps/web/src/routeConfig.ts +++ b/apps/web/src/routeConfig.ts @@ -258,6 +258,28 @@ if (import.meta.env.DEV) { } } +/** + * Tab 级权限映射 — 详情页内 Tab 可见性的单一真相源 + * + * key 格式: "{routePrefix}#{tabKey}" + * value: 权限码(undefined 表示始终可见) + * + * 新增详情页 Tab 时必须在此声明,否则 Tab 默认不可见(安全默认)。 + */ +const TAB_PERM_ENTRIES: Array<{ key: string; permission?: string }> = [ + // 患者详情 /health/patients/:id + { key: "patient#info", permission: undefined }, + { key: "patient#family", permission: "health.patient.manage" }, + { key: "patient#health", permission: "health.health-data.list" }, + { key: "patient#followup", permission: "health.follow-up.list" }, + { key: "patient#points", permission: "health.points.list" }, + { key: "patient#ai", permission: "ai.analysis.list" }, +]; + +export const TAB_PERMISSIONS: Record = Object.fromEntries( + TAB_PERM_ENTRIES.map((e) => [e.key, e.permission]), +); + /** * DEV 模式路由覆盖率校验。 * 在 App.tsx 挂载后调用,检查所有实际路由是否都有权限声明。