diff --git a/backend/app/api/pl/quota_cert.py b/backend/app/api/pl/quota_cert.py index 5da4bd085..a126f101e 100644 --- a/backend/app/api/pl/quota_cert.py +++ b/backend/app/api/pl/quota_cert.py @@ -42,11 +42,11 @@ def _require_cert_ready_menu( current_user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): - """证书齐备页面权限。小程序运输证件仍走原入口,不要求单独的 Web 菜单。""" + """证书齐备页面权限。Web 与小程序页签分开授权。""" if current_user.get("role") == "super_admin": return current_user codes = get_user_menu_codes(db, current_user.get("user_id")) - if "pl:cert-ready:list" in codes or "mini:pl:plan-cert:list" in codes: + if "pl:cert-ready:list" in codes or "mini:pl:cert-ready:list" in codes: return current_user raise HTTPException(status_code=403, detail="无菜单访问权限") diff --git a/backend/app/main.py b/backend/app/main.py index 84512d23b..0669ee871 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -185,8 +185,8 @@ app.include_router(crm_router, prefix=settings.API_V1_PREFIX) app.include_router(bx_router, prefix=settings.API_V1_PREFIX) app.include_router(db_router, prefix=settings.API_V1_PREFIX) app.include_router(pl_router, prefix=settings.API_V1_PREFIX) -app.include_router(quota_cert_router, prefix=settings.API_V1_PREFIX) app.include_router(cert_ready_router, prefix=settings.API_V1_PREFIX) +app.include_router(quota_cert_router, prefix=settings.API_V1_PREFIX) app.include_router(legal_use_document_router, prefix=settings.API_V1_PREFIX) app.include_router(vehicle_fuel_card_router, prefix=settings.API_V1_PREFIX) app.include_router(dispatch_calendar_router, prefix=settings.API_V1_PREFIX) diff --git a/backend/tests/test_pl_quota_api.py b/backend/tests/test_pl_quota_api.py index 300fc0e12..0084bca19 100644 --- a/backend/tests/test_pl_quota_api.py +++ b/backend/tests/test_pl_quota_api.py @@ -191,3 +191,21 @@ def test_contract_statistics_route_forwards_number_and_keyword_independently(api assert result.status_code == 200 assert received[0]['keyword'] == '目标' and received[0]['contract_no'] == 'HT' assert client.get('/api/transport/quota-certs/statistics/contracts', params={'contractNo': 'x' * 201}).status_code == 422 + + +def test_cert_ready_list_is_not_captured_by_cert_type_path(): + """证书齐备列表必须先于 /{cert_type}/{cert_id},否则 plans 会被当成证件类型。""" + from starlette.routing import Match + + from app.core.config import settings + from app.main import app + + path = settings.API_V1_PREFIX + "/transport/quota-certs/plans/cert-ready" + scope = {"type": "http", "path": path, "method": "GET", "root_path": "", "headers": []} + for route in app.routes: + match, child = route.matches(scope) + if match == Match.FULL: + assert route.name == "list_cert_ready" + assert child.get("path_params") == {} + return + raise AssertionError("证书齐备列表路由未注册") diff --git a/docs/sql/migrations/2026-11-06-mini-cert-tab-perms.sql b/docs/sql/migrations/2026-11-06-mini-cert-tab-perms.sql new file mode 100644 index 000000000..93cd0166b --- /dev/null +++ b/docs/sql/migrations/2026-11-06-mini-cert-tab-perms.sql @@ -0,0 +1,72 @@ +-- 小程序「运输证件管理」五个页签拆成独立权限。 +-- 已有入口权限的角色默认获得全部页签;销售办证员不获得「证书齐备」,与 Web 端已取消的授权一致。 +-- MySQL 5.7。可重复执行。仅 shanxia2.0。 +SET NAMES utf8mb4; +START TRANSACTION; + +INSERT INTO sys_menu ( + id, menu_name, parent_id, menu_type, path, component, perms, + is_frame, visible, status, tenant_no, order_num, remark, + create_by, create_dept_id, create_time, update_time, del_flag +) +SELECT + item.id, item.menu_name, parent.id, 'F', '', NULL, item.perms, + 1, '1', '1', parent.tenant_no, item.sort_no, '小程序运输证件页签权限', + 'system', '', NOW(), NOW(), 0 +FROM sys_menu parent +JOIN sys_menu folder ON folder.id = parent.parent_id + AND folder.tenant_no = parent.tenant_no AND folder.del_flag = 0 +JOIN sys_menu root ON root.id = folder.parent_id + AND root.tenant_no = parent.tenant_no AND root.del_flag = 0 + AND root.parent_id = '0' AND root.path = '/mini' +CROSS JOIN ( + SELECT 'MINI_PL_CERT_ARCHIVE' id, '证件建档' menu_name, 'mini:pl:quota-cert:list' perms, 1 sort_no + UNION ALL SELECT 'MINI_PL_CERT_CONTRACT', '合同用证', 'mini:pl:contract-quota:list', 2 + UNION ALL SELECT 'MINI_PL_CERT_USAGE', '证件用量', 'mini:pl:certificate-usage:list', 3 + UNION ALL SELECT 'MINI_PL_CERT_SEGMENT', '计划段次用证', 'mini:pl:plan-cert:segment', 4 + UNION ALL SELECT 'MINI_PL_CERT_READY', '证书齐备', 'mini:pl:cert-ready:list', 5 +) item +WHERE parent.id = '329823eb-e3af-49b2-b656-08f2c2a713f3' + AND parent.tenant_no = 'shanxia2.0' AND parent.del_flag = 0 + AND parent.perms = 'mini:pl:plan-cert:list' + AND NOT EXISTS ( + SELECT 1 FROM sys_menu existing + WHERE existing.id = item.id + OR (existing.tenant_no = parent.tenant_no AND existing.del_flag = 0 AND existing.perms = item.perms) + ); + +INSERT INTO sys_role_menu ( + id, role_id, menu_id, tenant_no, order_num, remark, + create_by, create_dept_id, create_time, update_time, del_flag +) +SELECT + MD5(CONCAT(access_row.role_id, '|', item.id, '|mini-cert-tab|v1')), + access_row.role_id, item.id, access_row.tenant_no, 0, + '小程序运输证件页签:沿用原入口授权', + 'system', '', NOW(), NOW(), 0 +FROM sys_role_menu access_row +JOIN sys_role role_row ON role_row.id = access_row.role_id + AND role_row.tenant_no = access_row.tenant_no AND role_row.del_flag = 0 +JOIN sys_menu item ON item.parent_id = '329823eb-e3af-49b2-b656-08f2c2a713f3' + AND item.tenant_no = access_row.tenant_no AND item.del_flag = 0 + AND item.perms IN ( + 'mini:pl:quota-cert:list', + 'mini:pl:contract-quota:list', + 'mini:pl:certificate-usage:list', + 'mini:pl:plan-cert:segment', + 'mini:pl:cert-ready:list' + ) +WHERE access_row.del_flag = 0 + AND access_row.tenant_no = 'shanxia2.0' + AND access_row.menu_id = '329823eb-e3af-49b2-b656-08f2c2a713f3' + AND NOT (item.perms = 'mini:pl:cert-ready:list' AND role_row.role_name = '销售办证员') + AND NOT EXISTS ( + SELECT 1 FROM sys_role_menu existing + WHERE existing.role_id = access_row.role_id + AND existing.menu_id = item.id + AND existing.tenant_no = access_row.tenant_no + AND existing.del_flag = 0 + ) +ON DUPLICATE KEY UPDATE del_flag = 0, update_time = NOW(); + +COMMIT; diff --git a/miniprogram/config/workbench-menus.js b/miniprogram/config/workbench-menus.js index 4cffda040..d43f0e04b 100644 --- a/miniprogram/config/workbench-menus.js +++ b/miniprogram/config/workbench-menus.js @@ -58,7 +58,15 @@ export const WORKBENCH_MODULES = [ enabled: true, code: 'pl-plan-cert-webview', icon: '📑', - perms: 'mini:pl:plan-cert:list' + perms: 'mini:pl:plan-cert:list', + anyPerms: [ + 'mini:pl:plan-cert:list', + 'mini:pl:quota-cert:list', + 'mini:pl:contract-quota:list', + 'mini:pl:certificate-usage:list', + 'mini:pl:plan-cert:segment', + 'mini:pl:cert-ready:list' + ] }, { name: '合法使用证明', diff --git a/miniprogram/utils/workbenchMenuFilter.js b/miniprogram/utils/workbenchMenuFilter.js index 40265dc0b..6c79d053d 100644 --- a/miniprogram/utils/workbenchMenuFilter.js +++ b/miniprogram/utils/workbenchMenuFilter.js @@ -20,6 +20,10 @@ export function filterWorkbenchModules(modules, auth) { const nextMenus = menus.filter((item) => { if (!item || item.enabled === false) return false if (menuAll) return true + const anyPerms = Array.isArray(item.anyPerms) ? item.anyPerms : [] + if (anyPerms.length) { + return anyPerms.some((perm) => codeSet.has(String(perm || '').trim())) + } const p = item.perms if (!p) return false return codeSet.has(String(p)) diff --git a/web-admin/scripts/cert-ready-default.test.ts b/web-admin/scripts/cert-ready-default.test.ts index eef17118d..b7c11f666 100644 --- a/web-admin/scripts/cert-ready-default.test.ts +++ b/web-admin/scripts/cert-ready-default.test.ts @@ -39,6 +39,11 @@ test('web funnel and miniprogram status dropdown default to incomplete', () => { /planDate:\s*\{\s*from:\s*defaultPlanDates\.from,\s*to:\s*defaultPlanDates\.to\s*\}/ ) assert.match(mobile, /certReadyDefaultPlanDates\(\)/) + assert.match(mobile, /mini:pl:quota-cert:list/) + assert.match(mobile, /mini:pl:contract-quota:list/) + assert.match(mobile, /mini:pl:certificate-usage:list/) + assert.match(mobile, /mini:pl:plan-cert:segment/) + assert.match(mobile, /mini:pl:cert-ready:list/) assert.match(mobile, /:plan-date-from="readyDateFrom"/) assert.match( web, diff --git a/web-admin/src/views/m/pl/plan-cert/index.vue b/web-admin/src/views/m/pl/plan-cert/index.vue index ca5ac8766..4ed32305f 100644 --- a/web-admin/src/views/m/pl/plan-cert/index.vue +++ b/web-admin/src/views/m/pl/plan-cert/index.vue @@ -13,6 +13,7 @@ > + - + import MpWebviewShell from '@/layouts/MpWebviewShell.vue' import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue' + import { fetchGetMenuCodes } from '@/api/auth' import { useUserStore } from '@/store/modules/user' import { goToMiniWorkbench } from '@/utils/mp-webview' import { certReadyDefaultPlanDates } from '@/utils/cert-ready-plan-date' @@ -170,12 +181,45 @@ const session = ref(0) const keyword = ref('') const committed = ref('') - const tabs = ['archive', 'contract', 'usage', 'plan', 'ready'] as const - const tab = ref<(typeof tabs)[number]>( - tabs.includes(route.query.tab as (typeof tabs)[number]) - ? (route.query.tab as (typeof tabs)[number]) - : 'archive' + const CERT_TABS = [ + { name: 'archive', title: '证件建档', perm: 'mini:pl:quota-cert:list' }, + { name: 'contract', title: '合同用证', perm: 'mini:pl:contract-quota:list' }, + { name: 'usage', title: '证件用量', perm: 'mini:pl:certificate-usage:list' }, + { name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' }, + { name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' } + ] as const + type CertTabName = (typeof CERT_TABS)[number]['name'] + const tabs = CERT_TABS.map((item) => item.name) + const tabsReady = ref(false) + const allowedTabNames = ref([]) + const visibleTabs = computed(() => + CERT_TABS.filter((item) => allowedTabNames.value.includes(item.name)) ) + const tab = ref( + tabs.includes(route.query.tab as CertTabName) ? (route.query.tab as CertTabName) : 'archive' + ) + + function selectAllowedTab() { + if (!allowedTabNames.value.length) return + if (allowedTabNames.value.includes(tab.value)) return + tab.value = allowedTabNames.value[0] + void router.replace({ query: { ...route.query, tab: tab.value } }) + } + + async function loadTabPermissions() { + const permissions = await fetchGetMenuCodes().catch(() => null) + const allowAll = !!permissions?.all || !!permissions?.isSuperAdmin + const codes = new Set(permissions?.menuCodes || []) + allowedTabNames.value = CERT_TABS.filter((item) => allowAll || codes.has(item.perm)).map( + (item) => item.name + ) + tabsReady.value = true + selectAllowedTab() + } + + watch(ready, (shown) => { + if (shown) void loadTabPermissions() + }) const archiveKind = ref('') const expiringSoon = ref(false) const expiringCount = ref(0) @@ -325,7 +369,7 @@ function onTab(name: string | number) { const next = String(name) - if (!tabs.includes(next as (typeof tabs)[number])) return + if (!tabs.includes(next as CertTabName)) return keyword.value = '' committed.value = '' void router.replace({ query: { ...route.query, tab: next } })