refactor(web): Tab 权限映射集中化 — 消除硬编码

- routeConfig.ts 新增 TAB_PERMISSIONS 配置(单一真相源)
- 新增 usePermFilteredTabs hook,通用 Tab 权限过滤
- PatientDetail.tsx 移除内联 TAB_PERMISSIONS,改用 hook
- 未声明 Tab 安全默认隐藏,DEV 模式 console.warn 提示
This commit is contained in:
iven
2026-05-15 19:15:26 +08:00
parent 8763e10d6e
commit 2c48bb0f56
3 changed files with 72 additions and 17 deletions

View File

@@ -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<T extends TabItem>(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);
});
}

View File

@@ -17,7 +17,7 @@ import {
Tooltip, Tooltip,
} from 'antd'; } from 'antd';
import { ArrowLeftOutlined, EditOutlined, InfoCircleOutlined } from '@ant-design/icons'; 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 { patientApi } from '../../api/health/patients';
import { AuthButton } from '../../components/AuthButton'; import { AuthButton } from '../../components/AuthButton';
import { CopilotBadge } from '../../components/Copilot'; import { CopilotBadge } from '../../components/Copilot';
@@ -59,22 +59,17 @@ export default function PatientDetail() {
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const isDark = useThemeMode(); const isDark = useThemeMode();
const permissions = useAuthStore((s) => s.permissions);
/** Tab 权限映射:无权限码的 tab 始终可见 */ // --- Tab 权限过滤(集中配置于 routeConfig.ts ---
const TAB_PERMISSIONS: Record<string, string | undefined> = { const allTabs = [
info: undefined, { key: 'info', label: '基本信息' },
family: 'health.patient.manage', { key: 'family', label: '家属管理' },
health: 'health.health-data.list', { key: 'health', label: '健康数据' },
followup: 'health.follow-up.list', { key: 'followup', label: '随访记录' },
points: 'health.points.list', { key: 'points', label: '积分账户' },
ai: 'ai.analysis.list', { key: 'ai', label: 'AI 建议' },
}; ];
const visibleTabKeys = usePermFilteredTabs('patient', allTabs).map((t) => t.key);
const hasPermission = (code: string | undefined): boolean => {
if (!code) return true;
return permissions.includes(code);
};
// --- 加载患者基本信息 --- // --- 加载患者基本信息 ---
const fetchPatient = useCallback(async () => { const fetchPatient = useCallback(async () => {
@@ -383,7 +378,7 @@ export default function PatientDetail() {
</Space> </Space>
) : null, ) : null,
}, },
].filter((tab) => hasPermission(TAB_PERMISSIONS[tab.key]))} ].filter((tab) => visibleTabKeys.includes(tab.key))}
/> />
</Card> </Card>

View File

@@ -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<string, string | undefined> = Object.fromEntries(
TAB_PERM_ENTRIES.map((e) => [e.key, e.permission]),
);
/** /**
* DEV 模式路由覆盖率校验。 * DEV 模式路由覆盖率校验。
* 在 App.tsx 挂载后调用,检查所有实际路由是否都有权限声明。 * 在 App.tsx 挂载后调用,检查所有实际路由是否都有权限声明。