#!/usr/bin/env node
|
/**
|
* RuoYi-Vue2 → Vue3 机械改造 codemod(阶段2)。
|
*
|
* 原则:
|
* 1. 模板改造一律基于 `@vue/compiler-sfc` 的 AST 属性节点定位(offset 为文件绝对偏移),
|
* 绝不做全文 String.replace —— 否则 `v-slot="scope"` 会被 `slot="` 误匹配。
|
* 2. 拿不准的一律写进报告交人工,不猜。
|
* 3. 默认 dry-run,只打印将要做的改动;加 `--write` 才落盘。
|
*
|
* 用法:
|
* node scripts/migrate/index.mjs <file-or-dir...> [--write] [--json] [--only=a,b] [--skip=c]
|
* node scripts/migrate/index.mjs src/views/business --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
|
|
// ---------------------------------------------------------------------------
|
// element-plus 2.13.1 各组件真实声明的具名插槽(从 node_modules 源码提取,
|
// 提取方式:grep 'renderSlot(_ctx.$slots, "X"' 与 'slots.X')。
|
// 这张表决定 `slot="X"` 能不能安全地包成 `<template #X>`。
|
// ---------------------------------------------------------------------------
|
const EP_SLOTS = {
|
'el-dialog': ['default', 'footer', 'header', 'title'],
|
'el-drawer': ['default', 'footer', 'header', 'title'],
|
'el-card': ['default', 'footer', 'header'],
|
'el-input': ['append', 'prefix', 'prepend', 'suffix'],
|
'el-form-item': ['default', 'error', 'label'],
|
'el-image': ['error', 'placeholder', 'progress', 'toolbar', 'viewer', 'viewer-error'],
|
'el-upload': ['default', 'file', 'tip', 'trigger'],
|
'el-tooltip': ['default', 'content'],
|
'el-popover': ['default', 'reference'],
|
'el-dropdown': ['default', 'dropdown', 'icon'],
|
'el-select': ['default', 'empty', 'footer', 'header', 'label', 'loading', 'prefix', 'tag'],
|
'el-table': ['append', 'content', 'default', 'empty', 'expand', 'filter-icon', 'header'],
|
'el-table-column': ['default', 'header'],
|
'el-input-number': ['decrease-icon', 'increase-icon', 'prefix', 'suffix'],
|
'el-badge': ['content', 'default'],
|
'el-tabs': ['default', 'label'],
|
'el-pagination': ['default'],
|
'el-empty': ['default', 'description', 'image'],
|
'el-result': ['default', 'extra', 'icon', 'sub-title', 'title'],
|
'el-alert': ['default', 'title'],
|
'el-collapse-item': ['default', 'title', 'icon'],
|
'el-autocomplete': ['default', 'footer', 'header', 'loading', 'prefix', 'prepend', 'append', 'suffix'],
|
'el-tree': ['default', 'empty'],
|
'el-tree-select': ['default', 'empty', 'footer', 'header', 'label', 'loading', 'prefix', 'tag'],
|
'el-carousel': ['default', 'indicator'],
|
'el-collapse': ['default'],
|
'el-descriptions-item': ['default', 'label'],
|
'el-radio': ['default'],
|
'el-checkbox': ['default'],
|
'el-switch': ['active-action', 'inactive-action'],
|
'el-tab-pane': ['default', 'label'],
|
'el-breadcrumb-item': ['default'],
|
'el-dropdown-item': ['default'],
|
'el-sub-menu': ['default', 'title', 'icon'],
|
'el-menu-item': ['default', 'icon', 'title'],
|
'el-menu': ['default'],
|
'el-steps': ['default'],
|
'el-step': ['default', 'description', 'icon', 'title'],
|
'el-timeline-item': ['default', 'dot'],
|
'el-transfer': ['default', 'empty', 'footer'],
|
'el-table-v2': ['default', 'empty', 'footer', 'header'],
|
'el-date-picker': ['default'],
|
'el-time-picker': ['default'],
|
'el-cascader': ['default', 'empty', 'prefix', 'suffix'],
|
'el-page-header': ['breadcrumb', 'content', 'default', 'extra', 'icon', 'title'],
|
'el-statistic': ['default', 'suffix', 'title'],
|
'el-avatar': ['default', 'error', 'icon'],
|
'el-skeleton-item': ['default'],
|
'el-skeleton': ['default', 'template'],
|
'el-watermark': ['default'],
|
'el-slider': ['default', 'prefix', 'suffix'],
|
'el-segmented': ['default'],
|
'el-anchor-link': ['default', 'sub-link'],
|
'el-text': ['default']
|
}
|
|
// 覆盖表:EP 对该组合没有插槽,或语义与 element-ui 不同,需要特殊动作。
|
// drop —— element-ui 的该插槽在 EP 已并入默认插槽,属性直接删除(内容仍会渲染)
|
const SLOT_OVERRIDE = {
|
// element-ui 的 el-menu-item 有 title 插槽,EP 只保留默认插槽(默认插槽即标题)
|
'el-menu-item': { title: 'drop' },
|
// EP 的 el-sub-menu 有 title 插槽,保留 wrap;列出仅为显式记录
|
'el-dropdown-item': {},
|
'el-radio': {},
|
'el-checkbox': {}
|
}
|
|
const HTML_TAGS = new Set(
|
('html body base head link meta style title address article aside footer header hgroup main nav section ' +
|
'blockquote dd div dl dt figcaption figure hr li menu ol p pre ul a abbr b bdi bdo br cite code data ' +
|
'dfn em i kbd mark q rp rt ruby s samp small span strong sub sup time u var wbr area audio img map ' +
|
'track video embed iframe object param picture portal source canvas noscript script del ins caption ' +
|
'col colgroup table tbody td tfoot th thead tr button datalist fieldset form input label legend meter ' +
|
'optgroup option output progress select textarea details dialog summary slot template acronym applet ' +
|
'basefont big center dir font frame frameset noframes strike tt xmp text svg circle clippath defs ' +
|
'ellipse g line lineargradient path polygon polyline radialgradient rect stop tspan use symbol ' +
|
'foreignobject marker mask pattern textpath view').split(' ')
|
)
|
|
const TRANSPARENT_TAGS = new Set(['template', 'slot', 'component', 'transition', 'transition-group', 'keep-alive'])
|
|
function isComponentTag(tag) {
|
if (!tag) return false
|
if (TRANSPARENT_TAGS.has(tag)) return false
|
return !HTML_TAGS.has(tag)
|
}
|
|
// ---------------------------------------------------------------------------
|
// 编辑原语
|
// ---------------------------------------------------------------------------
|
|
function addEdit(edits, start, end, text, kind, note) {
|
edits.push({ start, end, text, kind, note })
|
}
|
|
/** 属性值节点(含引号)→ 保留原引号写回新值 */
|
function requote(src, valueNode, newText) {
|
const raw = src.slice(valueNode.loc.start.offset, valueNode.loc.end.offset)
|
const q = raw[0] === '"' || raw[0] === "'" ? raw[0] : ''
|
return q ? q + newText + q : newText
|
}
|
|
/** 反向应用编辑,带区间重叠保护 */
|
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
|
const applied = []
|
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
|
applied.push(e)
|
}
|
return { out, applied }
|
}
|
|
/** 遍历模板 AST,回调每个元素及其祖先链(仅元素节点) */
|
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, [])
|
}
|
|
function staticAttr(node, name) {
|
return (node.props || []).find(p => p.type === ATTRIBUTE && p.name === name)
|
}
|
|
function staticAttrValue(node, name) {
|
const a = staticAttr(node, name)
|
return a && a.value && a.value.type === 2 ? a.value : null
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 1:`.native` 修饰符(Vue3 已移除,事件默认就是组件根上的原生监听)
|
// ---------------------------------------------------------------------------
|
|
function tNative(ctx) {
|
const { src, edits } = ctx
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type !== DIRECTIVE || p.name !== 'on') continue
|
const m = (p.modifiers || []).find(x => x.content === 'native')
|
if (!m) continue
|
// 连同前导 `.` 一起删;若 native 不在末尾(@click.native.stop),结果同样正确
|
addEdit(edits, src[m.loc.start.offset - 1] === '.' ? m.loc.start.offset - 1 : m.loc.start.offset,
|
m.loc.end.offset, '', 'native')
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 2:静态 size="mini|medium"(element-plus 只认 large/default/small)
|
// ---------------------------------------------------------------------------
|
|
function tSize(ctx) {
|
const { src, edits, report, file } = ctx
|
const MAP = { mini: 'small', medium: 'default' }
|
walkElements(ctx.ast, node => {
|
const v = staticAttrValue(node, 'size')
|
if (!v) return
|
const mapped = MAP[v.content]
|
if (!mapped) return
|
addEdit(edits, v.loc.start.offset, v.loc.end.offset, requote(src, v, mapped), 'size')
|
})
|
// 绑定的 :size="'mini'" 无法静态判定其取值,登记备查
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type !== DIRECTIVE || p.name !== 'bind') continue
|
if (p.arg?.content !== 'size') continue
|
if (p.modifiers?.length) continue
|
const c = p.exp?.content || ''
|
if (/mini|medium/.test(c) && /['"]/.test(c)) {
|
report.uncertain.push({ file, kind: 'size', msg: `绑定表达式含 mini/medium,需人工确认:${c}` })
|
}
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 3:`.sync` → `v-model:X`(el-dialog / el-drawer 的 visible → v-model)
|
// ---------------------------------------------------------------------------
|
|
const SYNC_VISIBLE_HOSTS = new Set(['el-dialog', 'el-drawer'])
|
|
function tSync(ctx) {
|
const { edits, report, file } = ctx
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type !== DIRECTIVE || p.name !== 'bind') continue
|
if (!(p.modifiers || []).some(m => m.content === 'sync')) continue
|
|
const arg = p.arg
|
if (!arg || !arg.content) {
|
report.uncertain.push({ file, kind: 'sync', msg: `v-bind.sync 无参数形式,需改写为 Object.assign,原文:${p.rawName}` })
|
continue
|
}
|
if (arg.isStatic === false) {
|
report.uncertain.push({ file, kind: 'sync', msg: `动态参数 .sync(${p.rawName})需人工处理` })
|
continue
|
}
|
// 名字区 = 从属性起 到 最后一个修饰符结束(`:data-x.sync` / `v-bind:data-x.sync`)
|
let nameEnd = arg.loc.end.offset
|
for (const m of p.modifiers) nameEnd = Math.max(nameEnd, m.loc.end.offset)
|
// 原属性名区必须是「前缀 + arg」形态,否则不认(防御)
|
const nameRegion = ctx.src.slice(p.loc.start.offset, nameEnd)
|
if (!/^(:|v-bind:)/.test(nameRegion)) {
|
report.uncertain.push({ file, kind: 'sync', msg: `无法识别的 .sync 形态:${nameRegion}` })
|
continue
|
}
|
const toVisibleModel = arg.content === 'visible' && SYNC_VISIBLE_HOSTS.has(node.tag)
|
addEdit(edits, p.loc.start.offset, nameEnd, toVisibleModel ? 'v-model' : `v-model:${arg.content}`, 'sync')
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 4:slot 重构(本 codemod 最需要小心的一项)
|
//
|
// <el-dialog><span slot="footer"> → <el-dialog><template #footer><span>
|
// <el-table-column><template slot="x"> → <template #x>
|
// <template slot="append"><el-button slot="append"> → 内层属性是残留,删除
|
// <el-menu-item><span slot="title"> → EP 无该插槽,属性删除(内容并入默认插槽)
|
// ---------------------------------------------------------------------------
|
|
function ancestorAction(ctx, node, ancestors, name) {
|
// 从最近的祖先往外走:先看有没有祖先已经声明了同名插槽(说明该属性是冗余残留)
|
for (let i = ancestors.length - 1; i >= 0; i--) {
|
const a = ancestors[i]
|
const av = staticAttrValue(a, 'slot')
|
if (av && av.content === name) return { action: 'drop', reason: `外层 <${a.tag} slot="${name}"> 已声明该插槽,此属性为残留` }
|
if (a.tag === 'template') continue
|
if (isComponentTag(a.tag)) {
|
const ov = SLOT_OVERRIDE[a.tag]?.[name]
|
if (ov) return { action: ov, reason: `EP 的 <${a.tag}> 特例规则` }
|
const list = EP_SLOTS[a.tag]
|
if (list) {
|
return list.includes(name)
|
? { action: 'wrap', reason: `EP 的 <${a.tag}> 声明了 "${name}" 插槽` }
|
: { action: 'report', reason: `EP 的 <${a.tag}> 未声明 "${name}" 插槽(已确认的插槽:${list.join('/')})` }
|
}
|
return { action: 'wrap', assumed: true, reason: `<${a.tag}> 是自定义组件,无法静态判定插槽名,按同名插槽处理` }
|
}
|
// 撞到普通 HTML 元素:Vue2 里 slot 会「穿透」普通元素挂到最近的组件上,Vue3 必须上提。
|
// 往上找最近的真实宿主,写进报告方便人工定位。
|
let host = null
|
for (let k = i - 1; k >= 0; k--) {
|
if (ancestors[k].tag === 'template') continue
|
if (isComponentTag(ancestors[k].tag)) { host = ancestors[k].tag; break }
|
}
|
if (!host) {
|
// 祖先链上根本没有组件宿主:该 slot 属性在 Vue2 里也不生效,
|
// 内容按普通元素就地渲染。Vue3 会把它当成普通 HTML 属性,删掉即可。
|
return { action: 'drop', reason: `祖先链上没有组件宿主,Vue2 里该 slot 属性本就不生效,直接删除` }
|
}
|
return {
|
action: 'report',
|
reason: `slot="${name}" 嵌在普通 <${a.tag}> 内,Vue3 需上提为 <${host}> 的直接子节点`
|
}
|
}
|
return { action: 'report', reason: `slot="${name}" 出现在根节点上,无处可挂` }
|
}
|
|
/** 删除属性,连同前导空白一起删掉,避免留下双空格 */
|
function deleteAttrWithSpace(src, edits, attr, kind = 'slot') {
|
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 tSlot(ctx) {
|
const { src, edits, report, file } = ctx
|
walkElements(ctx.ast, (node, ancestors) => {
|
const attr = staticAttr(node, 'slot')
|
const scopeAttr = staticAttr(node, 'slot-scope')
|
const name = attr && attr.value && attr.value.type === 2 ? attr.value.content : null
|
|
// ---- 1) slot-scope(Vue2 作用域插槽)----
|
if (scopeAttr) {
|
const scopeExpr = scopeAttr.value && scopeAttr.value.type === 2 ? scopeAttr.value.content : null
|
if (scopeExpr == null) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'slot', msg: `slot-scope 无静态值,需人工处理` })
|
return
|
}
|
if (node.tag === 'template') {
|
if (attr && name) {
|
// <template slot="x" slot-scope="y"> → <template #x="y">
|
addEdit(edits, attr.loc.start.offset, attr.loc.end.offset, `#${name}="${scopeExpr}"`, 'slot')
|
deleteAttrWithSpace(src, edits, scopeAttr)
|
} else {
|
// <template slot-scope="y"> → <template #default="y">
|
addEdit(edits, scopeAttr.loc.start.offset, scopeAttr.loc.end.offset, `#default="${scopeExpr}"`, 'slot')
|
}
|
return
|
}
|
// 非 template 承载 slot-scope:必须包一层
|
const slotName = name || 'default'
|
addEdit(edits, node.loc.start.offset, node.loc.start.offset, `<template #${slotName}="${scopeExpr}">`, 'slot')
|
addEdit(edits, node.loc.end.offset, node.loc.end.offset, '</template>', 'slot')
|
deleteAttrWithSpace(src, edits, scopeAttr)
|
if (attr) deleteAttrWithSpace(src, edits, attr)
|
return
|
}
|
|
// ---- 2) 普通 slot 属性 ----
|
if (!attr || !name) return
|
|
// 元素本身是 <template slot="x">:一律重命名为 #x(模板写法唯一,无歧义)
|
if (node.tag === 'template') {
|
addEdit(edits, attr.loc.start.offset, attr.loc.end.offset, `#${name}`, 'slot')
|
return
|
}
|
|
const { action, reason, assumed } = ancestorAction(ctx, node, ancestors, name)
|
if (action === 'wrap') {
|
addEdit(edits, node.loc.start.offset, node.loc.start.offset, `<template #${name}>`, 'slot')
|
addEdit(edits, node.loc.end.offset, node.loc.end.offset, '</template>', 'slot')
|
addEdit(edits, attr.loc.start.offset, attr.loc.end.offset, '', 'slot')
|
if (assumed) report.assumed.push({ file, line: node.loc.start.line, msg: reason })
|
return
|
}
|
if (action === 'drop') {
|
deleteAttrWithSpace(src, edits, attr)
|
report.assumed.push({ file, line: node.loc.start.line, msg: `删除 slot="${name}":${reason}` })
|
return
|
}
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'slot', msg: reason })
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 5:生命周期钩子改名
|
// ---------------------------------------------------------------------------
|
|
const LIFECYCLE = [
|
['beforeDestroy', 'beforeUnmount'],
|
['destroyed', 'unmounted']
|
]
|
|
function tLifecycle(ctx) {
|
for (const r of ctx.scriptRegions) {
|
const text = ctx.src.slice(r.start.offset, r.end.offset)
|
for (const [from, to] of LIFECYCLE) {
|
const re = new RegExp(`\\b${from}\\b`, 'g')
|
let m
|
while ((m = re.exec(text))) {
|
addEdit(ctx.edits, r.start.offset + m.index, r.start.offset + m.index + from.length, to, 'lifecycle')
|
}
|
}
|
}
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 6:process.env.VUE_APP_X → import.meta.env.VITE_APP_X
|
// ---------------------------------------------------------------------------
|
|
function tEnv(ctx) {
|
const { src, edits } = ctx
|
const patterns = [
|
[/\bprocess\.env\.VUE_APP_([A-Za-z0-9_]+)/g, m => `import.meta.env.VITE_APP_${m[1]}`],
|
[/\bprocess\.env\[\s*['"]VUE_APP_([A-Za-z0-9_]+)['"]\s*\]/g, m => `import.meta.env.VITE_APP_${m[1]}`]
|
]
|
for (const [re, build] of patterns) {
|
let m
|
while ((m = re.exec(src))) {
|
if (inStyleRegion(ctx, m.index)) continue
|
addEdit(edits, m.index, m.index + m[0].length, build(m), 'env')
|
}
|
}
|
}
|
|
function inStyleRegion(ctx, offset) {
|
return ctx.styleRegions.some(r => offset >= r.start.offset && offset < r.end.offset)
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 7:this.$set / this.$delete → 直接赋值 / delete
|
// ---------------------------------------------------------------------------
|
|
/** 把 `a, b, c` 按顶层逗号切分,尊重括号/中括号/花括号/字符串/模板串 */
|
function splitTopLevelArgs(s) {
|
const out = []
|
let depth = 0
|
let cur = ''
|
let quote = null
|
for (let i = 0; i < s.length; i++) {
|
const ch = s[i]
|
if (quote) {
|
cur += ch
|
if (ch === '\\') { cur += s[++i] ?? ''; continue }
|
if (ch === quote) quote = null
|
continue
|
}
|
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; cur += ch; continue }
|
if ('([{'.includes(ch)) { depth++; cur += ch; continue }
|
if (')]}'.includes(ch)) { depth--; cur += ch; continue }
|
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue }
|
cur += ch
|
}
|
if (cur.trim() !== '' || out.length) out.push(cur.trim())
|
return out
|
}
|
|
/** 从 open 处的 '(' 起找到配对的 ')',返回两者的下标 */
|
function matchParen(s, open) {
|
let depth = 0
|
let quote = null
|
for (let i = open; i < s.length; i++) {
|
const ch = s[i]
|
if (quote) {
|
if (ch === '\\') { i++; continue }
|
if (ch === quote) quote = null
|
continue
|
}
|
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue }
|
if (ch === '(') depth++
|
else if (ch === ')') { depth--; if (depth === 0) return i }
|
}
|
return -1
|
}
|
|
/** 简单成员表达式无需加括号即可安全地取下标 */
|
const SIMPLE_MEMBER = /^[A-Za-z_$][\w$]*(\s*\.\s*[A-Za-z_$][\w$]*|\s*\[[^\]]*\])*$/
|
|
function memberTarget(expr) {
|
return SIMPLE_MEMBER.test(expr) ? expr : `(${expr})`
|
}
|
|
function tSetDelete(ctx) {
|
const { report, file } = ctx
|
for (const r of ctx.scriptRegions) {
|
const text = ctx.src.slice(r.start.offset, r.end.offset)
|
const re = /this\.\$(set|delete)\s*\(/g
|
let m
|
while ((m = re.exec(text))) {
|
const open = r.start.offset + m.index + m[0].length - 1
|
const close = matchParen(ctx.src, open)
|
if (close < 0) {
|
report.uncertain.push({ file, kind: m[1], msg: `括号不配对,跳过:${m[0]}` })
|
continue
|
}
|
const inner = ctx.src.slice(open + 1, close)
|
const args = splitTopLevelArgs(inner)
|
if (m[1] === 'set') {
|
if (args.length !== 3) {
|
report.uncertain.push({ file, kind: 'set', msg: `$set 参数个数为 ${args.length},需人工处理:${inner.replace(/\s+/g, ' ').slice(0, 80)}` })
|
continue
|
}
|
const [target, key, val] = args
|
addEdit(ctx.edits, r.start.offset + m.index, close + 1, `${memberTarget(target)}[${key}] = ${val}`, 'set')
|
} else {
|
if (args.length !== 2) {
|
report.uncertain.push({ file, kind: 'delete', msg: `$delete 参数个数为 ${args.length},需人工处理:${inner.replace(/\s+/g, ' ').slice(0, 80)}` })
|
continue
|
}
|
const [target, key] = args
|
addEdit(ctx.edits, r.start.offset + m.index, close + 1, `delete ${memberTarget(target)}[${key}]`, 'delete')
|
}
|
// 注:不在这里推 lastIndex —— close 是文件绝对偏移,而 text 是相对切片,
|
// 直接赋值会把游标推到切片之外,导致同一 script 块里后续的 $set 全部漏掉。
|
// 嵌套在参数里的 $set 会各自产生编辑,由重叠保护兜底。
|
}
|
}
|
|
// 非 this.$set 的写法(Vue.set / vm.$set / 裸 $set)登记备查
|
const other = /\b(?:Vue\.\$set|Vue\.\$delete|(?!this\.)\w+\.\$(?:set|delete))\s*\(/g
|
for (const r of ctx.scriptRegions) {
|
const text = ctx.src.slice(r.start.offset, r.end.offset)
|
let m
|
while ((m = other.exec(text))) {
|
const line = text.slice(0, m.index).split('\n').length
|
report.uncertain.push({ file, line, kind: 'set', msg: `非 this.$set 写法,需人工确认:${text.slice(m.index, m.index + 60).replace(/\s+/g, ' ')}` })
|
}
|
}
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 8:<i class="el-icon-x"> → <lims-icon name="el-icon-x"/>
|
// ---------------------------------------------------------------------------
|
|
const ICON_HOST_TAGS = new Set(['i', 'div', 'span'])
|
|
function tIconTag(ctx) {
|
const { src, edits, report, file } = ctx
|
walkElements(ctx.ast, (node, ancestors) => {
|
if (!ICON_HOST_TAGS.has(node.tag)) return
|
const clsAttr = staticAttr(node, 'class')
|
const cls = clsAttr && clsAttr.value && clsAttr.value.type === 2 ? clsAttr.value : null
|
if (!cls) return
|
// class 里可能混着业务类名(如 class="el-icon-caret-left table_caret")。
|
// 把 el-icon-* 那个挑出来做 name,剩下的原样留在 class 上 —— 这样挂在
|
// <i> 上的业务样式(.table_caret 之类)不会被丢掉,也不必人工返工。
|
const tokens = cls.content.trim().split(/\s+/).filter(Boolean)
|
const iconTokens = tokens.filter(t => /^el-icon-[a-z0-9-]+$/.test(t))
|
if (!iconTokens.length) return // 只关心真正带 el-icon 类的;其它 <i>/<div>/<span> 与我们无关
|
if (iconTokens.length > 1) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon', msg: `<${node.tag}> 的 class 含多个 el-icon 类名,无法判定用哪个,需人工处理:class="${cls.content}"` })
|
return
|
}
|
const name = iconTokens[0]
|
const restClasses = tokens.filter(t => t !== name)
|
// 确认是纯 el-icon 类之后,才检查会阻碍替换的因素
|
if ((node.props || []).some(p => p.type === DIRECTIVE && p.arg?.content === 'class')) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon', msg: `带静态 icon 类的同时还有动态 :class,需人工处理:class="${cls.content}"` })
|
return
|
}
|
if ((node.children || []).some(c => c.type === ELEMENT || c.type === 5 || (c.type === 2 && c.content.trim() !== ''))) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon', msg: `<${node.tag} class="${cls.content}"> 内有内容,需人工处理(替换会丢内容)` })
|
return
|
}
|
// 先确认整个标签形态可以安全改写,再落编辑。
|
// 否则只替换开标签会把 <i ...>...</i> 改成一个没有闭合的 <lims-icon>,产出的就是坏标记。
|
let closeStart = -1
|
if (!node.isSelfClosing) {
|
// 闭合标签可能带空白(源码里存在 </i\n > 这种换行写法),用正则定位而不是精确串比较
|
const body = src.slice(node.loc.start.offset, node.loc.end.offset)
|
const cm = /<\/\s*[a-zA-Z][\w-]*\s*>\s*$/.exec(body)
|
if (!cm) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon', msg: `闭合标签形态异常,未能定位 </${node.tag}>,已跳过(避免只改开标签产生坏标记)` })
|
return
|
}
|
closeStart = node.loc.start.offset + cm.index
|
addEdit(edits, closeStart, node.loc.end.offset, '</lims-icon>', 'icon')
|
}
|
addEdit(edits, node.loc.start.offset + 1, node.loc.start.offset + 1 + node.tag.length, 'lims-icon', 'icon')
|
addEdit(edits, node.loc.start.offset + 1 + node.tag.length, node.loc.start.offset + 1 + node.tag.length, ` name="${name}"`, 'icon')
|
if (restClasses.length) {
|
// 只把 el-icon-* 从 class 里摘掉,业务类名原样留着
|
addEdit(edits, cls.loc.start.offset, cls.loc.end.offset, requote(src, cls, restClasses.join(' ')), 'icon')
|
} else {
|
// 整条 class 只剩图标名了,删整个属性(含名字与引号),不是只删值
|
deleteAttrWithSpace(src, edits, clsAttr, 'icon')
|
}
|
if (node.tag !== 'i') {
|
report.assumed.push({ file, line: node.loc.start.line, msg: `<${node.tag} class="${name}"> 也当作图标 → <lims-icon>,请确认它本来就不是布局元素` })
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 9:图标属性名 → $icon 组件
|
// icon="el-icon-x" → :icon="$icon('el-icon-x')"
|
// suffix-icon="el-icon-x" → :suffix-icon="$icon('el-icon-x')"
|
// :icon="a ? 'el-icon-x' : 'el-icon-y'" → :icon="$icon(a ? 'el-icon-x' : 'el-icon-y')"
|
// 仅作用于 el-* 组件;自定义组件的 icon 语义未知,登记备查。
|
// ---------------------------------------------------------------------------
|
|
const ICON_PROPS = new Set(['icon', 'suffix-icon', 'prefix-icon'])
|
|
function tIconAttr(ctx) {
|
const { src, edits, report, file } = ctx
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
// 静态属性
|
if (p.type === ATTRIBUTE && ICON_PROPS.has(p.name) && p.value && p.value.type === 2) {
|
const val = p.value.content
|
if (!/^el-icon-[a-z0-9-]+$/.test(val)) continue
|
if (!node.tag.startsWith('el-')) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon', msg: `自定义组件 <${node.tag}> 的 ${p.name}="${val}",需人工确认` })
|
continue
|
}
|
addEdit(edits, p.loc.start.offset, p.loc.end.offset, `:${p.name}="$icon('${val}')"`, 'icon-attr')
|
continue
|
}
|
// 绑定表达式:只要表达式里出现的字符串字面量**全部**是 el-icon-* 名,
|
// 整体包一层 $icon() 就是安全的(分支取到哪个都是图标名)。
|
if (p.type === DIRECTIVE && p.name === 'bind' && ICON_PROPS.has(p.arg?.content) && p.exp) {
|
const expText = p.exp.content || ''
|
if (!expText.includes('el-icon-')) continue
|
if (expText.includes('$icon(')) continue // 幂等:已转换过就不再套一层
|
const lits = [...expText.matchAll(/(['"])(.*?)\1/g)].map(m => m[2])
|
const allIcons = lits.length > 0 && lits.every(l => /^el-icon-[a-z0-9-]+$/.test(l))
|
if (!node.tag.startsWith('el-')) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon', msg: `自定义组件 <${node.tag}> 的 :${p.arg.content}="${expText}",需人工确认` })
|
continue
|
}
|
if (!allIcons) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon', msg: `:${p.arg.content} 表达式的字面量不全是 el-icon 名,需人工处理:${expText.slice(0, 70)}` })
|
continue
|
}
|
addEdit(edits, p.loc.start.offset, p.loc.end.offset, `:${p.arg.content}="$icon(${expText})"`, 'icon-attr')
|
}
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 10:<style> 内深度选择器 >>> / /deep/ / ::v-deep → :deep(...)
|
// ---------------------------------------------------------------------------
|
|
function tDeep(ctx) {
|
const { src, edits, report, file } = ctx
|
for (const r of ctx.styleRegions) {
|
const text = src.slice(r.start.offset, r.end.offset)
|
const base = r.start.offset
|
const matches = []
|
for (const re of [/::v-deep/g, /\/deep\//g, />{3}/g]) {
|
let m
|
while ((m = re.exec(text))) matches.push({ index: m.index, len: m[0].length })
|
}
|
matches.sort((a, b) => a.index - b.index)
|
let consumedTo = -1
|
for (const mt of matches) {
|
if (mt.index < consumedTo) continue
|
const tokenEnd = mt.index + mt.len
|
// 已在括号内的写法:::v-deep(.a) → :deep(.a)
|
let j = tokenEnd
|
while (text[j] === ' ' || text[j] === '\t') j++
|
if (text[j] === '(') {
|
// 找到配对的 ')'
|
let depth = 0
|
let k = j
|
for (; k < text.length; k++) {
|
if (text[k] === '(') depth++
|
else if (text[k] === ')') { depth--; if (depth === 0) break }
|
}
|
addEdit(edits, base + mt.index, base + tokenEnd, ':deep', 'deep')
|
consumedTo = k + 1
|
continue
|
}
|
// 未加括号:向后捕获「单个」选择器,到 { } ; 或深度 0 的逗号为止。
|
// 必须把逗号也算终止符:否则 `.a >>> .b,\n.a >>> .c {` 会一次性吃掉整串,
|
// 拼出 `:deep(.b, .a >>> .c)` 这种把 `>>>` 和逗号一起裹进去的坏结果,
|
// 而且第一个匹配的 consumedTo 会跳过后面那两个 `>>>`。
|
let k = j
|
let depth = 0
|
let stop = -1
|
for (; k < text.length; k++) {
|
const ch = text[k]
|
if (ch === '(' || ch === '[') depth++
|
else if (ch === ')' || ch === ']') depth--
|
else if (depth === 0 && (ch === '{' || ch === '}' || ch === ';' || ch === ',')) { stop = k; break }
|
}
|
if (stop < 0) {
|
report.uncertain.push({ file, kind: 'deep', msg: `深度选择器「${text.slice(mt.index, tokenEnd)}」后未找到选择器终止符,跳过` })
|
continue
|
}
|
const sel = text.slice(j, stop).replace(/\s+$/, '')
|
if (!sel.trim()) {
|
report.uncertain.push({ file, kind: 'deep', msg: `深度选择器「${text.slice(mt.index, tokenEnd)}」后无选择器,需人工处理` })
|
continue
|
}
|
addEdit(edits, base + mt.index, base + j + sel.length, `:deep(${sel})`, 'deep')
|
consumedTo = stop
|
}
|
}
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 11:日期 token(仅限 el-* 日期组件上的 format / value-format 属性值)
|
// ---------------------------------------------------------------------------
|
|
const DATE_TAGS = new Set(['el-date-picker', 'el-time-picker', 'el-time-select', 'el-datetime-picker', 'el-month-picker'])
|
|
function convertDateTokens(s) {
|
let out = s.replace(/y{4}/g, 'YYYY')
|
// dd → DD,但不能吃掉 ddd / dddd(星期缩写)里的字符
|
out = out.replace(/dd(?![dD])/g, 'DD')
|
return out
|
}
|
|
function tDateToken(ctx) {
|
const { src, edits, report, file } = ctx
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
const isStatic = p.type === ATTRIBUTE && p.name === 'format'
|
const isBound = p.type === DIRECTIVE && p.name === 'bind' &&
|
(p.arg?.content === 'format' || p.arg?.content === 'value-format') && !p.modifiers?.length
|
const isStaticVF = p.type === ATTRIBUTE && p.name === 'value-format'
|
if (!isStatic && !isStaticVF && !isBound) continue
|
|
const rawRegion = src.slice(p.loc.start.offset, p.loc.end.offset)
|
const hasToken = /y{2,}|d{2}/.test(rawRegion)
|
if (!hasToken) continue
|
|
// 绑定的动态表达式(拼接/模板串)无法静态改写
|
const dynamic = isBound && (/[+`]|\$\{/.test(p.exp?.content || '') || !/^['"]/.test(p.exp?.content || ''))
|
if (dynamic) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'date', msg: `动态 format 表达式,需人工改写日期 token:${p.exp.content.slice(0, 60)}` })
|
continue
|
}
|
if (!DATE_TAGS.has(node.tag)) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'date', msg: `<${node.tag}> 上的 ${p.name} 含 yyyy/dd,需人工确认是否日期 token:${rawRegion.slice(0, 70)}` })
|
continue
|
}
|
const newRegion = convertDateTokens(rawRegion)
|
// 先把已处理的 yyyy 摘掉,避免 yy 的负向断言在 yyyy 内部误命中
|
if (/yy|WW/.test(rawRegion.replace(/y{4}/g, ''))) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'date', msg: `含 yy / WW 等需人工判断的 token:${rawRegion.slice(0, 70)}` })
|
}
|
if (newRegion !== rawRegion) {
|
addEdit(edits, p.loc.start.offset, p.loc.end.offset, newRegion, 'date')
|
}
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 检测 12:模板表达式里的 `this.`
|
// Vue2 的模板编译成 `with(this)`,所以 `this.$store` / `this.foo` 都能用;
|
// Vue3 的 SFC 走 prefixIdentifiers,模板里没有 this 绑定,
|
// `this.foo` 会变成 "Cannot read properties of undefined"。
|
// 只报告不自动改:`this.foo` → `foo` 一般等价,但若与 v-for / 插槽作用域变量重名就会改变语义,
|
// 而 `this.$store` / `this.$refs` 需要语义替换(pinia / ref),无法机械处理。
|
// ---------------------------------------------------------------------------
|
|
function tTemplateThis(ctx) {
|
const { report, file } = ctx
|
|
const check = (text, line) => {
|
if (!text) return
|
const re = /\bthis\.(\$?[a-zA-Z_$][\w$]*)/g
|
let m
|
while ((m = re.exec(text))) {
|
const name = m[1]
|
const msg = name.startsWith('$')
|
? `模板表达式里的 this.${name},Vue3 无 this 绑定,需语义改写(如 $store→pinia):${text.trim().slice(0, 60)}`
|
: `模板表达式里的 this.${name},Vue3 无 this 绑定,需改为 ${name}:${text.trim().slice(0, 60)}`
|
report.uncertain.push({ file, line, kind: 'this-in-template', msg })
|
break // 同一处表达式只报一次
|
}
|
}
|
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
// 静态属性值(p.type === ATTRIBUTE)不是表达式,里面的 this. 只是普通文本,不检查
|
if (p.type === DIRECTIVE && p.exp) check(p.exp.content, node.loc.start.line)
|
}
|
for (const c of node.children || []) {
|
if (c.type === 5 && c.content) check(c.content.content, c.loc.start.line)
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 13:el-button 的 type="text" → `link type="primary"`
|
// element-ui 用 type="text" 表示无边框文字按钮;element-plus 已弃用该取值,
|
// 每次渲染打一条 deprecation 警告。阶段 3/4 是靠控制台告警发现漏改的,
|
// 197 处噪声会把真正的告警淹掉。
|
//
|
// 注意替代品不是 `type="link"`:element-plus 2.13 的 buttonTypes 里根本没有
|
// "link"(只有 default/primary/success/warning/info/danger/text/""),写
|
// type="link" 会立刻换来一条 "Invalid prop: validation failed for prop type"。
|
// 官方给的新写法是 link 这个布尔 prop,而单独写 `link` 时颜色取
|
// --el-button-text-color(默认是正文灰 #606266),跟 element-ui 的蓝色不符;
|
// 必须配 type="primary",命中 .el-button--primary.is-link 那条规则才回到
|
// --el-color-primary。所以完整替换成 `link type="primary"`。
|
//
|
// 必须限定在 el-button:type="text" 在 el-input 上是合法取值(login/register
|
// 的用户名框),不能一起改。
|
// ---------------------------------------------------------------------------
|
|
function tButtonType(ctx) {
|
const { edits } = ctx
|
walkElements(ctx.ast, node => {
|
if (node.tag !== 'el-button') return
|
const a = staticAttr(node, 'type')
|
if (!a || !a.value || a.value.type !== 2 || a.value.content !== 'text') return
|
// 源码里出现过 <el-button link type="text">(element-ui 不认 link,等于普通属性),
|
// 此时再补一个 link 就变成 `link link`,编译器直接报 Duplicate attribute。
|
const hasLink = !!staticAttr(node, 'link')
|
addEdit(edits, a.loc.start.offset, a.loc.end.offset, hasLink ? 'type="primary"' : 'link type="primary"', 'buttontype')
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 14:从 'vuex' 引入 mapGetters → 从 '@/utils/store-compat' 引入
|
// 老工程 53 个文件写 `import { mapGetters } from 'vuex'`,调用点全是
|
// `...mapGetters(['nickName'])`。Vue3 换 pinia 后没有 getters 聚合层,
|
// 我们在 utils/store-compat.js 里**沿用 mapGetters 这个名字**补齐了映射。
|
// 所以这里只换 import 的来源字符串,名字与调用点一个字都不用动 —— 这是最小 diff。
|
//
|
// 只处理「具名导入且全是 mapGetters」这一种形态。若同时导入了 mapState /
|
// createStore 之类,说明该文件对 vuex 的依赖更深,机械替换会导致静默 undefined,
|
// 登记人工处理(实测只有 layout 两个文件,已被 V3 模板的 layout 取代)。
|
// ---------------------------------------------------------------------------
|
|
/** 由绝对偏移求行号(scriptRegions 只给偏移,没有 AST 节点带 loc) */
|
function lineAt(src, offset) {
|
let line = 1
|
for (let i = 0; i < offset && i < src.length; i++) {
|
if (src.charCodeAt(i) === 10) line++
|
}
|
return line
|
}
|
|
// 具名导入必须锚在行首:用 `[\s\S]*?` 从任意 import 起匹配会跨过 `} from 'x'`
|
// 一路吃到文件后面真正那句 `from 'vuex'`,把整段源代码当成导入子句捕获进来。
|
// `^[ \t]*import` 保证只从行的开头起算,`\{[^}]*\}` 保证花括号内不越界。
|
const VUEX_NAMED_RE = /^[ \t]*import\s*\{([^}]*)\}\s*from\s*(['"])vuex\2/gm
|
const VUEX_ANY_RE = /from\s*(['"])vuex\1/g
|
|
function tVuex(ctx) {
|
const { src, edits, report, file } = ctx
|
for (const r of ctx.scriptRegions) {
|
const base = r.start.offset
|
const text = src.slice(base, r.end.offset)
|
const covered = new Set()
|
|
let m
|
VUEX_NAMED_RE.lastIndex = 0
|
while ((m = VUEX_NAMED_RE.exec(text))) {
|
const specStart = base + m.index + m[0].length - 6 // m[0] 以 'vuex'(含引号,6 字符)结尾
|
const names = m[1].split(',').map(s => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean)
|
covered.add(specStart)
|
if (!names.length || names.some(n => n !== 'mapGetters')) {
|
report.uncertain.push({ file, line: lineAt(src, base + m.index), kind: 'vuex', msg: `从 'vuex' 的具名导入不止 mapGetters,需人工处理:import {${m[1].trim()}} from 'vuex'` })
|
continue
|
}
|
edits.push({ start: specStart, end: specStart + 6, text: `${m[2]}@/utils/store-compat${m[2]}`, kind: 'vuex' })
|
}
|
|
// 其余形态(默认导入 / 命名空间 / 整体导入)无法机械替代,登记人工
|
VUEX_ANY_RE.lastIndex = 0
|
while ((m = VUEX_ANY_RE.exec(text))) {
|
const specStart = base + m.index + m[0].length - 6
|
if (covered.has(specStart)) continue
|
const stmtStart = Math.max(base, src.lastIndexOf('import', specStart))
|
const snippet = src.slice(stmtStart, specStart + 6).replace(/\s+/g, ' ')
|
report.uncertain.push({ file, line: lineAt(src, specStart), kind: 'vuex', msg: `非「仅 mapGetters 具名导入」的 vuex 引用,需人工处理:${snippet.slice(0, 90)}` })
|
}
|
}
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 15:动态 icon 类 → <lims-icon :name="...">
|
// 源码里有一批树节点图标用模板串拼 class:
|
// <i :class="`node_i ${data.children != undefined ? 'el-icon-folder-opened' : 'el-icon-tickets'}`"></i>
|
// element-plus 不再有字体图标,el-icon-* 类名等于空样式,不改就会整片图标消失。
|
// 旧工程里同样的写法有 7 处(copperOrder/copperView/addOrder/addView/sample×2/personnelInfo),
|
// 且都被 @node-expand / @node-collapse 配合 jQuery 改类名 —— 这段本来就坏的(源码没装 jQuery,
|
// 一展开就抛 $ is not defined),所以必须换成组件式图标。
|
//
|
// 识别条件(都满足才动,避免误伤普通动态 class):
|
// - 单条 `:class` 绑定,值是模板串
|
// - 模板串里恰好一个插值,插值里的字符串字面量**全部**是 el-icon-*
|
// - 模板串的静态部分(插值外的字面量)原样留作 class
|
// ---------------------------------------------------------------------------
|
|
/** 把 `pre${EXPR}post` 拆成 { staticClass, expr };不是这种形态返回 null */
|
function parseIconTemplateLiteral(exp) {
|
const s = exp.trim()
|
if (!s.startsWith('`') || !s.endsWith('`') || s.length < 2) return null
|
const inner = s.slice(1, -1)
|
// 找唯一的 ${ ... },括号要配对(EXPR 里可能有对象字面量/嵌套括号)
|
const open = inner.indexOf('${')
|
if (open < 0) return null
|
if (inner.indexOf('${', open + 2) !== -1) return null // 多个插值,形态复杂,不碰
|
let depth = 0
|
let close = -1
|
for (let i = open + 1; i < inner.length; i++) {
|
const ch = inner[i]
|
if (ch === '{') depth++
|
else if (ch === '}') { depth--; if (depth === 0) { close = i; break } }
|
}
|
if (close < 0) return null
|
const prefix = inner.slice(0, open)
|
const suffix = inner.slice(close + 1)
|
if (suffix.trim()) return null // 插值后面还有静态类名,形态少见,留给人工
|
const expr = inner.slice(open + 2, close).trim()
|
if (!expr) return null
|
// 插值里必须至少有一个 el-icon-* 字面量,否则跟我们无关(可能是普通 class 拼接)
|
const lits = [...expr.matchAll(/(['"])(.*?)\1/g)].map(m => m[2])
|
if (!lits.some(l => /^el-icon-[a-z0-9-]+$/.test(l))) return null
|
// 但插值里**可以**有非图标字面量,只要它不是"类名长相":
|
// copperOrder 里的 `data.code==='[1]'?'el-icon-folder-opened':'el-icon-folder'`
|
// `[1]` 是参与比较的值,不是 class,放行。
|
// 反之像 `cond ? 'el-icon-a' : 'btn-danger'` 这种,btn-danger 是正经 class 名,
|
// 机械替换会把它弄丢,必须拦下来交人工。
|
if (lits.some(l => l.startsWith('el-') && !/^el-icon-[a-z0-9-]+$/.test(l))) return null
|
const classLike = lits.filter(l => /^[a-zA-Z][\w-]*$/.test(l) && !/^el-icon-/.test(l))
|
if (classLike.length) return null
|
return { staticClass: prefix.trim(), expr, looseLits: lits.filter(l => !/^el-icon-/.test(l)) }
|
}
|
|
function tIconClass(ctx) {
|
const { src, edits, report, file } = ctx
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type !== DIRECTIVE || p.name !== 'bind' || p.arg?.content !== 'class' || !p.exp) continue
|
const tl = parseIconTemplateLiteral(p.exp.content)
|
if (!tl) continue
|
// 带子内容的话改名会丢内容,交给人工
|
if ((node.children || []).some(c => c.type === ELEMENT || c.type === 5 || (c.type === 2 && c.content.trim() !== ''))) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon-class', msg: `<${node.tag}> 的动态 icon 类里还有子内容,需人工处理:${p.exp.content.slice(0, 70)}` })
|
continue
|
}
|
const raw = src.slice(p.loc.start.offset, p.loc.end.offset)
|
const q = raw[0] === '"' ? '"' : raw[0] === "'" ? "'" : '"'
|
if (tl.expr.includes(q)) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon-class', msg: `动态 icon 表达式含 ${q},与属性引号冲突,需人工处理:${tl.expr.slice(0, 60)}` })
|
continue
|
}
|
let closeStart = -1
|
if (!node.isSelfClosing) {
|
const body = src.slice(node.loc.start.offset, node.loc.end.offset)
|
const cm = /<\/\s*[a-zA-Z][\w-]*\s*>\s*$/.exec(body)
|
if (!cm) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'icon-class', msg: `闭合标签形态异常,未能定位 </${node.tag}>,已跳过` })
|
continue
|
}
|
closeStart = node.loc.start.offset + cm.index
|
addEdit(edits, closeStart, node.loc.end.offset, '</lims-icon>', 'icon-class')
|
}
|
addEdit(edits, node.loc.start.offset + 1, node.loc.start.offset + 1 + node.tag.length, 'lims-icon', 'icon-class')
|
const attrs = []
|
if (tl.staticClass) attrs.push(`class=${q}${tl.staticClass}${q}`)
|
attrs.push(`:name=${q}${tl.expr}${q}`)
|
addEdit(edits, p.loc.start.offset, p.loc.end.offset, attrs.join(' '), 'icon-class')
|
if (node.tag !== 'i') {
|
report.assumed.push({ file, line: node.loc.start.line, msg: `<${node.tag}> 的动态 el-icon 类也当作图标 → <lims-icon>,请确认它不是布局元素` })
|
}
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 16:el-radio / el-radio-button / el-checkbox / el-checkbox-button 的 label → value
|
//
|
// element-plus 2.13 仍接受 label 当「值」,但同时会打 deprecation 告警
|
// (use-radio/use-checkbox 里 `isGroup && isPropAbsent(value)` 时 warn)。
|
// 而 label 又是默认插槽的兜底文案(element-ui 也是:`!_vm.$slots.default ? label : null`),
|
// 所以只有在「元素本身有插槽内容」时,改名才是纯粹的等价替换 —— 文案仍来自插槽。
|
// 无子节点的那些(label 同时当值又当文案)改名会让文案变空,登记人工。
|
// ---------------------------------------------------------------------------
|
|
const RADIO_LABEL_TAGS = new Set(['el-radio', 'el-radio-button', 'el-checkbox', 'el-checkbox-button'])
|
|
function tRadioLabel(ctx) {
|
const { src, edits, report, file } = ctx
|
walkElements(ctx.ast, node => {
|
if (!RADIO_LABEL_TAGS.has(node.tag)) return
|
const props = node.props || []
|
// 只处理 label;已经有 value 的说明是 EP 的双属性用法(value 才是值,label 只是文案),不动
|
const hasValue = props.some(p =>
|
(p.type === ATTRIBUTE && p.name === 'value') ||
|
(p.type === DIRECTIVE && p.name === 'bind' && p.arg?.content === 'value')
|
)
|
const labelProp = props.find(p =>
|
(p.type === ATTRIBUTE && p.name === 'label') ||
|
(p.type === DIRECTIVE && p.name === 'bind' && p.arg?.content === 'label')
|
)
|
if (!labelProp || hasValue) return
|
|
const hasSlotContent = (node.children || []).some(c =>
|
c.type === ELEMENT || c.type === 5 || (c.type === 2 && c.content.trim() !== '')
|
)
|
if (!hasSlotContent) {
|
report.uncertain.push({
|
file,
|
line: node.loc.start.line,
|
kind: 'radio-label',
|
msg: `<${node.tag}> 无子节点,label 同时充当值与文案,需人工改成 :value + 插槽文案:${src.slice(labelProp.loc.start.offset, labelProp.loc.end.offset).slice(0, 60)}`
|
})
|
return
|
}
|
|
if (labelProp.type === ATTRIBUTE) {
|
addEdit(edits, labelProp.nameLoc.start.offset, labelProp.nameLoc.end.offset, 'value', 'radio-label')
|
return
|
}
|
// `:label="x"` / `v-bind:label="x"`:nameLoc 为空,用正则在原始文本里定位 arg 名
|
const raw = src.slice(labelProp.loc.start.offset, labelProp.loc.end.offset)
|
const m = /^(?::|v-bind\s*:)\s*label\b/.exec(raw)
|
if (!m) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'radio-label', msg: `未能定位 label 属性名:${raw.slice(0, 60)}` })
|
return
|
}
|
const nameStart = labelProp.loc.start.offset + m[0].length - 'label'.length
|
addEdit(edits, nameStart, nameStart + 'label'.length, 'value', 'radio-label')
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 17:el-radio-group / el-checkbox-group 的 @input → @change
|
//
|
// element-ui 的两个 group 组件都会 `$emit('input', val)`,
|
// element-plus 只发 `update:modelValue` 与 `change`(radioEmits 定义里没有 input)。
|
// 不改的话这些 @input 处理器永远不会被调用 —— 是静默的功能丢失,编译器不报错。
|
// ---------------------------------------------------------------------------
|
|
const GROUP_TAGS = new Set(['el-radio-group', 'el-checkbox-group'])
|
|
function tGroupInput(ctx) {
|
const { edits, report, file } = ctx
|
walkElements(ctx.ast, node => {
|
if (!GROUP_TAGS.has(node.tag)) return
|
for (const p of node.props || []) {
|
if (p.type !== DIRECTIVE || p.name !== 'on') continue
|
if (p.arg?.content !== 'input') continue
|
if (!p.arg.loc) {
|
report.uncertain.push({ file, line: node.loc.start.line, kind: 'group-input', msg: `<${node.tag}> 的 @input 参数无位置信息,需人工改成 @change` })
|
continue
|
}
|
addEdit(edits, p.arg.loc.start.offset, p.arg.loc.end.offset, 'change', 'group-input')
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换 18:删除 v-removeAriaHidden
|
//
|
// 这个自定义指令在源码里靠 jQuery / el-radio 的内部 DOM 结构去掉 aria-hidden,
|
// Vue3 工程既没注册它(未注册指令 = 运行时告警 + 完全不生效),
|
// element-plus 的 el-radio 结构也变了、该 a11y bug 已不存在。直接删属性。
|
// ---------------------------------------------------------------------------
|
|
function tRemoveAriaHidden(ctx) {
|
const { src, edits } = ctx
|
walkElements(ctx.ast, node => {
|
for (const p of node.props || []) {
|
if (p.type !== DIRECTIVE) continue
|
if (p.name !== 'removeAriaHidden' && p.name !== 'remove-aria-hidden') continue
|
let start = p.loc.start.offset
|
while (start > 0 && /\s/.test(src[start - 1])) start--
|
addEdit(edits, start, p.loc.end.offset, '', 'aria')
|
}
|
})
|
}
|
|
// ---------------------------------------------------------------------------
|
// 变换注册表
|
// ---------------------------------------------------------------------------
|
|
const TRANSFORMS = [
|
{ name: 'radiolabel', fn: tRadioLabel },
|
{ name: 'groupinput', fn: tGroupInput },
|
{ name: 'removearia', fn: tRemoveAriaHidden },
|
{ name: 'native', fn: tNative },
|
{ name: 'size', fn: tSize },
|
{ name: 'sync', fn: tSync },
|
{ name: 'slot', fn: tSlot },
|
{ name: 'lifecycle', fn: tLifecycle },
|
{ name: 'env', fn: tEnv },
|
{ name: 'setdelete', fn: tSetDelete },
|
{ name: 'icontag', fn: tIconTag },
|
{ name: 'iconclass', fn: tIconClass },
|
{ name: 'iconattr', fn: tIconAttr },
|
{ name: 'deep', fn: tDeep },
|
{ name: 'datetoken', fn: tDateToken },
|
{ name: 'buttontype', fn: tButtonType },
|
{ name: 'vuex', fn: tVuex },
|
{ name: 'templateThis', fn: tTemplateThis }
|
]
|
|
// ---------------------------------------------------------------------------
|
// 主流程
|
// ---------------------------------------------------------------------------
|
|
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 migrateFile(file, opts) {
|
const src = readFileSync(file, 'utf8')
|
const report = { applied: {}, assumed: [], uncertain: [] }
|
const edits = []
|
let ast = null
|
let scriptRegions = []
|
let styleRegions = []
|
|
if (file.endsWith('.vue')) {
|
let descriptor
|
let parseErrors = []
|
try {
|
const r = parse(src, { filename: file })
|
descriptor = r.descriptor
|
parseErrors = r.errors
|
} catch (e) {
|
report.uncertain.push({ file, kind: 'parse', msg: `SFC 解析失败:${e.message}` })
|
return { changed: false, report, src }
|
}
|
if (parseErrors.length) {
|
report.uncertain.push({ file, kind: 'parse', msg: `SFC 解析告警 ${parseErrors.length} 条:${parseErrors[0].message || parseErrors[0]}` })
|
}
|
ast = descriptor.template?.ast || null
|
scriptRegions = [descriptor.script?.loc, descriptor.scriptSetup?.loc].filter(Boolean)
|
styleRegions = descriptor.styles.map(s => s.loc).filter(Boolean)
|
} else {
|
scriptRegions = [{ start: { offset: 0 }, end: { offset: src.length } }]
|
}
|
|
const ctx = { src, file, ast, scriptRegions, styleRegions, edits, report }
|
|
for (const t of TRANSFORMS) {
|
if (opts.only && !opts.only.includes(t.name)) continue
|
if (opts.skip?.includes(t.name)) 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)
|
}
|
|
const { out, applied } = applyEdits(src, edits, file, report)
|
const changed = out !== src
|
if (opts.write && changed) writeFileSync(file, out, 'utf8')
|
return { changed, report, applied, src, out }
|
}
|
|
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/migrate/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 }
|
const results = []
|
|
for (const file of files) {
|
const r = migrateFile(file, opts)
|
results.push({ file, ...r })
|
agg.files++
|
if (r.changed) agg.changed++
|
for (const [k, v] of Object.entries(r.report.applied)) agg.applied[k] = (agg.applied[k] || 0) + v
|
agg.assumed += r.report.assumed.length
|
agg.uncertain += r.report.uncertain.length
|
}
|
|
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.report.applied,
|
assumed: r.report.assumed,
|
uncertain: r.report.uncertain
|
})).filter(f => f.changed || f.assumed.length || f.uncertain.length)
|
}, null, 2))
|
return
|
}
|
|
for (const r of results) {
|
const assumed = r.report.assumed
|
const uncertain = r.report.uncertain
|
if (!r.changed && !assumed.length && !uncertain.length) continue
|
console.log(`\n=== ${relative(process.cwd(), r.file)} ${r.changed ? '' : '(无改动)'}`)
|
const appliedStr = Object.entries(r.report.applied).map(([k, v]) => `${k}×${v}`).join(' ')
|
if (appliedStr) console.log(` 编辑条数:${appliedStr}`)
|
for (const a of assumed) console.log(` · [假定] ${a.line ? 'L' + a.line + ' ' : ''}${a.msg}`)
|
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(`[假定] ${agg.assumed} 条 [人工] ${agg.uncertain} 条`)
|
}
|
|
main()
|