吴羡
16 小时以前 1193ed5cf72c8d1093d0c061b9eefe61a28b0cb7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
#!/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()