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 @@
>
-
-
-
-
-
+
@@ -104,8 +106,12 @@
+
-
+
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 } })