import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, relative } from 'node:path' const root = new URL('..', import.meta.url).pathname const src = join(root, 'src') const requiredFiles = [ 'src/styles/theme.css', 'src/styles/components.css', 'src/styles/element-plus.css' ] const requiredTokens = [ { re: /--color-brand:\s+#2b1f99/, desc: '--color-brand: #2b1f99' }, { re: /--color-accent:\s+#3ad4d8/, desc: '--color-accent: #3ad4d8' }, { re: /--color-bg:/, desc: '--color-bg:' }, { re: /--color-surface:/, desc: '--color-surface:' }, { re: /--color-border:/, desc: '--color-border:' }, { re: /--color-text:/, desc: '--color-text:' }, { re: /--radius-xs:\s+4px/, desc: '--radius-xs: 4px' }, { re: /--radius-sm:\s+8px/, desc: '--radius-sm: 8px' }, { re: /--radius-md:\s+16px/, desc: '--radius-md: 16px' }, { re: /--radius-pill:\s+9999px/, desc: '--radius-pill: 9999px' }, { re: /--shadow-panel:\s+none/, desc: '--shadow-panel: none' } ] const bannedEmoji = /[📋📌⚡⏳✅❌🎯🚀💡🔥💬]/u const bannedDecor = /(radial-gradient|gradient orb|bokeh|blur\(60px\))/ const bannedTextIcon = /[←→↑↓]/ const rawHexColor = /#[0-9a-fA-F]{3,8}\b/ const bannedLegacyPatterns = [ { re: /page-hero/, msg: '禁止 page-hero(改用 data-toolbar)' }, { re: /section-badge/, msg: '禁止 section-badge 胶囊徽章' }, { re: /grade-ring/, msg: '禁止 grade-ring 圆环' }, { re: /score-hero/, msg: '禁止 score-hero 渐变区' }, { re: /hint-card/, msg: '禁止 hint-card 大引导卡' }, { re: /conic-gradient/, msg: '禁止 conic-gradient' } ] const migratedMarkers = [ 'clinical-shell', 'internal-login', 'period-workbench', 'period-status-strip', 'okr-workbench', 'alignment-workbench', 'team-okr-workbench', 'notification-workbench', 'review-workbench', 'scoring-worklist', 'result-workbench', 'history-workbench', 'feedback-workbench', 'org-workbench', 'log-workbench', 'dimension-workbench', 'template-workbench' ] function walk(dir) { return readdirSync(dir).flatMap((name) => { const path = join(dir, name) return statSync(path).isDirectory() ? walk(path) : [path] }) } const failures = [] for (const file of requiredFiles) { try { readFileSync(join(root, file), 'utf8') } catch { failures.push(`missing required style file: ${file}`) } } const themePath = join(root, 'src/styles/theme.css') let theme = '' try { theme = readFileSync(themePath, 'utf8') } catch {} for (const tok of requiredTokens) { if (!tok.re.test(theme)) failures.push(`missing theme token: ${tok.desc}`) } const main = readFileSync(join(root, 'src/main.js'), 'utf8') for (const file of requiredFiles) { const importPath = './' + file.replace('src/', '') if (!main.includes(importPath)) failures.push(`main.js must import ${importPath}`) } const layout = readFileSync(join(root, 'src/components/MainLayout.vue'), 'utf8') const login = readFileSync(join(root, 'src/views/auth/LoginView.vue'), 'utf8') const periodList = readFileSync(join(root, 'src/views/period/PeriodListView.vue'), 'utf8') const periodBanner = readFileSync(join(root, 'src/components/PeriodBanner.vue'), 'utf8') const myOkr = readFileSync(join(root, 'src/views/okr/MyOkrView.vue'), 'utf8') const teamOkr = readFileSync(join(root, 'src/views/okr/TeamOkrView.vue'), 'utf8') const scoringEdit = readFileSync(join(root, 'src/views/scoring/ScoringEditView.vue'), 'utf8') const scoringList = readFileSync(join(root, 'src/views/scoring/ScoringListView.vue'), 'utf8') const myResult = readFileSync(join(root, 'src/views/scoring/MyResultView.vue'), 'utf8') if (!layout.includes('clinical-shell')) failures.push('MainLayout must use clinical-shell') if (!layout.includes('clinical-sidebar')) failures.push('MainLayout must use clinical-sidebar') if (!login.includes('internal-login')) failures.push('LoginView must use internal-login') if (login.includes('radial-gradient')) failures.push('LoginView must not use radial-gradient') if (!periodList.includes('period-workbench')) failures.push('PeriodListView must use period-workbench') if (!periodList.includes('period-metrics')) failures.push('PeriodListView must show period-metrics') if (!periodBanner.includes('period-status-strip')) failures.push('PeriodBanner must use period-status-strip') if (!myOkr.includes('okr-workbench')) failures.push('MyOkrView must use okr-workbench') if (!teamOkr.includes('team-okr-workbench')) failures.push('TeamOkrView must use team-okr-workbench') if (!teamOkr.includes('@update:model-value="selectPeriod"')) failures.push('TeamOkrView period switch must reload subordinate OKRs') if (!scoringEdit.includes('review-workbench')) failures.push('ScoringEditView must use review-workbench') if (!scoringList.includes('scoring-worklist')) failures.push('ScoringListView must use scoring-worklist') // v2 信息架构断言 if (!myOkr.includes('data-toolbar')) failures.push('MyOkrView must use data-toolbar (replaced page-hero)') if (!myOkr.includes('data-table')) failures.push('MyOkrView must use data-table (table-based O/KR)') if (!myOkr.includes('side-drawer__content') && !myOkr.includes('el-drawer')) { failures.push('MyOkrView must use side drawer for KR detail/progress') } if (!scoringEdit.includes('compare-grid-3')) failures.push('ScoringEditView must use compare-grid-3 (3-column comparison)') if (!scoringEdit.includes('section-band')) failures.push('ScoringEditView must use section-band') if (!scoringEdit.includes('review-summary-bar')) failures.push('ScoringEditView must use review-summary-bar') if (!myResult.includes('metric-bar')) failures.push('MyResultView must use metric-bar (replaced grade-ring)') if (!myResult.includes('history-trend')) failures.push('MyResultView must use history-trend') if (myResult.includes('conic-gradient')) failures.push('MyResultView must not use conic-gradient (grade-ring removed)') if (!login.includes('login-input')) failures.push('LoginView must use the approved 48px login input') if (!login.includes('grid-template-columns: minmax(0, 56%) minmax(0, 44%)')) failures.push('LoginView must use the approved 56/44 split') if (!login.includes('background: #2b1f99')) failures.push('LoginView must use Emoon deep brand color') if (!login.includes('color: #3ad4d8')) failures.push('LoginView must use Emoon accent color') if (login.includes('login-panel__header')) failures.push('LoginView must not use the legacy segmented form card') if (!login.includes('login-brand')) failures.push('LoginView must use login-brand (full-screen split)') const v2LegacyCheckFiles = new Map([ ['src/components/MainLayout.vue', layout], ['src/views/auth/LoginView.vue', login], ['src/views/okr/MyOkrView.vue', myOkr], ['src/views/scoring/ScoringEditView.vue', scoringEdit], ['src/views/scoring/MyResultView.vue', myResult] ]) for (const [file, text] of v2LegacyCheckFiles) { for (const { re, msg } of bannedLegacyPatterns) { if (re.test(text)) failures.push(`${file}: ${msg}`) } } const requiredClasses = new Map([ ['src/views/scoring/MyResultView.vue', 'result-workbench'], ['src/views/scoring/HistoryView.vue', 'history-workbench'], ['src/views/feedback/FeedbackView.vue', 'feedback-workbench'], ['src/views/org/OrgView.vue', 'org-workbench'], ['src/views/system/LogView.vue', 'log-workbench'], ['src/views/system/DimensionConfigView.vue', 'dimension-workbench'], ['src/views/period/KpiTemplateView.vue', 'template-workbench'], ['src/views/okr/AlignmentView.vue', 'alignment-workbench'], ['src/views/notification/NotificationView.vue', 'notification-workbench'] ]) for (const [file, className] of requiredClasses) { const text = readFileSync(join(root, file), 'utf8') if (!text.includes(className)) failures.push(`${file} must use ${className}`) } const vueAndCssFiles = walk(src).filter((file) => /\.(vue|css)$/.test(file)) for (const file of vueAndCssFiles) { const text = readFileSync(file, 'utf8') const rel = relative(root, file) const mustFollowMedtechRules = rel.startsWith('src/styles/') || migratedMarkers.some((marker) => text.includes(marker)) const isStyleFile = rel.startsWith('src/styles/') if (mustFollowMedtechRules && bannedEmoji.test(text)) failures.push(`banned emoji in ${rel}`) if (mustFollowMedtechRules && bannedDecor.test(text)) failures.push(`banned decorative style in ${rel}`) if (mustFollowMedtechRules && !isStyleFile && rawHexColor.test(text)) { const rawColors = text.match(/#[0-9a-fA-F]{3,8}\b/g) || [] const allowedLoginColors = new Set(['#2b1f99', '#3ad4d8']) const disallowedColors = rel === 'src/views/auth/LoginView.vue' ? rawColors.filter(color => !allowedLoginColors.has(color.toLowerCase())) : rawColors if (disallowedColors.length) failures.push(`raw hex color in migrated file: ${rel}`) } if (mustFollowMedtechRules && !isStyleFile && bannedTextIcon.test(text)) failures.push(`text arrow used as icon in ${rel}`) } if (failures.length) { console.error(failures.join('\n')) process.exit(1) } console.log('Open Design theme checks passed')