verify-medtech-theme.mjs 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import { readFileSync, readdirSync, statSync } from 'node:fs'
  2. import { join, relative } from 'node:path'
  3. const root = new URL('..', import.meta.url).pathname
  4. const src = join(root, 'src')
  5. const requiredFiles = [
  6. 'src/styles/theme.css',
  7. 'src/styles/components.css',
  8. 'src/styles/element-plus.css'
  9. ]
  10. const requiredTokens = [
  11. { re: /--color-brand:\s+#2b1f99/, desc: '--color-brand: #2b1f99' },
  12. { re: /--color-accent:\s+#3ad4d8/, desc: '--color-accent: #3ad4d8' },
  13. { re: /--color-bg:/, desc: '--color-bg:' },
  14. { re: /--color-surface:/, desc: '--color-surface:' },
  15. { re: /--color-border:/, desc: '--color-border:' },
  16. { re: /--color-text:/, desc: '--color-text:' },
  17. { re: /--radius-xs:\s+4px/, desc: '--radius-xs: 4px' },
  18. { re: /--radius-sm:\s+8px/, desc: '--radius-sm: 8px' },
  19. { re: /--radius-md:\s+16px/, desc: '--radius-md: 16px' },
  20. { re: /--radius-pill:\s+9999px/, desc: '--radius-pill: 9999px' },
  21. { re: /--shadow-panel:\s+none/, desc: '--shadow-panel: none' }
  22. ]
  23. const bannedEmoji = /[📋📌⚡⏳✅❌🎯🚀💡🔥💬]/u
  24. const bannedDecor = /(radial-gradient|gradient orb|bokeh|blur\(60px\))/
  25. const bannedTextIcon = /[←→↑↓]/
  26. const rawHexColor = /#[0-9a-fA-F]{3,8}\b/
  27. const bannedLegacyPatterns = [
  28. { re: /page-hero/, msg: '禁止 page-hero(改用 data-toolbar)' },
  29. { re: /section-badge/, msg: '禁止 section-badge 胶囊徽章' },
  30. { re: /grade-ring/, msg: '禁止 grade-ring 圆环' },
  31. { re: /score-hero/, msg: '禁止 score-hero 渐变区' },
  32. { re: /hint-card/, msg: '禁止 hint-card 大引导卡' },
  33. { re: /conic-gradient/, msg: '禁止 conic-gradient' }
  34. ]
  35. const migratedMarkers = [
  36. 'clinical-shell',
  37. 'internal-login',
  38. 'period-workbench',
  39. 'period-status-strip',
  40. 'okr-workbench',
  41. 'alignment-workbench',
  42. 'team-okr-workbench',
  43. 'notification-workbench',
  44. 'review-workbench',
  45. 'scoring-worklist',
  46. 'result-workbench',
  47. 'history-workbench',
  48. 'feedback-workbench',
  49. 'org-workbench',
  50. 'log-workbench',
  51. 'dimension-workbench',
  52. 'template-workbench'
  53. ]
  54. function walk(dir) {
  55. return readdirSync(dir).flatMap((name) => {
  56. const path = join(dir, name)
  57. return statSync(path).isDirectory() ? walk(path) : [path]
  58. })
  59. }
  60. const failures = []
  61. for (const file of requiredFiles) {
  62. try {
  63. readFileSync(join(root, file), 'utf8')
  64. } catch {
  65. failures.push(`missing required style file: ${file}`)
  66. }
  67. }
  68. const themePath = join(root, 'src/styles/theme.css')
  69. let theme = ''
  70. try {
  71. theme = readFileSync(themePath, 'utf8')
  72. } catch {}
  73. for (const tok of requiredTokens) {
  74. if (!tok.re.test(theme)) failures.push(`missing theme token: ${tok.desc}`)
  75. }
  76. const main = readFileSync(join(root, 'src/main.js'), 'utf8')
  77. for (const file of requiredFiles) {
  78. const importPath = './' + file.replace('src/', '')
  79. if (!main.includes(importPath)) failures.push(`main.js must import ${importPath}`)
  80. }
  81. const layout = readFileSync(join(root, 'src/components/MainLayout.vue'), 'utf8')
  82. const login = readFileSync(join(root, 'src/views/auth/LoginView.vue'), 'utf8')
  83. const periodList = readFileSync(join(root, 'src/views/period/PeriodListView.vue'), 'utf8')
  84. const periodBanner = readFileSync(join(root, 'src/components/PeriodBanner.vue'), 'utf8')
  85. const myOkr = readFileSync(join(root, 'src/views/okr/MyOkrView.vue'), 'utf8')
  86. const teamOkr = readFileSync(join(root, 'src/views/okr/TeamOkrView.vue'), 'utf8')
  87. const scoringEdit = readFileSync(join(root, 'src/views/scoring/ScoringEditView.vue'), 'utf8')
  88. const scoringList = readFileSync(join(root, 'src/views/scoring/ScoringListView.vue'), 'utf8')
  89. const myResult = readFileSync(join(root, 'src/views/scoring/MyResultView.vue'), 'utf8')
  90. if (!layout.includes('clinical-shell')) failures.push('MainLayout must use clinical-shell')
  91. if (!layout.includes('clinical-sidebar')) failures.push('MainLayout must use clinical-sidebar')
  92. if (!login.includes('internal-login')) failures.push('LoginView must use internal-login')
  93. if (login.includes('radial-gradient')) failures.push('LoginView must not use radial-gradient')
  94. if (!periodList.includes('period-workbench')) failures.push('PeriodListView must use period-workbench')
  95. if (!periodList.includes('period-metrics')) failures.push('PeriodListView must show period-metrics')
  96. if (!periodBanner.includes('period-status-strip')) failures.push('PeriodBanner must use period-status-strip')
  97. if (!myOkr.includes('okr-workbench')) failures.push('MyOkrView must use okr-workbench')
  98. if (!teamOkr.includes('team-okr-workbench')) failures.push('TeamOkrView must use team-okr-workbench')
  99. if (!teamOkr.includes('@update:model-value="selectPeriod"')) failures.push('TeamOkrView period switch must reload subordinate OKRs')
  100. if (!scoringEdit.includes('review-workbench')) failures.push('ScoringEditView must use review-workbench')
  101. if (!scoringList.includes('scoring-worklist')) failures.push('ScoringListView must use scoring-worklist')
  102. // v2 信息架构断言
  103. if (!myOkr.includes('data-toolbar')) failures.push('MyOkrView must use data-toolbar (replaced page-hero)')
  104. if (!myOkr.includes('data-table')) failures.push('MyOkrView must use data-table (table-based O/KR)')
  105. if (!myOkr.includes('side-drawer__content') && !myOkr.includes('el-drawer')) {
  106. failures.push('MyOkrView must use side drawer for KR detail/progress')
  107. }
  108. if (!scoringEdit.includes('compare-grid-3')) failures.push('ScoringEditView must use compare-grid-3 (3-column comparison)')
  109. if (!scoringEdit.includes('section-band')) failures.push('ScoringEditView must use section-band')
  110. if (!scoringEdit.includes('review-summary-bar')) failures.push('ScoringEditView must use review-summary-bar')
  111. if (!myResult.includes('metric-bar')) failures.push('MyResultView must use metric-bar (replaced grade-ring)')
  112. if (!myResult.includes('history-trend')) failures.push('MyResultView must use history-trend')
  113. if (myResult.includes('conic-gradient')) failures.push('MyResultView must not use conic-gradient (grade-ring removed)')
  114. if (!login.includes('login-input')) failures.push('LoginView must use the approved 48px login input')
  115. if (!login.includes('grid-template-columns: minmax(0, 56%) minmax(0, 44%)')) failures.push('LoginView must use the approved 56/44 split')
  116. if (!login.includes('background: #2b1f99')) failures.push('LoginView must use Emoon deep brand color')
  117. if (!login.includes('color: #3ad4d8')) failures.push('LoginView must use Emoon accent color')
  118. if (login.includes('login-panel__header')) failures.push('LoginView must not use the legacy segmented form card')
  119. if (!login.includes('login-brand')) failures.push('LoginView must use login-brand (full-screen split)')
  120. const v2LegacyCheckFiles = new Map([
  121. ['src/components/MainLayout.vue', layout],
  122. ['src/views/auth/LoginView.vue', login],
  123. ['src/views/okr/MyOkrView.vue', myOkr],
  124. ['src/views/scoring/ScoringEditView.vue', scoringEdit],
  125. ['src/views/scoring/MyResultView.vue', myResult]
  126. ])
  127. for (const [file, text] of v2LegacyCheckFiles) {
  128. for (const { re, msg } of bannedLegacyPatterns) {
  129. if (re.test(text)) failures.push(`${file}: ${msg}`)
  130. }
  131. }
  132. const requiredClasses = new Map([
  133. ['src/views/scoring/MyResultView.vue', 'result-workbench'],
  134. ['src/views/scoring/HistoryView.vue', 'history-workbench'],
  135. ['src/views/feedback/FeedbackView.vue', 'feedback-workbench'],
  136. ['src/views/org/OrgView.vue', 'org-workbench'],
  137. ['src/views/system/LogView.vue', 'log-workbench'],
  138. ['src/views/system/DimensionConfigView.vue', 'dimension-workbench'],
  139. ['src/views/period/KpiTemplateView.vue', 'template-workbench'],
  140. ['src/views/okr/AlignmentView.vue', 'alignment-workbench'],
  141. ['src/views/notification/NotificationView.vue', 'notification-workbench']
  142. ])
  143. for (const [file, className] of requiredClasses) {
  144. const text = readFileSync(join(root, file), 'utf8')
  145. if (!text.includes(className)) failures.push(`${file} must use ${className}`)
  146. }
  147. const vueAndCssFiles = walk(src).filter((file) => /\.(vue|css)$/.test(file))
  148. for (const file of vueAndCssFiles) {
  149. const text = readFileSync(file, 'utf8')
  150. const rel = relative(root, file)
  151. const mustFollowMedtechRules = rel.startsWith('src/styles/') || migratedMarkers.some((marker) => text.includes(marker))
  152. const isStyleFile = rel.startsWith('src/styles/')
  153. if (mustFollowMedtechRules && bannedEmoji.test(text)) failures.push(`banned emoji in ${rel}`)
  154. if (mustFollowMedtechRules && bannedDecor.test(text)) failures.push(`banned decorative style in ${rel}`)
  155. if (mustFollowMedtechRules && !isStyleFile && rawHexColor.test(text)) {
  156. const rawColors = text.match(/#[0-9a-fA-F]{3,8}\b/g) || []
  157. const allowedLoginColors = new Set(['#2b1f99', '#3ad4d8'])
  158. const disallowedColors = rel === 'src/views/auth/LoginView.vue'
  159. ? rawColors.filter(color => !allowedLoginColors.has(color.toLowerCase()))
  160. : rawColors
  161. if (disallowedColors.length) failures.push(`raw hex color in migrated file: ${rel}`)
  162. }
  163. if (mustFollowMedtechRules && !isStyleFile && bannedTextIcon.test(text)) failures.push(`text arrow used as icon in ${rel}`)
  164. }
  165. if (failures.length) {
  166. console.error(failures.join('\n'))
  167. process.exit(1)
  168. }
  169. console.log('Open Design theme checks passed')