#!/usr/bin/env node
|
/**
|
* 视觉重做(阶段 7):把散落在业务页里的旧皮肤色值收敛到设计 token。
|
*
|
* 原则(沿用 scripts/migrate/index.mjs 的工程约定):
|
* 1. 模板改动一律走 `@vue/compiler-sfc` 的 AST 属性节点定位(偏移为文件绝对偏移),
|
* 绝不做全文 String.replace —— `:header-cell-style="{...}"` 里的花括号、引号
|
* 一旦被粗暴替换,很容易吃掉相邻属性。
|
* 2. 色值替换只作用于 `<style>` block 文本。`var(--x)` 在 `<script>` 里不是合法
|
* 表达式,所以脚本块一律不碰;模板内联 style 同样不碰(那属于页面级覆写,
|
* 按方案边界留在原地),只把它们计入报告供人工知悉。
|
* 3. 白名单严格制,只认下面 4 组字面量。语义色(#FD8504 警示橙、#67C23A 成功绿、
|
* #F56C6C 危险红、#E6A23C 告警黄)**一个都不碰**。
|
* 4. 默认 dry-run,只打印将要做的改动;加 `--write` 才落盘。
|
*
|
* 用法:
|
* node scripts/theme/index.mjs <file-or-dir...> [--write] [--json] [--only=a,b] [--skip=c]
|
* node scripts/theme/index.mjs src/views --write
|
*/
|
|
import { readFileSync, writeFileSync, statSync, readdirSync } from 'node:fs'
|
import { join, extname, relative } from 'node:path'
|
import { parse } from '@vue/compiler-sfc'
|
|
const ELEMENT = 1
|
const ATTRIBUTE = 6
|
const DIRECTIVE = 7
|
|
// ---------------------------------------------------------------------------
|
// 编辑原语
|
// ---------------------------------------------------------------------------
|
|
function addEdit(edits, start, end, text, kind, note) {
|
edits.push({ start, end, text, kind, note })
|
}
|
|
/** 删除属性,连同前导空白一起删掉,避免留下双空格 */
|
function deleteAttrWithSpace(src, edits, attr, kind) {
|
let s = attr.loc.start.offset
|
while (s > 0 && (src[s - 1] === ' ' || src[s - 1] === '\t')) s--
|
addEdit(edits, s, attr.loc.end.offset, '', kind)
|
}
|
|
/** 反向应用编辑,带区间重叠保护 */
|
function applyEdits(src, edits, file, report) {
|
const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end)
|
let out = src
|
let lastStart = Infinity
|
for (const e of sorted) {
|
if (e.end > lastStart) {
|
const snip = src.slice(e.start, Math.min(e.end, e.start + 60)).replace(/\s+/g, ' ')
|
report.uncertain.push({ file, kind: e.kind, msg: `编辑区间重叠,已跳过:${snip}` })
|
continue
|
}
|
out = out.slice(0, e.start) + e.text + out.slice(e.end)
|
lastStart = e.start
|
}
|
return out
|
}
|
|
function walkElements(ast, cb) {
|
const visit = (node, ancestors) => {
|
if (!node || typeof node !== 'object') return
|
if (node.type === ELEMENT) {
|
cb(node, ancestors)
|
ancestors.push(node)
|
for (const c of node.children || []) visit(c, ancestors)
|
ancestors.pop()
|
return
|
}
|
for (const c of node.children || []) visit(c, ancestors)
|
}
|
visit(ast, [])
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 1:摘除旧表头内联样式
|
// :header-cell-style="{ background: '#f8f8f9', color: '#515a6e' }"
|
//
|
// header-cell-style 在 element-plus 里是**内联样式**,优先级高于任何全局 CSS。
|
// 留着它,theme/element.scss 对表头的皮肤就永远被盖掉;且它写死的 #f8f8f9
|
// 在暗色模式下是一块刺眼的白斑。字面量完全匹配才删(对象里恰好这两个键
|
// 且值恰好是这两个色值),别的自定义表头样式一律不碰。
|
// ---------------------------------------------------------------------------
|
|
const HEADER_STYLE_CANON = "{ background: '#f8f8f9', color: '#515a6e' }"
|
|
/** 把对象字面量归一化成「单空格分隔」再比对,容忍空格/换行差异 */
|
function normalizeExpr(s) {
|
return (s || '').replace(/\s+/g, ' ').trim()
|
}
|
|
/** 键值顺序无关的匹配:{color:'#515a6e',background:'#f8f8f9'} 也算匹配 */
|
function isOldHeaderStyle(expr) {
|
const n = normalizeExpr(expr)
|
if (n === HEADER_STYLE_CANON) return true
|
const body = n.replace(/^\{/, '').replace(/\}$/).trim()
|
if (!body) return false
|
const pairs = body.split(',').map(s => s.trim()).filter(Boolean)
|
if (pairs.length !== 2) return false
|
const got = {}
|
for (const p of pairs) {
|
const mm = /^([A-Za-z_$][\w$]*)\s*:\s*(.*)$/.exec(p)
|
if (!mm) return false
|
got[mm[1]] = mm[2].replace(/['"]/g, '').trim().toLowerCase()
|
}
|
return got.background === '#f8f8f9' && got.color === '#515a6e'
|
}
|
|
function tHeaderStyle(ctx) {
|
const { src, edits, report, file } = ctx
|
if (!ctx.ast) return
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type === DIRECTIVE && p.name === 'bind' && p.arg?.content === 'header-cell-style') {
|
if (isOldHeaderStyle(p.exp?.content)) {
|
deleteAttrWithSpace(src, edits, p, 'headerstyle')
|
} else {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'headerstyle', msg: `自定义 header-cell-style,保留待人工确认:${normalizeExpr(p.exp?.content).slice(0, 70)}` })
|
}
|
continue
|
}
|
if (p.type === ATTRIBUTE && p.name === 'header-cell-style') {
|
const v = p.value && p.value.type === 2 ? p.value.content : null
|
if (v && isOldHeaderStyle(v)) {
|
deleteAttrWithSpace(src, edits, p, 'headerstyle')
|
}
|
}
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 2-4:`<style>` block 内的色值 → token
|
//
|
// 品牌蓝(旧的 RuoYi 主色)→ var(--el-color-primary)
|
// 旧表头灰/字色 → var(--lims-table-head-bg) / var(--lims-text-2)
|
// 旧页面底 → var(--lims-bg)
|
//
|
// 注意 #409eff 是 element-ui 默认主题蓝,源码里常与 #3a7bfa 混用指向同一个
|
// 「主色」语义,一并收敛。
|
// ---------------------------------------------------------------------------
|
|
const COLOR_RULES = [
|
{ name: 'primary', re: /#3a7bfa/gi, to: 'var(--el-color-primary)' },
|
{ name: 'primary', re: /#409eff/gi, to: 'var(--el-color-primary)' },
|
{ name: 'tablehead', re: /#f8f8f9/gi, to: 'var(--lims-table-head-bg)' },
|
{ name: 'tablehead', re: /#515a6e/gi, to: 'var(--lims-text-2)' },
|
{ name: 'pagebg', re: /#f5f7fb/gi, to: 'var(--lims-bg)' }
|
]
|
|
function activeColorRules(opts) {
|
return COLOR_RULES.filter(rule => {
|
if (opts.only && !opts.only.includes(rule.name)) return false
|
if (opts.skip?.includes(rule.name)) return false
|
return true
|
})
|
}
|
|
/** 在 [start,end) 文本区间内替换白名单色值 */
|
function replaceLiterals(ctx, rules, start, end, kind) {
|
const { src, edits } = ctx
|
const text = src.slice(start, end)
|
for (const rule of rules) {
|
rule.re.lastIndex = 0
|
let m
|
while ((m = rule.re.exec(text))) {
|
addEdit(edits, start + m.index, start + m.index + m[0].length, rule.to, kind)
|
ctx.report.colorRules[rule.name] = (ctx.report.colorRules[rule.name] || 0) + 1
|
}
|
}
|
}
|
|
function tStyleColors(ctx) {
|
const rules = activeColorRules(ctx.opts)
|
for (const r of ctx.styleRegions) {
|
replaceLiterals(ctx, rules, r.start.offset, r.end.offset, 'colors')
|
}
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 5:模板内联 style 里的同一批色值
|
//
|
// <span style="color: #3A7BFA;">返回</span>
|
// :style="{ color: '#3A7BFA' }"
|
//
|
// 这批内联色会**直接盖掉**主色(典型是 el-button link type="primary" 上再挂一句
|
// style="color:#3A7BFA",按钮就还是蓝的,紧挨着另一个正确的青绿按钮)。所以必须收。
|
//
|
// 只认 `style` 静态属性与 `:style` 绑定表达式的**值区**:
|
// - 其它 prop 的色值(如 el-radio-group 的 fill="#409EFF")不是 CSS 声明,跳过;
|
// - `<script>` 里的图表配色(ECharts 画布解析不了 CSS 变量)一律不碰。
|
// ---------------------------------------------------------------------------
|
|
function tInlineStyles(ctx) {
|
const rules = activeColorRules(ctx.opts)
|
if (!rules.length || !ctx.ast) return
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type === ATTRIBUTE && p.name === 'style' && p.value && p.value.type === 2) {
|
replaceLiterals(ctx, rules, p.value.loc.start.offset, p.value.loc.end.offset, 'inlinestyle')
|
continue
|
}
|
if (p.type === DIRECTIVE && p.name === 'bind' && p.arg?.content === 'style' && p.exp?.loc) {
|
replaceLiterals(ctx, rules, p.exp.loc.start.offset, p.exp.loc.end.offset, 'inlinestyle')
|
}
|
}
|
})
|
// 非 style 属性上的白名单色值(如 fill="#409EFF")登记备查
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type === ATTRIBUTE && p.name === 'style') continue
|
if (p.type === DIRECTIVE && p.name === 'bind' && p.arg?.content === 'style') continue
|
const region = ctx.src.slice(p.loc.start.offset, p.loc.end.offset)
|
if (/#3a7bfa|#409eff|#f5f7fb/i.test(region)) {
|
ctx.report.uncertain.push({ file: ctx.file, line: node.loc.start.line, kind: 'inlinestyle', msg: `非 style 属性上的旧主色值,需人工确认:${region.replace(/\s+/g, ' ').slice(0, 70)}` })
|
}
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 残留统计:非 `<style>` 区域里的白名单色值(模板内联 style / 脚本里的色值)。
|
// 这些按方案边界不动,但要报出来,避免"零残留"的错觉。
|
// ---------------------------------------------------------------------------
|
|
function countResidual(ctx) {
|
const out = {}
|
const covered = off => ctx.edits.some(e => off >= e.start && off < e.end)
|
for (const rule of COLOR_RULES) {
|
rule.re.lastIndex = 0
|
let m
|
while ((m = rule.re.exec(ctx.src))) {
|
const off = m.index
|
if (ctx.styleRegions.some(r => off >= r.start.offset && off < r.end.offset)) continue
|
if (covered(off)) continue
|
const label = m[0].toLowerCase()
|
out[label] = (out[label] || 0) + 1
|
}
|
}
|
return out
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换注册表
|
// ---------------------------------------------------------------------------
|
|
// `--only` / `--skip` 同时认「变换名」与「色值规则名」(primary / tablehead / pagebg)。
|
const TRANSFORMS = [
|
{ name: 'headerstyle', fn: tHeaderStyle },
|
{ name: 'colors', fn: tStyleColors },
|
{ name: 'inlinestyle', fn: tInlineStyles }
|
]
|
|
// 这两个变换都按「色值规则名」(primary / tablehead / pagebg)细分 --only / --skip,
|
// 只要点了任一条规则,它们就得跑。
|
const AGGREGATE_TRANSFORMS = ['colors', 'inlinestyle']
|
const COLOR_RULE_NAMES = [...new Set(COLOR_RULES.map(r => r.name))]
|
|
function shouldRun(name, opts) {
|
if (AGGREGATE_TRANSFORMS.includes(name)) {
|
if (opts.skip?.includes(name)) return false
|
if (!opts.only) return true
|
return opts.only.includes(name) || COLOR_RULE_NAMES.some(n => opts.only.includes(n))
|
}
|
if (opts.only && !opts.only.includes(name)) return false
|
if (opts.skip?.includes(name)) return false
|
return true
|
}
|
|
// ---------------------------------------------------------------------------
|
// 主流程
|
// ---------------------------------------------------------------------------
|
|
function collectFiles(targets) {
|
const out = []
|
const walk = p => {
|
const st = statSync(p)
|
if (st.isDirectory()) {
|
for (const e of readdirSync(p)) walk(join(p, e))
|
return
|
}
|
const ext = extname(p)
|
if (ext === '.vue' || ext === '.js') out.push(p)
|
}
|
for (const t of targets) walk(t)
|
return out
|
}
|
|
function themeFile(file, opts) {
|
const src = readFileSync(file, 'utf8')
|
const report = { applied: {}, assumed: [], uncertain: [], residual: {}, colorRules: {} }
|
const edits = []
|
let ast = null
|
let styleRegions = []
|
|
if (file.endsWith('.vue')) {
|
try {
|
const r = parse(src, { filename: file })
|
ast = r.descriptor.template?.ast || null
|
styleRegions = r.descriptor.styles.map(s => s.loc).filter(Boolean)
|
} catch (e) {
|
report.uncertain.push({ file, kind: 'parse', msg: `SFC 解析失败:${e.message}` })
|
return { changed: false, report, applied: {} }
|
}
|
}
|
|
const ctx = { src, file, ast, styleRegions, edits, report, opts }
|
|
for (const t of TRANSFORMS) {
|
if (!shouldRun(t.name, opts)) continue
|
const before = edits.length
|
try {
|
t.fn(ctx)
|
} catch (e) {
|
report.uncertain.push({ file, kind: t.name, msg: `变换抛错:${e.message}` })
|
}
|
if (edits.length > before) report.applied[t.name] = (report.applied[t.name] || 0) + (edits.length - before)
|
}
|
|
report.residual = countResidual(ctx)
|
|
const out = applyEdits(src, edits, file, report)
|
const changed = out !== src
|
if (opts.write && changed) writeFileSync(file, out, 'utf8')
|
|
// `colors` / `inlinestyle` 是聚合变换,可读粒度在 colorRules(primary / tablehead / pagebg)
|
const applied = { ...report.colorRules }
|
for (const [k, v] of Object.entries(report.applied)) {
|
if (!AGGREGATE_TRANSFORMS.includes(k)) applied[k] = v
|
}
|
return { changed, report, applied }
|
}
|
|
function main() {
|
const argv = process.argv.slice(2)
|
const opts = { write: false, json: false }
|
const targets = []
|
for (const a of argv) {
|
if (a === '--write') opts.write = true
|
else if (a === '--json') opts.json = true
|
else if (a.startsWith('--only=')) opts.only = a.slice(7).split(',').filter(Boolean)
|
else if (a.startsWith('--skip=')) opts.skip = a.slice(7).split(',').filter(Boolean)
|
else targets.push(a)
|
}
|
if (!targets.length) {
|
console.error('用法:node scripts/theme/index.mjs <file-or-dir...> [--write] [--json] [--only=a,b] [--skip=c]')
|
process.exit(2)
|
}
|
|
const files = collectFiles(targets)
|
const agg = { files: 0, changed: 0, applied: {}, assumed: 0, uncertain: 0, residual: {} }
|
const results = []
|
|
for (const file of files) {
|
const r = themeFile(file, opts)
|
results.push({ file, ...r })
|
agg.files++
|
if (r.changed) agg.changed++
|
for (const [k, v] of Object.entries(r.applied)) agg.applied[k] = (agg.applied[k] || 0) + v
|
agg.assumed += r.report.assumed.length
|
agg.uncertain += r.report.uncertain.length
|
for (const [k, v] of Object.entries(r.report.residual)) agg.residual[k] = (agg.residual[k] || 0) + v
|
}
|
|
if (opts.json) {
|
console.log(JSON.stringify({
|
mode: opts.write ? 'write' : 'dry-run',
|
agg,
|
files: results.map(r => ({
|
file: relative(process.cwd(), r.file),
|
changed: r.changed,
|
applied: r.applied,
|
uncertain: r.report.uncertain,
|
residual: r.report.residual
|
})).filter(f => f.changed || f.uncertain.length)
|
}, null, 2))
|
return
|
}
|
|
for (const r of results) {
|
const uncertain = r.report.uncertain
|
if (!r.changed && !uncertain.length) continue
|
console.log(`\n=== ${relative(process.cwd(), r.file)} ${r.changed ? '' : '(无改动)'}`)
|
const appliedStr = Object.entries(r.applied).map(([k, v]) => `${k}×${v}`).join(' ')
|
if (appliedStr) console.log(` 编辑条数:${appliedStr}`)
|
if (Object.keys(r.report.residual).length) {
|
console.log(` 残留(非 style 区,未动):${Object.entries(r.report.residual).map(([k, v]) => `${k}×${v}`).join(' ')}`)
|
}
|
for (const u of uncertain) console.log(` ⚠ [人工] ${u.line ? 'L' + u.line + ' ' : ''}[${u.kind}] ${u.msg}`)
|
}
|
|
console.log(`\n${opts.write ? '已写入' : 'dry-run(未落盘,加 --write 应用)'}`)
|
console.log(`文件 ${agg.files} 个,其中 ${agg.changed} 个有改动`)
|
console.log(`变换统计(编辑条数):${Object.entries(agg.applied).map(([k, v]) => `${k}×${v}`).join(' ') || '(无)'}`)
|
console.log(`残留(非 style 区,按方案边界保留):${Object.entries(agg.residual).map(([k, v]) => `${k}×${v}`).join(' ') || '(无)'}`)
|
console.log(`[人工] ${agg.uncertain} 条`)
|
}
|
|
main()
|