|
- /**
- * 暗色模式 codemod(一次性脚本,保留以便后续新增页面时复跑)
- *
- * 做两件事:
- * 1. 扫描 app/ 下所有 Tailwind 任意值里的硬编码颜色(#hex / rgba() / bg-white 家族),
- * 生成 app/assets/css/theme.css:亮色变量值 = 原色值(亮色视觉零变化),
- * .dark 下按语义分桶给出暗色取值。
- * 2. 把源码中的硬编码色值替换为 var(--c-xxxxxx) / var(--sh-n)。
- *
- * 用法:node scripts/gen-theme.mjs [--dry]
- * 只处理 <template> 与 <style> 段,避免动到 <script> 里作为数据传给后端的颜色值。
- */
- import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs'
- import { join, dirname } from 'node:path'
- import { fileURLToPath } from 'node:url'
-
- const here = dirname(fileURLToPath(import.meta.url))
- const appDir = join(here, '..', 'app')
- const cssOut = join(appDir, 'assets', 'css', 'theme.css')
- const dry = process.argv.includes('--dry')
-
- /* ---------------- 颜色工具 ---------------- */
- function normHex(h) {
- let s = h.replace('#', '').toLowerCase()
- if (s.length === 3) s = s.split('').map(c => c + c).join('')
- if (s.length === 8) s = s.slice(0, 6)
- return s
- }
- function hexToRgb(hex) {
- const s = normHex(hex)
- return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
- }
- function rgbToHsl([r, g, b]) {
- r /= 255; g /= 255; b /= 255
- const max = Math.max(r, g, b), min = Math.min(r, g, b)
- const l = (max + min) / 2
- let h = 0, s = 0
- const d = max - min
- if (d) {
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
- if (max === r) h = ((g - b) / d + (g < b ? 6 : 0))
- else if (max === g) h = (b - r) / d + 2
- else h = (r - g) / d + 4
- h *= 60
- }
- return { h, s, l, chroma: d }
- }
- function hslToHex(h, s, l) {
- h = ((h % 360) + 360) % 360
- const c = (1 - Math.abs(2 * l - 1)) * s
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
- const m = l - c / 2
- let rgb
- if (h < 60) rgb = [c, x, 0]
- else if (h < 120) rgb = [x, c, 0]
- else if (h < 180) rgb = [0, c, x]
- else if (h < 240) rgb = [0, x, c]
- else if (h < 300) rgb = [x, 0, c]
- else rgb = [c, 0, x]
- return '#' + rgb.map(v => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')
- }
- const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
-
- /**
- * 亮色 -> 暗色。
- *
- * 同一个色值在项目里既可能当文字、当背景、也可能当边框(例如 #172033 既是标题色,
- * 也是证件照页选中态的深底;#e8eef7 主要是卡片描边),因此按
- * 「用法角色 + 彩度 + 亮度」决定映射方向:
- * - text:深色文字提亮,浅灰文字压成暗色下的弱文字;
- * - line(border / ring / divide / outline):亮色下的浅描边必须映射成「比面板更亮」
- * 的线色,否则暗色下边框会和面板同色、直接看不见;
- * - surface:浅色底转深色底;本来就是深色的底(遮罩、深色 hero)原样保留。
- * @param {string} hex 亮色取值
- * @param {'text'|'line'|'surface'} role 该色值在源码中的主要用法
- */
- function darkFor(hex, role = 'surface') {
- const { h, s, l, chroma } = rgbToHsl(hexToRgb(hex))
- const isText = role === 'text'
- const isLine = role === 'line'
-
- // 鲜艳的品牌色 / 语义色:保持色相与识别度
- if (chroma >= 0.45) {
- if (isText && l < 0.62) return hslToHex(h, Math.min(s, 0.95), 0.65)
- if (isLine && l >= 0.85) return hslToHex(h, clamp(s, 0.25, 0.55), 0.42) // 彩色描边要看得见
- if (!isText && !isLine && l >= 0.85) return hslToHex(h, clamp(s, 0.2, 0.55), 0.30)
- return hex
- }
-
- // 带色调的浅底(蓝/绿/橙/红/紫的 tint)
- if (chroma >= 0.055) {
- if (isText) {
- return l < 0.45
- ? hslToHex(h, Math.min(s, 0.20), 0.88)
- : hslToHex(h, Math.min(s, 0.25), 0.62)
- }
- if (isLine) {
- if (l < 0.35) return hex // 本来就是深色描边
- if (l >= 0.90) return hslToHex(h, Math.min(s, 0.22), 0.32) // 极浅描边 -> 可见线色
- if (l >= 0.70) return hslToHex(h, Math.min(s, 0.28), 0.38)
- return hslToHex(h, Math.min(s, 0.32), 0.46)
- }
- if (l < 0.35) return hex
- if (l >= 0.93) return hslToHex(h, Math.min(s, 0.42), 0.16)
- if (l >= 0.85) return hslToHex(h, Math.min(s, 0.45), 0.20)
- if (l >= 0.70) return hslToHex(h, Math.min(s, 0.45), 0.26)
- return hslToHex(h, Math.min(s, 0.40), 0.34)
- }
-
- // 中性灰:暗色下统一到一套固定层级,避免同层级颜色互相不一致
- if (isText) {
- if (l < 0.45) return '#e9eef6' // 标题
- if (l < 0.62) return '#aebdd0' // 正文
- return '#8494a8' // 弱文字
- }
- if (isLine) {
- if (l < 0.35) return hex
- if (l >= 0.90) return '#2f3a49' // 常规描边(比面板亮一档)
- if (l >= 0.75) return '#3a4757' // 稍重的描边
- return '#485768'
- }
- if (l < 0.35) return hex // 已是深底(遮罩等),沿用
- if (l >= 0.99) return '#171e2a' // 面板
- if (l >= 0.955) return '#1b2331' // 次级面板
- if (l >= 0.90) return '#202939'
- if (l >= 0.80) return '#2a3442'
- return '#3a4553'
- }
-
- /** 人工校准的关键层级(覆盖算法结果,保证主要面板/文字层级干净) */
- const CURATED = {
- ffffff: '#171e2a', // 卡片、弹层、页头
- fff: '#171e2a',
- f3f7fb: '#090d13', // 页面底色(与面板拉开对比,无阴影时靠它区分层次)
- f4f7fb: '#090d13',
- f7f9fd: '#1d2632', // 次级面板
- f1f5fa: '#1d2632',
- eef2f7: '#2f3a49', // 也是描边(15 处 border),不能按浅底压暗,否则边线看不见
- e8eef7: '#2f3a49', // 主描边
- eef7ff: '#152a40', // 品牌浅底(选中/hover)
- e0f0ff: '#1b3550',
- // 品牌蓝钉死不变:它既是文字色也是按钮底色,提亮会让按钮上的白字掉到 3:1 以下;
- // 而 #0b8cff 落在暗色面板上本身就有约 5:1 的对比,不需要动。
- '0b8cff': '#0b8cff',
- '1f8cff': '#1f8cff',
- '3aa9ff': '#3aa9ff',
- '0a7ce0': '#0a7ce0',
- '172033': '#e9eef6', // 标题(注意:#0f172a / #0b1324 在本项目里是深色底与遮罩,不在此列)
- '4b5b70': '#aebdd0', // 正文
- '5b6b80': '#a2b2c6',
- '66758a': '#97a7bb',
- '8a97a8': '#8494a8', // 弱文字
- '8a95a6': '#8494a8',
- a0aabb: '#7c8b9f',
- '9aa5b5': '#7c8b9f',
- f04438: '#ff6f63', // 语义-危险
- e5484d: '#ff6f63',
- '17a65a': '#38c884', // 语义-成功
- '159a54': '#38c884',
- e85f1c: '#ff9455', // 语义-警示
- ff681f: '#ff9455',
- '7a5af8': '#a68cff', // 语义-紫
- }
-
- /* ---------------- 需要替换的 Tailwind 具名颜色 ---------------- */
- // 只映射「作为表面/文字层级」的具名色;text-white、bg-white/低透明度等装饰用法保持原样
- const NAMED = {
- 'gray-50': 'f9fafb',
- 'gray-100': 'f3f4f6',
- 'gray-200': 'e5e7eb',
- 'slate-100': 'f1f5f9',
- 'slate-200': 'e2e8f0',
- 'slate-300': 'cbd5e1',
- 'slate-400': '94a3b8',
- 'slate-500': '64748b',
- 'slate-600': '475569',
- 'slate-900': '0f172a',
- }
-
- /* ---------------- 收集文件 ---------------- */
- const files = []
- ;(function walk(dir) {
- for (const name of readdirSync(dir)) {
- const p = join(dir, name)
- if (statSync(p).isDirectory()) walk(p)
- else if (name.endsWith('.vue')) files.push(p)
- }
- })(appDir)
-
- const hexUsed = new Set()
- const shadowColors = new Map() // var 名 -> 亮色 rgba 值
- const roleCount = new Map() // hex -> { text, line, surface }
-
- const TEXT_UTILS = new Set(['text', 'placeholder', 'decoration', 'caret'])
- const LINE_UTILS = new Set(['border', 'ring', 'divide', 'outline'])
-
- function utilRole(util) {
- if (TEXT_UTILS.has(util)) return 'text'
- if (LINE_UTILS.has(util)) return 'line'
- return 'surface'
- }
-
- function addRole(hex, role) {
- if (!roleCount.has(hex)) roleCount.set(hex, { text: 0, line: 0, surface: 0 })
- roleCount.get(hex)[role]++
- }
-
- function countRoles(src) {
- // Tailwind 工具类
- for (const m of src.matchAll(/([a-z]+)-\[var\(--c-([0-9a-f]{6})\)\]/g)) {
- addRole(m[2], utilRole(m[1]))
- }
- // <style> 段里的 CSS 声明(富文本样式等)
- for (const block of src.matchAll(/<style[\s\S]*?<\/style>/g)) {
- for (const m of block[0].matchAll(/(^|[\s;{])color\s*:\s*var\(--c-([0-9a-f]{6})\)/g)) {
- addRole(m[2], 'text')
- }
- for (const m of block[0].matchAll(/(border|outline)[\w-]*\s*:[^;]*var\(--c-([0-9a-f]{6})\)/g)) {
- addRole(m[2], 'line')
- }
- for (const m of block[0].matchAll(/(background|box-shadow|fill|stroke)[\w-]*\s*:[^;]*var\(--c-([0-9a-f]{6})\)/g)) {
- addRole(m[2], 'surface')
- }
- }
- }
-
- /** 取用得最多的角色;数量相同时,line 优先于 surface(描边看不见比底色偏差更刺眼) */
- function roleOf(hex) {
- const rec = roleCount.get(hex)
- if (!rec) return 'surface'
- if (rec.text > rec.line && rec.text > rec.surface) return 'text'
- if (rec.line >= rec.surface && rec.line > 0) return 'line'
- return 'surface'
- }
-
- /**
- * 阴影色 var 名由 rgba 数值直接编码(--sh-r-g-b-a千分位),
- * 这样脚本可重复执行:即使源码里已经是 var(--sh-...),也能还原出亮色取值。
- */
- function shadowVar(raw) {
- const nums = raw.match(/[\d.]+/g) || []
- const [r, g, b] = nums.slice(0, 3).map(Number)
- let a = nums.length > 3 ? Number(nums[3]) : 1
- if (raw.includes('%')) a = a / 100
- const name = `--sh-${r}-${g}-${b}-${Math.round(a * 1000)}`
- shadowColors.set(name, `rgba(${r}, ${g}, ${b}, ${+a.toFixed(3)})`)
- return name
- }
-
- function transform(src) {
- // 已替换过的令牌先登记,保证脚本可重复执行
- for (const m of src.matchAll(/var\(--c-([0-9a-f]{6})\)/g)) hexUsed.add(m[1])
- for (const m of src.matchAll(/var\(--sh-(\d+)-(\d+)-(\d+)-(\d+)\)/g)) {
- shadowColors.set(`--sh-${m[1]}-${m[2]}-${m[3]}-${m[4]}`, `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${+(Number(m[4]) / 1000).toFixed(3)})`)
- }
-
- // 方括号里的 hex 一定是 Tailwind 任意值(含 <script> 里的 class 字符串),全文替换。
- // 复合任意值也覆盖,例如 shadow-[inset_0_0_0_1px_#edf2f7]、bg-[linear-gradient(...,#edf2f8_25%,...)]。
- // 裸写的 '#438edb'(如证件照背景色,会作为数据传给后端)不在方括号里,不会被动到。
- // 注意:hex 后面不能用 \b —— 任意值里的空格被写成下划线(#edf2f8_25%),
- // 下划线是单词字符,\b 不成立会漏改。这里用「后面不是 hex 字符」来收尾。
- let out = src.replace(/\[[^\s"'`\]]*#[0-9a-fA-F]{3,8}[^\s"'`]*?\]/g, token =>
- token.replace(/#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g, (_all, hx) => {
- const key = normHex(hx)
- hexUsed.add(key)
- return `var(--c-${key})`
- }),
- )
-
- // 其余规则(rgba、具名色)跳过 <script> 段,避免误改脚本里作为数据的颜色。
- // 注意:不能用 <template>...</template> 去框定范围 —— 页面里有 <template #slot> 嵌套,
- // 非贪婪匹配会在第一个 </template> 处收尾,导致后半个模板漏改。
- const parts = []
- const re = /<script[\s\S]*?<\/script>/g
- let last = 0
- let m
- while ((m = re.exec(out))) {
- parts.push(['edit', out.slice(last, m.index)])
- parts.push(['keep', m[0]])
- last = m.index + m[0].length
- }
- parts.push(['edit', out.slice(last)])
-
- out = parts.map(([kind, text]) => (kind === 'keep' ? text : editSection(text))).join('')
-
- // <style> 段里的裸 hex(富文本 prose 样式、滚动条等)
- out = out.replace(/<style[\s\S]*?<\/style>/g, block =>
- block.replace(/#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g, (_all, hx) => {
- const key = normHex(hx)
- hexUsed.add(key)
- return `var(--c-${key})`
- }),
- )
-
- return out
- }
-
- function editSection(text) {
- let out = text
-
- // 2) 阴影等复合任意值里的 rgb/rgba
- out = out.replace(/rgba?\([^)]*\)/g, (raw) => {
- // style 段里的 CSS 变量定义本身不处理
- const name = shadowVar(raw.replace(/\s+/g, ' ').trim())
- return `var(${name})`
- })
-
- // 3) 具名中性色(表面/文字层级)
- for (const [named, hx] of Object.entries(NAMED)) {
- const re = new RegExp(`\\b(bg|text|border|ring|from|via|to|divide|placeholder)-${named}\\b(?!/)`, 'g')
- out = out.replace(re, (_a, util) => {
- hexUsed.add(hx)
- return `${util}-[var(--c-${hx})]`
- })
- }
-
- // 4) white:仅表面类用法替换,text-white 与低透明度装饰保持不变
- out = out.replace(/\b(bg|from|via|to|divide)-white\b(?!\/)/g, (_a, util) => {
- hexUsed.add('ffffff')
- return `${util}-[var(--c-ffffff)]`
- })
- out = out.replace(/\b(border|ring)-white\b(?!\/)/g, (_a, util) => {
- hexUsed.add('e8eef7')
- return `${util}-[var(--c-e8eef7)]`
- })
- // bg-white/70 及以上视为面板(半透明毛玻璃面板)
- out = out.replace(/\bbg-white\/(\d{2,3})\b/g, (all, a) => {
- if (Number(a) < 70) return all
- hexUsed.add('ffffff')
- return `bg-[var(--c-ffffff)]/${a}`
- })
-
- return out
- }
-
- /* ---------------- 执行替换 ---------------- */
- let changed = 0
- for (const f of files) {
- const src = readFileSync(f, 'utf8')
- const next = transform(src)
- countRoles(next)
- if (next !== src) {
- changed++
- if (!dry) writeFileSync(f, next, 'utf8')
- }
- }
-
- /* ---------------- 生成 theme.css ---------------- */
- /**
- * 阴影色的暗色取值。
- *
- * 亮色下这些投影是「浅灰蓝 + 很低透明度」,直接搬到暗色底上等于不存在。
- * 处理方式:
- * - 品牌色光晕(按钮、选中态)保留色相并略微加强,暗色下依然是发光效果;
- * - 中性投影换成近黑并显著提高透明度,才能在深色面板上压出层次。
- * 卡片边缘主要靠描边(line 角色)和「面板 / 页面底」的明度差来区分,不指望投影。
- */
- function darkShadow(raw) {
- const nums = raw.match(/[\d.]+/g) || []
- const [r, g, b] = nums.slice(0, 3).map(Number)
- const a = nums.length > 3 ? Number(nums[3]) : 1
- const isBrand = b > 180 && g > 90 && r < 80
- if (isBrand) return `rgba(${r}, ${g}, ${b}, ${Math.min(+(a * 1.3).toFixed(3), 0.5)})`
- return `rgba(0, 0, 0, ${clamp(+(a * 3.2).toFixed(3), 0.28, 0.62)})`
- }
-
- const hexList = [...hexUsed].sort()
- const lines = []
- lines.push('/**')
- lines.push(' * 主题色令牌(由 scripts/gen-theme.mjs 生成,勿手改生成段)')
- lines.push(' *')
- lines.push(' * :root 为亮色,取值与改造前的硬编码色值完全一致 —— 亮色视觉不变。')
- lines.push(' * html.dark 为暗色,按层级(页面底/面板/边框/文字/品牌/语义)分桶取值。')
- lines.push(' * 切换由 @nuxtjs/color-mode(Nuxt UI 内置)在 <html> 上加 .dark 类完成。')
- lines.push(' */')
- lines.push('')
- lines.push('/* Tailwind v4 默认 dark: 走 prefers-color-scheme,这里改为跟随 .dark 类 */')
- lines.push('@custom-variant dark (&:where(.dark, .dark *));')
- lines.push('')
- lines.push(':root {')
- lines.push(' color-scheme: light;')
- const shadowList = [...shadowColors.entries()].sort((a, b) => a[0].localeCompare(b[0]))
- for (const h of hexList) lines.push(` --c-${h}: #${h};`)
- for (const [name, raw] of shadowList) lines.push(` ${name}: ${raw};`)
- lines.push('}')
- lines.push('')
- lines.push('html.dark {')
- lines.push(' color-scheme: dark;')
- for (const h of hexList) lines.push(` --c-${h}: ${CURATED[h] || darkFor('#' + h, roleOf(h))};`)
- for (const [name, raw] of shadowList) lines.push(` ${name}: ${darkShadow(raw)};`)
- lines.push('}')
- lines.push('')
-
- if (!dry) writeFileSync(cssOut, lines.join('\n'), 'utf8')
-
- console.log(`files: ${files.length}, changed: ${changed}`)
- console.log(`hex tokens: ${hexList.length}, shadow tokens: ${shadowColors.size}`)
- console.log(dry ? '(dry run)' : `written: ${cssOut}`)
|