2026-09-23 15:15:02 +08:00

474 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
// Vue2 -> Vue3 一次性批量语法转换(codemod)
// 用法:node scripts/codemods/transform.mjs [--dry]
// 仅做文本级保守转换,转换不了的记录到 scripts/codemods/report.json 供人工处理。
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, URL } from 'node:url';
const ROOT = fileURLToPath(new URL('../..', import.meta.url));
const SRC = path.join(ROOT, 'src');
const DRY = process.argv.includes('--dry');
const FILTER_NAMES = [
'liveReviewStatusFilter',
'liveStatusFilter',
'formatDate',
'broadcastType',
'filterClose',
'broadcastDisplayType',
'filterEmpty',
'userType',
'sourceType',
];
const ASSET_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|mp3|mp4|ttf|woff2?|eot|json)$/i;
const EP_IMPORT_MAP = {
Message: 'ElMessage',
MessageBox: 'ElMessageBox',
Loading: 'ElLoading',
Notification: 'ElNotification',
};
const report = { files: 0, changed: 0, counts: {}, issues: [] };
const issue = (type, file, detail) => {
report.counts[type] = (report.counts[type] || 0) + 1;
report.issues.push({ type, file, detail: String(detail).slice(0, 300) });
};
// ---------------- 通用工具 ----------------
// 提取平衡括号的实参(处理嵌套 () [] {} 与字符串)
function parseCallArgs(src, openIdx) {
let depth = 0;
let quote = null;
const args = [];
let cur = '';
for (let i = openIdx; i < src.length; i++) {
const c = src[i];
if (quote) {
cur += c;
if (c === quote && src[i - 1] !== '\\') quote = null;
continue;
}
if (c === '"' || c === "'" || c === '`') {
quote = c;
cur += c;
continue;
}
if (i === openIdx) {
// 调用自身的起始括号不计入参数
depth = 1;
continue;
}
if (c === '(' || c === '[' || c === '{') {
depth++;
cur += c;
continue;
}
if (c === ')' || c === ']' || c === '}') {
depth--;
if (depth === 0 && c === ')') {
if (cur.trim()) args.push(cur.trim());
return { closeIdx: i, args };
}
cur += c;
continue;
}
if (c === ',' && depth === 1) {
args.push(cur.trim());
cur = '';
continue;
}
cur += c;
}
return null;
}
// ---------------- 模板转换 ----------------
function transformSlotSyntax(tpl, file) {
let out = tpl;
let guard = 0;
let changed = true;
while (changed && guard++ < 20) {
changed = false;
const tagRe = /<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
let m;
while ((m = tagRe.exec(out))) {
const [full, tagName, attrsRaw, selfClose] = m;
const attrStr = attrsRaw + '';
const slotM = attrStr.match(/\sslot="([^"]+)"/);
const scopeM = attrStr.match(/\sslot-scope="([^"]+)"/);
if (!slotM && !scopeM) continue;
let newAttrs = attrStr.replace(/\sslot="[^"]+"/, '').replace(/\sslot-scope="[^"]+"/, '');
let replacement;
if (tagName === 'template') {
const slotName = slotM ? slotM[1] : 'default';
replacement = `<template${newAttrs}${slotName === 'default' && !scopeM ? '' : ` #${slotName}`}${
scopeM ? `="${scopeM[1]}"` : ''
}>`;
} else if (selfClose) {
const slotName = slotM ? slotM[1] : 'default';
replacement = `<template #${slotName}${scopeM ? `="${scopeM[1]}"` : ''}><${tagName}${newAttrs}/></template>`;
} else {
// 需要找匹配的闭合标签并包裹
const closeRe = new RegExp(`<\\/?${tagName}\\b[^>]*>`, 'g');
closeRe.lastIndex = m.index + full.length;
let depth = 1;
let close = null;
let cm;
while ((cm = closeRe.exec(out))) {
const t = cm[0];
if (t.startsWith('</')) {
depth--;
if (depth === 0) {
close = cm;
break;
}
} else if (!t.endsWith('/>')) {
depth++;
}
}
if (!close) {
issue('slot-wrap-failed', file, full);
continue;
}
const slotName = slotM ? slotM[1] : 'default';
const innerStart = m.index + full.length;
const innerEnd = close.index;
const openTag = `<${tagName}${newAttrs}>`;
const closeTag = close[0];
const wrapped =
`<template #${slotName}${scopeM ? `="${scopeM[1]}"` : ''}>` +
openTag +
out.slice(innerStart, innerEnd) +
closeTag +
'</template>';
out = out.slice(0, m.index) + wrapped + out.slice(close.index + close[0].length);
changed = true;
guard++;
break; // 重新扫描(索引已变化)
}
out = out.slice(0, m.index) + replacement + out.slice(m.index + full.length);
changed = true;
break; // 重新扫描
}
}
return out;
}
function transformSync(tpl) {
let out = tpl;
// el-dialog / el-drawer 的 :visible.sync -> v-model(Element Plus 使用 modelValue)
out = out.replace(/<(el-dialog|el-drawer)\b[^>]*>/gs, (tag) =>
tag.replace(/:visible\.sync="([^"]*)"/g, 'v-model="$1"'),
);
// 其余 :xxx.sync="yyy" -> v-model:xxx="yyy"
out = out.replace(/:([\w-]+)\.sync="([^"]*)"/g, 'v-model:$1="$2"');
return out;
}
function transformNative(tpl) {
return tpl.replace(/(@[\w.]+)\.native\b/g, '$1').replace(/v-on:(\w+)\.native\b/g, 'v-on:$1');
}
function transformFiltersInTpl(tpl, file) {
return tpl.replace(/\{\{([\s\S]*?)\}\}/g, (full, inner) => {
if (!/\|/.test(inner)) return full;
// 按顶层单竖线切分(忽略 ||)
const parts = [];
let cur = '';
let quote = null;
let depth = 0;
for (let i = 0; i < inner.length; i++) {
const c = inner[i];
if (quote) {
cur += c;
if (c === quote && inner[i - 1] !== '\\') quote = null;
continue;
}
if (c === '"' || c === "'" || c === '`') {
quote = c;
cur += c;
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
if (c === ')' || c === ']' || c === '}') depth--;
if (c === '|' && depth === 0 && inner[i + 1] !== '|' && inner[i - 1] !== '|') {
parts.push(cur.trim());
cur = '';
continue;
}
cur += c;
}
parts.push(cur.trim());
if (parts.length < 2) return full;
let expr = parts[0];
for (let i = 1; i < parts.length; i++) {
const fname = parts[i];
if (!/^[a-zA-Z_$][\w$]*$/.test(fname)) {
issue('tpl-filter-unknown', file, full);
return full;
}
if (FILTER_NAMES.includes(fname)) {
expr = `$filters.${fname}(${expr})`;
} else {
// 组件局部 filters:转为同名函数直接调用(迁移 script setup 时把 filters 改为普通函数)
issue('tpl-filter-local', file, `${fname} <- ${full.replace(/\n/g, ' ').slice(0, 120)}`);
expr = `${fname}(${expr})`;
}
}
return `{{ ${expr} }}`;
});
}
function transformTplAttrHelpers(tpl, file) {
let out = tpl;
// el-submenu -> el-sub-menu
out = out.replace(/<\/?el-submenu\b/g, (t) => t.replace('el-submenu', 'el-sub-menu'));
// el-link :underline="false" -> underline="never"
out = out.replace(/:underline="false"/g, 'underline="never"');
// 检测项:template v-for 内 key 未上移 / v-for 与 v-if 同标签
const tagRe = /<[a-zA-Z][\w-]*(?:"[^"]*"|'[^']*'|[^>"'])*>/g;
let m;
while ((m = tagRe.exec(out))) {
const t = m[0];
if (/<template\b[^>]*v-for/.test(t)) issue('tpl-template-vfor', file, t);
if (/v-for=/.test(t) && /v-if=/.test(t)) issue('tpl-vfor-vif', file, t);
if (/\benter-class|\bleave-class\b/.test(t)) issue('tpl-transition-class', file, t);
}
return out;
}
// ---------------- 样式转换 ----------------
function transformStyle(css, file) {
let out = css;
// ::v-deep / /deep/ 支持开头与选择器中间位置,逐个逗号分段包裹
out = out
.replace(/(^|[\s,>~+])::v-deep\s*([^{,]+?)\s*(?=[,{])/g, (m, pre, sel) => `${pre}:deep(${sel.trim()})`)
.replace(/(^|[\s,>~+])\/deep\/\s*([^{,]+?)\s*(?=[,{])/g, (m, pre, sel) => `${pre}:deep(${sel.trim()})`);
// webpack tilde 前缀
out = out.replace(/@import\s+["']~/g, "@import '");
const leftover = out.match(/::v-deep|\/deep\//g);
if (leftover) issue('style-deep-leftover', file, `${leftover.length} 处`);
return out;
}
// ---------------- 脚本转换 ----------------
function transformSetDelete(code, file) {
let out = code;
let from = 0;
const inLineComment = (idx) => {
const lineStart = out.lastIndexOf('\n', idx) + 1;
const before = out.slice(lineStart, idx);
return before.includes('//');
};
// 支持 this.$set / that.$set / self.$set 等任意接收者别名
while (true) {
const re = /\b(\w+)\.\$set\(/g;
re.lastIndex = from;
const m = re.exec(out);
if (!m) break;
const idx = m.index;
const callLen = m[0].length;
if (inLineComment(idx)) {
from = idx + callLen;
continue;
}
const parsed = parseCallArgs(out, idx + callLen - 1);
if (!parsed) break;
if (parsed.args.length === 3) {
const rep = `${parsed.args[0]}[${parsed.args[1]}] = ${parsed.args[2]}`;
out = out.slice(0, idx) + rep + out.slice(parsed.closeIdx + 1);
from = idx + rep.length;
} else {
issue('set-args', file, out.slice(idx, parsed.closeIdx + 1));
from = parsed.closeIdx;
}
}
from = 0;
while (true) {
const re = /\b(\w+)\.\$delete\(/g;
re.lastIndex = from;
const m = re.exec(out);
if (!m) break;
const idx = m.index;
const callLen = m[0].length;
if (inLineComment(idx)) {
from = idx + callLen;
continue;
}
const parsed = parseCallArgs(out, idx + callLen - 1);
if (!parsed) break;
if (parsed.args.length === 2) {
const rep = `delete ${parsed.args[0]}[${parsed.args[1]}]`;
out = out.slice(0, idx) + rep + out.slice(parsed.closeIdx + 1);
from = idx + rep.length;
} else {
issue('delete-args', file, out.slice(idx, parsed.closeIdx + 1));
from = parsed.closeIdx;
}
}
return out;
}
function transformLifecycle(code) {
return code.replace(/\bbeforeDestroy\s*\(/g, 'beforeUnmount(').replace(/\bdestroyed\s*\(/g, 'unmounted(');
}
function transformEnv(code) {
return code.replace(/process\.env\.VUE_APP_/g, 'import.meta.env.VITE_APP_');
}
function transformElementUiImport(code, file) {
let out = code;
if (/import\s+'element-ui\/lib\/theme-chalk\/index\.css'/.test(out)) {
out = out.replace(/import\s+'element-ui\/lib\/theme-chalk\/index\.css';?\n?/g, '');
report.counts['el-css-removed'] = (report.counts['el-css-removed'] || 0) + 1;
}
const m = out.match(/import\s+\{([^}]+)\}\s+from\s+'element-ui';/);
if (m) {
const names = m[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const renamed = names.map((n) => EP_IMPORT_MAP[n] || n);
out = out.replace(m[0], `import { ${renamed.join(', ')} } from 'element-plus';`);
names.forEach((old, i) => {
const neu = renamed[i];
if (old !== neu) {
out = out.replace(new RegExp(`\\b${old}\\b`, 'g'), neu);
}
});
report.counts['el-import-fixed'] = (report.counts['el-import-fixed'] || 0) + 1;
}
out = out.replace(/from\s+'element-ui'/g, "from 'element-plus'");
return out;
}
function insertImports(code, importLines) {
if (!importLines.length) return code;
const lines = code.split('\n');
let insertAt = 0;
// 跳过顶部版权注释块
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (t === '' || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/')) {
insertAt = i + 1;
if (t === '' && i > 2) break;
} else break;
}
lines.splice(insertAt, 0, ...importLines, '');
return lines.join('\n');
}
function transformRequireAssets(code, file, isVue) {
const imports = [];
const map = new Map();
let counter = 0;
let out = code.replace(/require\((['"])([^'"]+)\1\)/g, (full, q, p) => {
if (!ASSET_EXT.test(p) || p.includes('${')) return full;
if (!map.has(p)) {
const base = path
.basename(p)
.replace(/\.[^.]+$/, '')
.replace(/[^\w]/g, '_');
const id = `__asset_${base}_${counter++}`;
map.set(p, id);
imports.push(`import ${id} from '${p}';`);
}
report.counts['require->import'] = (report.counts['require->import'] || 0) + 1;
return map.get(p);
});
if (!imports.length) return out;
return insertImports(out, imports);
}
// ---------------- SFC 处理 ----------------
function transformVueFile(code, file) {
let out = code;
// template 块(贪婪匹配第一个 <template> 到最后一个 </template>)
const tplStart = out.search(/<template[^>]*>/);
if (tplStart !== -1) {
const tplEnd = out.lastIndexOf('</template>');
if (tplEnd > tplStart) {
const inner = out.slice(tplStart, tplEnd + '</template>'.length);
let newTpl = inner;
newTpl = transformSlotSyntax(newTpl, file);
newTpl = transformSync(newTpl);
newTpl = transformNative(newTpl);
newTpl = transformFiltersInTpl(newTpl, file);
newTpl = transformTplAttrHelpers(newTpl, file);
const tplRequire = newTpl.match(/require\((['"])[^'"]+\1\)/g);
if (tplRequire) issue('tpl-require', file, `${tplRequire.length} 处(转 script setup 时改为顶部 import)`);
out = out.slice(0, tplStart) + newTpl + out.slice(tplEnd + '</template>'.length);
}
}
// script 块
const scriptRe = /<script([^>]*)>([\s\S]*?)<\/script>/g;
out = out.replace(scriptRe, (full, attrs, body) => {
let nb = body;
nb = transformSetDelete(nb, file);
nb = transformLifecycle(nb);
nb = transformEnv(nb);
nb = transformElementUiImport(nb, file);
nb = transformRequireAssets(nb, file, true);
if (/import\s+Vue\s+from\s+'vue'/.test(nb)) issue('import-vue-default', file, '');
return `<script${attrs}>${nb}</script>`;
});
// style 块
const styleRe = /<style([^>]*)>([\s\S]*?)<\/style>/g;
out = out.replace(styleRe, (full, attrs, body) => `<style${attrs}>${transformStyle(body, file)}</style>`);
return out;
}
function transformJsFile(code, file) {
let out = code;
out = transformSetDelete(out, file);
out = transformLifecycle(out);
out = transformEnv(out);
out = transformElementUiImport(out, file);
out = transformRequireAssets(out, file, false);
if (/import\s+Vue\s+from\s+'vue'/.test(out)) issue('import-vue-default', file, '');
return out;
}
// ---------------- 遍历执行 ----------------
function walk(dir, cb) {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p, cb);
else cb(p);
}
}
walk(SRC, (p) => {
if (!/\.(vue|js)$/.test(p)) return;
const rel = path.relative(ROOT, p);
const code = fs.readFileSync(p, 'utf8');
report.files++;
const out = p.endsWith('.vue') ? transformVueFile(code, rel) : transformJsFile(code, rel);
if (out !== code) {
report.changed++;
if (!DRY) fs.writeFileSync(p, out);
}
});
fs.writeFileSync(path.join(ROOT, 'scripts/codemods/report.json'), JSON.stringify(report, null, 2));
const summary = Object.entries(report.counts)
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
console.log(`扫描 ${report.files} 个文件,改动 ${report.changed} 个${DRY ? '(dry 模式未写入)' : ''}`);
console.log(`转换统计: ${summary || '无'}`);
console.log(`待人工处理 issue: ${report.issues.length} 条,明细见 scripts/codemods/report.json`);
const byType = {};
report.issues.forEach((i) => (byType[i.type] = (byType[i.type] || 0) + 1));
console.log('issue 分布:', JSON.stringify(byType));