/** * 深度扫描前端页面按钮(含同目录 components),对比 pageButtonCatalog 输出遗漏 * node scripts/scan-page-buttons-deep.mjs */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); const srcDir = path.join(root, 'src'); const catalogPath = path.join(srcDir, 'constants/pageButtonCatalog.ts'); const reportPath = path.join(root, 'scripts/scan-report.txt'); const SKIP_VIEWS = new Set([ 'login', 'redirect', 'not-found', 'exception', 'result', 'form', 'list', 'wechat', 'h5', 'home/workplace', 'home/monitor', 'track/trackGuide', 'user/profile', 'xhs/agentPair', 'system/permission/button', ]); /** viewKey -> menu permissionCode(generate-menu-excel.js) */ const VIEW_TO_MENU_PARENT = { 'api/upstreamInterface': 'Api.UpstreamInterface', 'api/downstreamInterface': 'Api.DownstreamInterface', 'api/appList': 'Api.AppList', 'api/requestLog': 'Api.RequestLog', 'api/callStatistics': 'Api.CallStatistics', 'api/apiDebugger': 'Api.ApiDebugger', 'api/apiTest': 'Api.ApiTest', 'track/trackOrder': 'Track.Order', 'track/trackWarningOrder': 'Track.WarningOrder', 'track/trackState': 'Track.State', 'track/trackFlag': 'Track.Flag', 'track/trackContent': 'Track.Content', 'track/kpiRegion': 'Track.KpiRegion', 'track/kpiEarlyWarningConfig': 'Track.KpiEarlyWarningConfig', 'order/priceCheck': 'Order.PriceCheck', 'order/placeOrder': 'Order.PlaceOrder', 'order/myOrders': 'Order.MyOrders', 'channel/salesProducts': 'Channel.SalesProducts', 'channel/serviceChannels': 'Channel.ServiceChannels', 'channel/priceArea': 'Channel.PriceArea', 'channel/blacklist': 'Channel.Blacklist', 'channel/remoteZones': 'Channel.RemoteZones', 'channel/weightRules': 'Channel.WeightRules', 'channel/weightRounding': 'Channel.WeightRounding', 'channel/autoTrigger': 'Channel.AutoTrigger', 'channel/issueTemplate': 'Channel.IssueTemplate', 'channel/labelDesign': 'Channel.LabelDesign', 'operation/inboundScan': 'Operation.InboundScan', 'operation/outboundScan': 'Operation.OutboundScan', 'operation/printLabel': 'Operation.PrintLabel', 'user/customer': 'User.Customer', 'user/sub-account': 'User.SubAccount', 'user/recharge/deposit': 'User.Recharge', 'finance/rechargeRecord': 'Finance.RechargeRecord', 'finance/accountFlow': 'Finance.AccountFlow', 'finance/receivablePayable': 'Finance.ReceivablePayable', 'system/permission/menu': 'Rbac.Menus', 'system/permission/role': 'Rbac.Roles', 'system/organization': 'Rbac.Organizations', 'system/organization/user': 'Rbac.Users', 'system/dataPerm/templates': 'Rbac.DataPerm.Template', 'system/dataPerm/audit': 'Rbac.DataPerm.Audit', 'system/dataPerm/users': 'Rbac.DataPerm', 'system/announcement': 'System.Announcement', 'system/dict': 'System.Dict', 'system/warehouseAddress': 'System.WarehouseAddress', 'system/otherConfig': 'System.OtherConfig', 'system/batchEmail': 'System.BatchEmail', 'system/aiAssistant': 'System.Ai', 'system/aiSkills': 'System.AiSkill', 'xhs/startService': 'Xhs.Agent', 'xhs/publishHistory': 'Xhs.Publish', 'xhs/skills': 'Xhs.Skills', 'xhs/humanSettings': 'Xhs.HumanSettings', 'xhs/analysis': 'Xhs.Analysis', 'xhs/creation': 'Xhs.Creation', }; function walk(dir, ext, out = []) { if (!fs.existsSync(dir)) return out; for (const name of fs.readdirSync(dir)) { const full = path.join(dir, name); if (fs.statSync(full).isDirectory()) walk(full, ext, out); else if (full.endsWith(ext)) out.push(full); } return out; } function viewKey(file) { const rel = path.relative(path.join(srcDir, 'views'), file).replace(/\\/g, '/'); return rel.replace(/\/index\.vue$/, ''); } function loadLocaleMap() { const text = fs.readFileSync(path.join(srcDir, 'locale/zh-CN.ts'), 'utf8'); const map = new Map(); const re = /'([^']+)':\s*'((?:\\'|[^'])*)'/g; let m; while ((m = re.exec(text))) { map.set(m[1], m[1].replace(/\\'/g, "'")); map.set(m[1], m[2].replace(/\\'/g, "'")); } return map; } function resolveI18n(raw, localeMap) { const key = raw.trim(); return localeMap.get(key) || key.split('.').pop() || key; } function extractButtonsFromVue(text, localeMap) { const labels = new Set(); const noise = new Set([ 'template', 'div', 'slot', 'icon', 'span', 'KG', 'name', 'save', 'import', 'send', 'track.region.actions.add', '<', '×', '脳', '<', ]); const i18nPatterns = [ /]*>\s*\{\{\s*\$t\(['"]([^'"]+)['"]\)/gi, /]*>\s*\{\{\s*t\(['"]([^'"]+)['"]\)/gi, /]*>\s*\{\{\s*\$t\(['"]([^'"]+)['"]\)/gi, /]*>\s*\{\{\s*t\(['"]([^'"]+)['"]\)/gi, /ok-text="([^"]+)"/gi, /cancel-text="([^"]+)"/gi, /:title="t\(['"]([^'"]+)['"]\)/g, /title="t\(['"]([^'"]+)['"]\)/g, ]; for (const re of i18nPatterns) { let m; while ((m = re.exec(text))) { const label = resolveI18n(m[1], localeMap); if (label && label.length <= 24 && !noise.has(label)) labels.add(label); } } const plainPatterns = [ /]*>([^<{]+)]*>([^<{]+)]*>([^<{]+)<\/button/gi, />([\u4e00-\u9fa5A-Za-z·]{2,20})<\/(?:a-button|button)/g, ]; for (const re of plainPatterns) { let m; while ((m = re.exec(text))) { let label = m[1].trim(); if (!label || label.length > 24) continue; if (/^[\d\s{}/.$]+$/.test(label)) continue; if (noise.has(label)) continue; if (label.startsWith('order.') || label.startsWith('system.') || label.startsWith('channel.')) { label = resolveI18n(label, localeMap); } labels.add(label); } } return [...labels].sort(); } function collectViewVueFiles(viewKeyStr) { const viewDir = path.join(srcDir, 'views', ...viewKeyStr.split('/')); const files = []; const indexFile = path.join(viewDir, 'index.vue'); if (fs.existsSync(indexFile)) files.push(indexFile); const compDir = path.join(viewDir, 'components'); if (fs.existsSync(compDir)) { walk(compDir, '.vue', files); } // sibling vue in view folder (e.g. trackWarningOrder/*.vue) if (fs.existsSync(viewDir) && fs.statSync(viewDir).isDirectory()) { for (const name of fs.readdirSync(viewDir)) { const full = path.join(viewDir, name); if (full.endsWith('.vue') && full !== indexFile) files.push(full); } } return files; } function parseExistingCatalog() { const text = fs.readFileSync(catalogPath, 'utf8'); const parents = new Set(); const parentRe = /B\('([^']+)'/g; let m; while ((m = parentRe.exec(text))) parents.add(m[1]); const buttonNames = new Set(); const nameRe = /name:\s*'([^']+)'/g; while ((m = nameRe.exec(text))) buttonNames.add(m[1]); return { parents, buttonNames }; } const localeMap = loadLocaleMap(); const existing = parseExistingCatalog(); const viewFiles = walk(path.join(srcDir, 'views'), '.vue').filter((f) => { const normalized = f.replace(/\\/g, '/'); if (!normalized.endsWith('/index.vue')) return false; const key = viewKey(f); if (SKIP_VIEWS.has(key)) return false; if (SKIP_VIEWS.has(key.split('/')[0])) return false; return true; }); const report = []; for (const file of viewFiles.sort()) { const key = viewKey(file); if (SKIP_VIEWS.has(key)) continue; const parent = VIEW_TO_MENU_PARENT[key] || null; const vueFiles = collectViewVueFiles(key); const allButtons = new Set(); for (const vf of vueFiles) { const text = fs.readFileSync(vf, 'utf8'); extractButtonsFromVue(text, localeMap).forEach((b) => allButtons.add(b)); } const buttons = [...allButtons].sort(); const inCatalog = parent && existing.parents.has(parent); const missingButtons = buttons.filter((b) => !existing.buttonNames.has(b)); if (!parent || !inCatalog || missingButtons.length > 0) { report.push({ view: key, parent, inCatalog: !!inCatalog, buttons, missingButtons }); } } const lines = []; lines.push('# 深度扫描报告'); lines.push(''); lines.push(`已收录 parent 数: ${existing.parents.size}`); lines.push(`扫描页面数: ${viewFiles.length}`); lines.push(`需补充/遗漏: ${report.length}`); lines.push(''); for (const row of report) { lines.push(`## ${row.view}`); lines.push(` parent: ${row.parent || '(未映射)'}`); lines.push(` inCatalog: ${row.inCatalog}`); lines.push(` 页面按钮: ${row.buttons.join(' | ') || '(无)'}`); if (row.missingButtons.length) { lines.push(` 目录缺失: ${row.missingButtons.join(' | ')}`); } lines.push(''); } const missingParents = [...new Set(report.filter((r) => !r.inCatalog && r.parent).map((r) => r.parent))].sort(); lines.push('# 完全未收录的 parentPermissionCode'); missingParents.forEach((p) => lines.push(` - ${p}`)); const output = lines.join('\n'); fs.writeFileSync(reportPath, `\uFEFF${output}`, 'utf8'); console.log(output);