@dongmingzhixiu/vuexir-compiler 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,21 +18,100 @@
18
18
  import { parse } from '@vue/compiler-sfc';
19
19
  import { NodeTypes } from '@vue/compiler-core';
20
20
  import { CssParseError, mergeIrProps, parseCss, parseInlineStyle } from './css.js';
21
+ import { buildStoreRegistry, findStoreAliases, parseSharedConstArrays } from './stores.js';
21
22
  export class VueCompileError extends Error {
22
23
  }
24
+ /** R90 store getter 降级告警(flattenStorePath 无 warnings 参数,先收集后并入) */
25
+ let storeGetterWarnings = [];
26
+ /** R89 派生名表:`all` → `ui_upcoming`(computed 折叠成状态点路径) */
27
+ let derivedNames = new Map();
28
+ /** R88 store 文件导出的常量数组(v-for 编译期内联用) */
29
+ let storeConstArrays = {};
30
+ /** R86 store 别名表:`ui` → StoreDef(每次 vueToIr 重建;与 vforConsts 同风格) */
31
+ let storeAliases = new Map();
32
+ /** R86 把 `alias.field.path` 拍平为 `alias_field.path`;getter/action 返回哨兵串 */
33
+ function flattenStorePath(name) {
34
+ // R89:computed 折叠出的派生名(all → ui_upcoming;all.length → ui_upcoming.length)
35
+ if (derivedNames.size > 0) {
36
+ const m0 = name.match(/^([A-Za-z_$][\w$]*)(\..+)?$/);
37
+ if (m0 && derivedNames.has(m0[1]))
38
+ return derivedNames.get(m0[1]) + (m0[2] ?? '');
39
+ }
40
+ if (storeAliases.size === 0)
41
+ return name;
42
+ const m = name.match(/^([A-Za-z_$][\w$]*)\.(.+)$/);
43
+ if (!m)
44
+ return name;
45
+ const def = storeAliases.get(m[1]);
46
+ if (!def)
47
+ return name;
48
+ const segs = m[2].split('.');
49
+ const field = segs[0];
50
+ // R86 续:getter 若可折叠成「从某个 state 字段出发的成员链」,直接拍成该字段的点路径
51
+ const getter = def.getters.find((g) => g.name === field);
52
+ if (getter && getter.stateField) {
53
+ const tail = [getter.path, ...segs.slice(1)].filter(Boolean).join('.');
54
+ return [`${m[1]}_${getter.stateField}`, tail].filter(Boolean).join('.');
55
+ }
56
+ // R90 含计算逻辑的 getter:降级为读取底层 state 字段(筛选/排序/派生字段语义丢失)
57
+ if (getter && getter.refField) {
58
+ // 链式解析:getter → 另一个 getter → … → state 字段(如 soon → upcoming → list)
59
+ let field2 = getter.refField;
60
+ const seen = new Set([field]);
61
+ let guard = 0;
62
+ while (field2 && !def.state.some((f) => f.name === field2) && guard++ < 6 && !seen.has(field2)) {
63
+ seen.add(field2);
64
+ field2 = def.getters.find((g) => g.name === field2)?.refField;
65
+ }
66
+ if (field2 && def.state.some((f) => f.name === field2)) {
67
+ const msg = `store getter「${m[1]}.${field}」含计算逻辑(遍历/过滤/排序等),原生端无法求值 → 已降级为直接读取底层状态「${m[1]}_${field2}」,派生语义丢失(R90)`;
68
+ if (!storeGetterWarnings.includes(msg))
69
+ storeGetterWarnings.push(msg);
70
+ return [`${m[1]}_${field2}`, ...segs.slice(1)].filter(Boolean).join('.');
71
+ }
72
+ }
73
+ if (getter && getter.multi) {
74
+ const msg = `store getter「${m[1]}.${field}」依赖多个 state 字段,无法降级 → 已生成占位(R90)`;
75
+ if (!storeGetterWarnings.includes(msg))
76
+ storeGetterWarnings.push(msg);
77
+ return `\u0000store:${m[1]}.${m[2]}`;
78
+ }
79
+ if (!def.state.some((f) => f.name === field))
80
+ return `\u0000store:${m[1]}.${m[2]}`;
81
+ return [`${m[1]}_${field}`, ...segs.slice(1)].join('.');
82
+ }
23
83
  /** R-VFOBJ-C:解析 <script setup> 顶层 `const rows = [ ... ]`(静态数组字面量)→ { name: 数组内文 }
24
84
  * 模板 `v-for="(item, i) in rows"` 引用变量名时查表展开(编译期常量,非 M4b 动态状态);
25
85
  * 自动排除 ref([...])(`=` 后非 `[` 不匹配)、navBar 对象(非数组)与函数体(保守容忍,与 extractScreenState 一致)
26
86
  */
27
87
  export function extractScreenConstArrays(script) {
28
88
  const out = {};
29
- const re = /const\s+(\w+)\s*=\s*(\[[\s\S]*?\])\s*;?/g;
89
+ // 允许类型标注(const WAYS: {...}[] = [)且用括号配平,避免嵌套数组被首个 ] 截断(R88)
90
+ const re = /const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*\[/g;
30
91
  let m;
31
92
  while ((m = re.exec(script))) {
32
- const arr = m[2];
33
- const inner = arr.slice(1, -1).trim();
93
+ const at = script.indexOf('[', m.index + m[0].length - 1);
94
+ if (at === -1)
95
+ continue;
96
+ let depth = 0;
97
+ let end = -1;
98
+ for (let i = at; i < script.length; i++) {
99
+ if (script[i] === '[')
100
+ depth++;
101
+ else if (script[i] === ']') {
102
+ depth--;
103
+ if (depth === 0) {
104
+ end = i;
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ if (end === -1)
110
+ continue;
111
+ const inner = script.slice(at + 1, end).trim();
34
112
  if (inner)
35
113
  out[m[1]] = inner;
114
+ re.lastIndex = end + 1;
36
115
  }
37
116
  return out;
38
117
  }
@@ -57,6 +136,37 @@ export function extractScreenState(script) {
57
136
  const json = '[' + m[2].trim().replace(/'([^']*)'/g, '"$1"') + ']';
58
137
  states.push({ name: m[1], init: json, list: true });
59
138
  }
139
+ // R85 对象态:const board = ref({ month: { label: '10 月' }, tags: '生日' })
140
+ // init 存规范化 JSON 字符串(双端一致:Swift/Compose 交给 vrnField 点路径解析,JS 侧解析为对象)
141
+ for (const m of script.matchAll(/const\s+(\w+)\s*=\s*ref(?:<[^>]*>)?\(\s*\{/g)) {
142
+ const start = (m.index ?? 0) + m[0].length - 1;
143
+ let depth = 0;
144
+ let end = -1;
145
+ for (let i = start; i < script.length; i++) {
146
+ const ch = script[i];
147
+ if (ch === '{')
148
+ depth++;
149
+ else if (ch === '}') {
150
+ depth--;
151
+ if (depth === 0) {
152
+ end = i;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ if (end === -1)
158
+ continue;
159
+ const raw = script.slice(start, end + 1);
160
+ // 规范化:单引号 → 双引号;裸键加引号(仅限简单字面量对象,复杂表达式会被 vrnField 忽略该层)
161
+ const json = raw
162
+ .replace(/'([^']*)'/g, (_a, inner) => `"${inner.replace(/"/g, '\\"')}"`)
163
+ .replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, (_a, pre, key) => `${pre}"${key}":`)
164
+ .replace(/,(\s*[}\]])/g, '$1')
165
+ .replace(/\s*\n\s*/g, ' ')
166
+ .replace(/\s{2,}/g, ' ')
167
+ .trim();
168
+ states.push({ name: m[1], init: json, obj: true });
169
+ }
60
170
  // 箭头函数(有参):const f = (p) => { x.value = p } / const f = p => { ... }
61
171
  for (const m of script.matchAll(/const\s+(\w+)\s*=\s*(?:\(\s*([A-Za-z_]\w*)\s*\)|([A-Za-z_]\w*))\s*=>\s*\{\s*(\w+)\.value\s*=\s*([^;{}]+)\s*;?\s*\}/g)) {
62
172
  setters[m[1]] = { target: m[4], param: (m[2] ?? m[3]) };
@@ -264,6 +374,39 @@ if (typeof queueMicrotask !== "function") {
264
374
  };
265
375
  }
266
376
 
377
+ // R81 原生桥:bridge.toast / bridge.openUrl(iOS 由 __nativeToast/__nativeOpenUrl 宿主函数实现;
378
+ // Android 由编译器把 bridge.* 翻译成 VrnBridge.*;浏览器预览退回 alert/console)
379
+ // R81/R84 原生桥:编译期注入的实现(iOS VrnBridge.swift / Android VrnBridge.kt)
380
+ // 未注入原生实现时(浏览器预览)退化为 console / window 语义,保证同一份代码两端可跑
381
+ function __vrnNative(name) {
382
+ return typeof globalThis[name] === "function" || typeof (typeof self !== "undefined" ? self[name] : undefined) === "function";
383
+ }
384
+ var bridge = {
385
+ isNative: __vrnNative("__nativeToast"),
386
+ toast: function (msg) {
387
+ if (__vrnNative("__nativeToast")) __nativeToast(String(msg));
388
+ else if (typeof console !== "undefined") console.log("[bridge.toast]", msg);
389
+ },
390
+ openUrl: function (url) {
391
+ if (__vrnNative("__nativeOpenUrl")) __nativeOpenUrl(String(url));
392
+ else if (typeof window !== "undefined") window.open(String(url), "_blank");
393
+ },
394
+ vibrate: function (ms) {
395
+ if (__vrnNative("__nativeVibrate")) __nativeVibrate(Number(ms) > 0 ? Number(ms) : 18);
396
+ else if (typeof navigator !== "undefined" && navigator.vibrate) navigator.vibrate(Number(ms) > 0 ? Number(ms) : 18);
397
+ },
398
+ copy: function (text) {
399
+ if (__vrnNative("__nativeCopy")) __nativeCopy(String(text));
400
+ else if (typeof navigator !== "undefined" && navigator.clipboard) navigator.clipboard.writeText(String(text));
401
+ },
402
+ setStatusBar: function (style) {
403
+ if (__vrnNative("__nativeSetStatusBar")) __nativeSetStatusBar(String(style));
404
+ },
405
+ keepScreenOn: function (on) {
406
+ if (__vrnNative("__nativeKeepScreenOn")) __nativeKeepScreenOn(!!on);
407
+ }
408
+ };
409
+
267
410
  // localStorage → 原生 UserDefaults(D7:vrn. 前缀 + 同步读写)
268
411
  var localStorage = {
269
412
  getItem: function (k) { return typeof __lsGet === "function" ? __lsGet("vrn." + k) : null; },
@@ -326,7 +469,81 @@ export function swiftExpr(exp) {
326
469
  }
327
470
  /** @click="fn(arg)" → 若 fn 是已提取的 setter,内联为状态赋值 { var, value, kind }(M1 扩展无参表达式 setter)
328
471
  * M2:直接赋值表达式 `selected = item` / `selected = i`(列表选中)同样内联 */
329
- export function resolveSetCall(handler, state) {
472
+ /**
473
+ * R87 store action 折叠:把 `ui.setSkin('night')` 解析成页面状态赋值。
474
+ * 只认三种简单体:`this.f = <字面量>` / `this.f = <形参>`(调用处实参替代)/ `this.f++`。
475
+ * 返回 null 表示不可折叠(调用方保留原样并给 warning)。
476
+ */
477
+ function resolveStoreAction(alias, actionName, argsRaw, warn) {
478
+ const def = storeAliases.get(alias);
479
+ if (!def)
480
+ return null;
481
+ const act = def.actions.find((a) => a.name === actionName);
482
+ if (!act)
483
+ return null;
484
+ // 只取第一条语句:`this.skin = skin; void setSetting(...)` 里状态赋值可折叠,持久化副作用丢弃(给 warning)
485
+ const stmts = act.body
486
+ .split(/[\n;]+/)
487
+ .map((t) => t.trim())
488
+ .filter(Boolean);
489
+ const body = (stmts[0] ?? '').replace(/;\s*$/, '').trim();
490
+ const args = argsRaw
491
+ .split(',')
492
+ .map((a) => a.trim())
493
+ .filter(Boolean);
494
+ // this.f = <rhs>
495
+ let m = body.match(/^this\.(\w+)\s*=\s*([\s\S]+)$/);
496
+ if (m) {
497
+ const field = m[1];
498
+ if (!def.state.some((f) => f.name === field))
499
+ return null;
500
+ let rhs = m[2].trim();
501
+ const pIdx = act.params.indexOf(rhs);
502
+ if (pIdx !== -1) {
503
+ if (args.length <= pIdx)
504
+ return null;
505
+ rhs = args[pIdx];
506
+ }
507
+ else if (act.params.some((p) => new RegExp(`\\b${p}\\b`).test(rhs))) {
508
+ return null; // 体里对形参做了运算 → 不折叠
509
+ }
510
+ if (stmts.length > 1) {
511
+ warn?.(`store action「${actionName}」已折叠状态赋值,其余语句(${stmts.slice(1).join('; ')})未折叠(R87)`);
512
+ }
513
+ if (/^-?\d+(\.\d+)?$/.test(rhs))
514
+ return { var: `${alias}_${field}`, value: Number(rhs), kind: 'literal' };
515
+ if (/^'[^']*'$|^"[^"]*"$/.test(rhs))
516
+ return { var: `${alias}_${field}`, value: rhs.slice(1, -1), kind: 'literal' };
517
+ const expr = swiftExpr(rhs);
518
+ return expr === null ? null : { var: `${alias}_${field}`, value: expr, kind: 'expr' };
519
+ }
520
+ // this.f++ / this.f--
521
+ m = body.match(/^this\.(\w+)\s*(\+\+|--)$/);
522
+ if (m && def.state.some((f) => f.name === m[1])) {
523
+ if (stmts.length > 1)
524
+ warn?.(`store action「${actionName}」已折叠状态赋值,其余语句(${stmts.slice(1).join('; ')})未折叠(R87)`);
525
+ const v = `${alias}_${m[1]}`;
526
+ return { var: v, value: `${v} ${m[2] === '++' ? '+' : '-'} 1`, kind: 'expr' };
527
+ }
528
+ return null;
529
+ }
530
+ /** R87 把 handler 体内 `alias.action(...)` 折成 `stateStore.alias_field = <值>`(双端可译) */
531
+ function foldStoreActionsInBody(body) {
532
+ if (storeAliases.size === 0)
533
+ return body;
534
+ let out = body;
535
+ for (const [alias, def] of storeAliases) {
536
+ for (const act of def.actions) {
537
+ const re = new RegExp(`\\b${alias}\\.${act.name}\\(([^)]*)\\)`, 'g');
538
+ out = out.replace(re, (whole, args) => {
539
+ const r = resolveStoreAction(alias, act.name, args);
540
+ return r === null ? whole : `stateStore.${r.var} = ${r.kind === 'literal' && typeof r.value === 'string' ? JSON.stringify(r.value) : String(r.value)}`;
541
+ });
542
+ }
543
+ }
544
+ return out;
545
+ }
546
+ export function resolveSetCall(handler, state, warn) {
330
547
  const t = handler.trim();
331
548
  // M2 直接赋值:目标必须是已声明状态
332
549
  const assign = t.match(/^([A-Za-z_]\w*)\s*=\s*(.+)$/);
@@ -341,6 +558,13 @@ export function resolveSetCall(handler, state) {
341
558
  return { var: assign[1], value: expr, kind: 'expr' };
342
559
  return null;
343
560
  }
561
+ // R87 store action:`ui.setSkin('night')` → onSet 赋值(两端统一走 onSet)
562
+ const sa = t.match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(([^)]*)\)$/);
563
+ if (sa && storeAliases.has(sa[1])) {
564
+ const r = resolveStoreAction(sa[1], sa[2], sa[3], warn);
565
+ if (r)
566
+ return r;
567
+ }
344
568
  const m = t.match(/^(\w+)\(([^)]*)\)$/);
345
569
  if (!m)
346
570
  return null;
@@ -464,6 +688,20 @@ function parseVFor(exp, warnings, constArrays) {
464
688
  const body = m[3].trim();
465
689
  // M4b 动态列表:in <stateName>(stateStore 数组;Swift 侧 JSON 字符串解码渲染)
466
690
  // R-VFOBJ-C:引用 script 顶层 const 数组(const rows = [...])→ 编译期展开,优先于动态状态判定
691
+ // R88 store 常量数组(store 文件里 export const SKINS = [...])与页面常量数组同等待遇
692
+ if (body in storeConstArrays && !(constArrays && body in constArrays)) {
693
+ const vf = parseVForArrayInner(storeConstArrays[body], itemKey, indexKey, warnings);
694
+ if (vf)
695
+ vf.srcName = body;
696
+ return vf;
697
+ }
698
+ // R88 store 状态数组:v-for="x in ui.list" → 拍平为 ui_list 后按动态列表处理
699
+ {
700
+ const flat = flattenStorePath(body);
701
+ if (flat !== body && /^[A-Za-z_$][\w$]*$/.test(flat)) {
702
+ return { listState: flat, itemKey, indexKey };
703
+ }
704
+ }
467
705
  if (/^[A-Za-z_]\w*$/.test(body)) {
468
706
  if (constArrays && body in constArrays) {
469
707
  const inner = constArrays[body];
@@ -569,9 +807,21 @@ function parseObjectLiteral(s) {
569
807
  const key = trimmed.slice(0, ci).trim().replace(/^['"]|['"]$/g, '');
570
808
  const valRaw = trimmed.slice(ci + 1).trim();
571
809
  const lit = literalValue(valRaw);
572
- if (lit === undefined || typeof lit === 'boolean')
573
- return null;
574
- obj[key] = lit;
810
+ if (typeof lit === 'boolean') {
811
+ // R88:布尔值按字符串保留(列表项属性表只存 string|number,渲染时按文本用)
812
+ obj[key] = lit ? 'true' : 'false';
813
+ continue;
814
+ }
815
+ if (lit !== undefined) {
816
+ obj[key] = lit;
817
+ continue;
818
+ }
819
+ // R88:嵌套数组/对象值 → 序列化为 JSON 文本保底(不作为可渲染字段,但不因此整段丢弃)
820
+ if (/^[\[{]/.test(valRaw) && /[\]}]$/.test(valRaw)) {
821
+ obj[key] = valRaw.replace(/'([^']*)'/g, (_a, inner) => `"${inner}"`);
822
+ continue;
823
+ }
824
+ return null;
575
825
  }
576
826
  return Object.keys(obj).length > 0 ? obj : null;
577
827
  }
@@ -588,6 +838,14 @@ const TAG_MAP = {
588
838
  li: { type: 'group' },
589
839
  p: { type: 'text', defaultProps: { fontSize: 14 } },
590
840
  span: { type: 'text', defaultProps: { fontSize: 14 } },
841
+ // R89 续:行内语义标签按纯文本处理(原生端无 HTML 语义,但文本内容必须保留)
842
+ b: { type: 'text', defaultProps: { fontSize: 14 } },
843
+ strong: { type: 'text', defaultProps: { fontSize: 14 } },
844
+ i: { type: 'text', defaultProps: { fontSize: 14 } },
845
+ em: { type: 'text', defaultProps: { fontSize: 14 } },
846
+ small: { type: 'text', defaultProps: { fontSize: 12 } },
847
+ u: { type: 'text', defaultProps: { fontSize: 14 } },
848
+ code: { type: 'text', defaultProps: { fontSize: 13 } },
591
849
  h1: { type: 'text', defaultProps: { fontSize: 24, fontWeight: 'bold' } },
592
850
  h2: { type: 'text', defaultProps: { fontSize: 20, fontWeight: 'bold' } },
593
851
  h3: { type: 'text', defaultProps: { fontSize: 17, fontWeight: 'medium' } },
@@ -605,6 +863,10 @@ let vforConsts = {};
605
863
  export function vueToIr(source, opts = {}) {
606
864
  compPosition = new Map();
607
865
  vforConsts = {};
866
+ storeAliases = new Map();
867
+ storeConstArrays = {};
868
+ derivedNames = new Map();
869
+ storeGetterWarnings = [];
608
870
  const warnings = [];
609
871
  const { descriptor, errors } = parse(source, { filename: 'Screen.vue' });
610
872
  if (errors.length > 0) {
@@ -687,6 +949,8 @@ export function vueToIr(source, opts = {}) {
687
949
  let navBarConfig = null;
688
950
  let onMountedBody;
689
951
  let onMountedAsync = false;
952
+ /** R74 保留名状态的重命名映射(旧名 → 新名) */
953
+ const stateRenames = new Map();
690
954
  let stateInfo;
691
955
  const script = descriptor.scriptSetup ?? descriptor.script;
692
956
  if (script && script.content.trim()) {
@@ -706,12 +970,71 @@ export function vueToIr(source, opts = {}) {
706
970
  warnings.push(`handler「${h.name}」函数体疑似含 TS 类型标注(如 const x: number = 1),runtime.js 将解析失败——请移除类型标注(R13 仅支持 JS 子集)`);
707
971
  }
708
972
  }
709
- // R74:状态名撞原生保留成员(SwiftUI `body`、Kotlin 关键字等)会导致生成代码编译失败 → 必须提前警告
973
+ // R86 Pinia 折叠:`const ui = useUiStore()` → 把 store.state 拍平成页面状态 `ui_<field>`
974
+ if (opts.storeSources) {
975
+ for (const src of Object.values(opts.storeSources)) {
976
+ storeConstArrays = { ...storeConstArrays, ...parseSharedConstArrays(src) };
977
+ }
978
+ const registry = buildStoreRegistry(opts.storeSources);
979
+ storeAliases = findStoreAliases(src, registry);
980
+ for (const [alias, def] of storeAliases) {
981
+ for (const f of def.state) {
982
+ const flat = `${alias}_${f.name}`;
983
+ if (stateInfo.states.some((st) => st.name === flat))
984
+ continue;
985
+ stateInfo.states.push({ name: flat, init: f.init, ...(f.obj ? { obj: true } : {}), ...(f.list ? { list: true } : {}) });
986
+ }
987
+ }
988
+ }
989
+ // R89 computed 折叠:`const all = computed(() => store.upcoming)` → 派生名指向扁平状态点路径
990
+ for (const cm of src.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*computed\s*\(\s*\(\s*\)\s*=>\s*([^)]+?)\s*\)/g)) {
991
+ const expr = cm[2].trim();
992
+ const flat = flattenStorePath(expr);
993
+ if (flat !== expr && /^[A-Za-z_$][\w$.]*$/.test(flat)) {
994
+ derivedNames.set(cm[1], flat);
995
+ }
996
+ }
997
+ // R89 常量折叠:模块级纯常量(含可静态求值的模板字面量)注入为状态
998
+ for (const km of src.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(`[^`]*`|'[^']*'|"[^"]*"|-?\d+(?:\.\d+)?)\s*$/gm)) {
999
+ const raw = km[2];
1000
+ const name = km[1];
1001
+ if (stateInfo.states.some((st) => st.name === name))
1002
+ continue;
1003
+ let value;
1004
+ if (/^-?\d+(\.\d+)?$/.test(raw))
1005
+ value = Number(raw);
1006
+ else if (raw.startsWith('`')) {
1007
+ // 模板字面量:仅允许 Date/Math/数字/字符串等可静态求值的片段,否则跳过
1008
+ const inner = raw.slice(1, -1);
1009
+ if (/\$\{[^}]*\}/.test(inner)) {
1010
+ // 白名单改为黑名单:允许中文等任意字面字符,但禁止访问宿主/业务对象
1011
+ if (!/\b(window|document|globalThis|require|process|fetch|store|props|this|localStorage)\b/.test(inner)) {
1012
+ try {
1013
+ value = String(new Function(`return \`${inner}\`;`)());
1014
+ }
1015
+ catch {
1016
+ /* 求值失败 → 跳过 */
1017
+ }
1018
+ }
1019
+ }
1020
+ else {
1021
+ value = inner;
1022
+ }
1023
+ }
1024
+ else {
1025
+ value = raw.slice(1, -1);
1026
+ }
1027
+ if (value !== undefined)
1028
+ stateInfo.states.push({ name, init: value });
1029
+ }
1030
+ // R74:状态名撞原生保留成员(SwiftUI `body`、Kotlin 关键字)→ 自动改名 + 警告(不再只警告导致后续编译失败)
710
1031
  {
711
1032
  const RESERVED = ['body', 'self', 'init', 'class', 'object', 'fun', 'val', 'var', 'when', 'is', 'in'];
712
1033
  for (const st of stateInfo?.states ?? []) {
713
1034
  if (RESERVED.includes(st.name)) {
714
- warnings.push(`状态名「${st.name}」与原生保留成员冲突(SwiftUI body / Kotlin 关键字)——请改名(如 ${st.name}Text / ${st.name}Value),否则生成的 iOS/Android 代码无法编译`);
1035
+ const next = `${st.name}Text`;
1036
+ stateRenames.set(st.name, next);
1037
+ warnings.push(`状态名「${st.name}」与原生保留成员冲突 → 将自动改名「${next}」(R74)`);
715
1038
  }
716
1039
  }
717
1040
  }
@@ -723,6 +1046,28 @@ export function vueToIr(source, opts = {}) {
723
1046
  onMountedAsync = /(^|[^\w$.])await\s/.test(onMountedBody) || /onMounted\s*\(\s*async\b/.test(src);
724
1047
  onMountedBody = rewriteStateRefs(onMountedBody, (stateInfo?.states ?? []).map((s) => s.name));
725
1048
  }
1049
+ // R87:handler/onMounted 内的 store action 调用 → 状态赋值(iOS 走 JS stateStore,Android 走内联赋值)
1050
+ if (storeAliases.size > 0) {
1051
+ for (const h of handlers ?? [])
1052
+ h.body = foldStoreActionsInBody(h.body);
1053
+ if (onMountedBody !== undefined)
1054
+ onMountedBody = foldStoreActionsInBody(onMountedBody);
1055
+ }
1056
+ // R74 续:先在 handler/onMounted 源码里改名,再生成 runtime(否则 JS 侧仍指向旧名)
1057
+ if (stateRenames.size > 0) {
1058
+ const renameSrc = (src) => {
1059
+ let out = src;
1060
+ for (const [from, to] of stateRenames) {
1061
+ out = out.replace(new RegExp(`\\bstateStore\\.${from}\\b`, 'g'), `stateStore.${to}`);
1062
+ out = out.replace(new RegExp(`\\b${from}\\.value\\b`, 'g'), `${to}.value`);
1063
+ }
1064
+ return out;
1065
+ };
1066
+ for (const h of handlers ?? [])
1067
+ h.body = renameSrc(h.body);
1068
+ if (onMountedBody !== undefined)
1069
+ onMountedBody = renameSrc(onMountedBody);
1070
+ }
726
1071
  if (handlers.length > 0 || onMountedBody !== undefined) {
727
1072
  runtime = generateRuntimeSource(stateInfo?.states ?? [], handlers, onMountedBody, onMountedAsync);
728
1073
  }
@@ -736,6 +1081,21 @@ export function vueToIr(source, opts = {}) {
736
1081
  compPosition.set(c.name, tree.position);
737
1082
  return { name: c.name, ui: tree.ui };
738
1083
  });
1084
+ // R74 续:把重命名应用到 handler/onMounted 源码与 IR 引用(否则绑定仍指向旧名 → 编译失败)
1085
+ if (stateRenames.size > 0) {
1086
+ const renameSrc = (src) => {
1087
+ let out = src;
1088
+ for (const [from, to] of stateRenames) {
1089
+ out = out.replace(new RegExp(`\\bstateStore\\.${from}\\b`, 'g'), `stateStore.${to}`);
1090
+ out = out.replace(new RegExp(`\\b${from}\\.value\\b`, 'g'), `${to}.value`);
1091
+ }
1092
+ return out;
1093
+ };
1094
+ for (const h of handlers ?? [])
1095
+ h.body = renameSrc(h.body);
1096
+ if (onMountedBody !== undefined)
1097
+ onMountedBody = renameSrc(onMountedBody);
1098
+ }
739
1099
  const children = ast.children.flatMap((c) => walk(c, classProps, comboRules, scopedRules, pseudoRules, warnings, stateInfo, compTable));
740
1100
  let pageRootBg;
741
1101
  // 3) 组装 screen:root(width fill + height 基准)包一层 group(承载根元素布局 props)
@@ -774,8 +1134,64 @@ export function vueToIr(source, opts = {}) {
774
1134
  checkClickHandlers(c);
775
1135
  };
776
1136
  checkClickHandlers(ui);
1137
+ // R74 续:IR 树里的状态引用改名(bind/show/onSet/bindParts/fieldOf/condition/动态样式/输入值/列表态)
1138
+ if (stateRenames.size > 0) {
1139
+ // 状态表本身改名(放在 walk 之后,保证 {{ body }} 在解析时仍能命中原名)
1140
+ for (const st of stateInfo?.states ?? []) {
1141
+ const to = stateRenames.get(st.name);
1142
+ if (to !== undefined)
1143
+ st.name = to;
1144
+ }
1145
+ const rn = (v) => (v === undefined ? undefined : stateRenames.get(v) ?? v);
1146
+ const rnCond = (c) => {
1147
+ if (c === undefined)
1148
+ return undefined;
1149
+ let out = c;
1150
+ for (const [from, to] of stateRenames)
1151
+ out = out.replace(new RegExp(`\\b${from}\\b`, 'g'), to);
1152
+ return out;
1153
+ };
1154
+ const walkRen = (n) => {
1155
+ const p = n.props;
1156
+ if (p) {
1157
+ for (const k of ['bind', 'show', 'value', 'colorDynamic', 'fontSizeDynamic']) {
1158
+ if (typeof p[k] === 'string')
1159
+ p[k] = rn(p[k]);
1160
+ }
1161
+ if (p.onSet && typeof p.onSet === 'object') {
1162
+ const os = p.onSet;
1163
+ os.var = rn(os.var);
1164
+ }
1165
+ if (p.fieldOf && typeof p.fieldOf === 'object') {
1166
+ const f = p.fieldOf;
1167
+ f.obj = rn(f.obj);
1168
+ }
1169
+ if (Array.isArray(p.bindParts)) {
1170
+ for (const seg of p.bindParts) {
1171
+ if (typeof seg.bind === 'string')
1172
+ seg.bind = rn(seg.bind);
1173
+ if (seg.fieldOf && typeof seg.fieldOf === 'object') {
1174
+ const f = seg.fieldOf;
1175
+ f.obj = rn(f.obj);
1176
+ }
1177
+ }
1178
+ }
1179
+ if (n.vfor && n.vfor.listState)
1180
+ n.vfor.listState = rn(n.vfor.listState);
1181
+ }
1182
+ n.condition = rnCond(n.condition);
1183
+ if (n.active)
1184
+ n.active.when = rnCond(n.active.when);
1185
+ for (const c of n.children ?? [])
1186
+ walkRen(c);
1187
+ };
1188
+ walkRen(ui);
1189
+ }
777
1190
  // 5) <script setup> 逻辑源码 → 运行时 JS(R12:事件逻辑由原生 JS 引擎执行)
778
1191
  // 仅支持 JS 子集(无 TS 类型标注、无 import);超出即编译期警告(后续阶段扩展)
1192
+ for (const w of storeGetterWarnings)
1193
+ if (!warnings.includes(w))
1194
+ warnings.push(w);
779
1195
  return {
780
1196
  screen: {
781
1197
  id: opts.screenId ?? 'vue-screen',
@@ -804,11 +1220,23 @@ function walk(node, classProps, comboRules, scopedRules, pseudoRules, warnings,
804
1220
  case NodeTypes.COMMENT:
805
1221
  return [];
806
1222
  case NodeTypes.INTERPOLATION: {
807
- const name = textOf(node.content).trim();
1223
+ const nameRaw = textOf(node.content).trim();
1224
+ // R86:store 访问先拍平(ui.dialog.title → ui_dialog.title);getter/action 返回哨兵
1225
+ const name = flattenStorePath(normalizeTemplateExpr(nameRaw, warnings));
808
1226
  // 组件树模式(propRefs 传入=非空):{{ 任意名 }} → text ref(Swift Text(prop),
809
1227
  // prop 名由组件树实际引用自动收集,不强制 defineProps 声明)
810
- if (propRefs)
1228
+ if (propRefs) {
1229
+ // R78 续(G13):组件内 {{ obj.field }} → 组件 prop 是字符串(JSON)→ 运行时取值
1230
+ const cf = name.match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/);
1231
+ if (cf && propRefs.has(cf[1])) {
1232
+ return [{ type: 'text', props: { fieldOf: { obj: cf[1], field: cf[2] } } }];
1233
+ }
811
1234
  return [{ type: 'text', props: { ref: name } }];
1235
+ }
1236
+ if (name.startsWith('\u0000store:')) {
1237
+ warnings.push(`插值 {{ ${name.slice(7)} }} 是 Pinia store 的 getter/action,R86 目前只折叠 state 字段 → 已生成占位文本`);
1238
+ return [{ type: 'text', props: { text: '\u200B' } }];
1239
+ }
812
1240
  // M2 模板内三元(D1 小范围):cond ? 'a' : 'b' → Swift 三元字符串表达式(Text(expr))
813
1241
  const tri = parseTernaryExpr(name);
814
1242
  if (tri)
@@ -830,6 +1258,29 @@ function walk(node, classProps, comboRules, scopedRules, pseudoRules, warnings,
830
1258
  }
831
1259
  throw new VueCompileError(`插值 {{ ${name} }}:未在 <script setup> 中声明为 ref 状态(M1 白名单:仅支持已声明状态)`);
832
1260
  }
1261
+ // R92 点路径兜底:{{ a.b ?? '默认' }} → fieldOf + fallback
1262
+ {
1263
+ const fbm = name.match(/^@@fb@@([^@]+)@@([\s\S]*)$/);
1264
+ if (fbm) {
1265
+ // 内部路径可能仍是 store 别名/computed 派生名 → 先拍平
1266
+ const p2 = matchFieldPath(flattenStorePath(fbm[1]));
1267
+ if (p2 && (state?.states ?? []).some((st) => st.name === p2.obj)) {
1268
+ return [{ type: 'text', props: { fieldOf: { obj: p2.obj, field: p2.field, fallback: fbm[2] } } }];
1269
+ }
1270
+ }
1271
+ }
1272
+ // R78/R85 对象字段插值 {{ obj.field }} / {{ obj.a.b }} / {{ obj.list.length }}:
1273
+ // obj 是页面状态 → 运行时按点路径取值;否则明确警告 + 空文本降级
1274
+ {
1275
+ const fld = matchFieldPath(name);
1276
+ if (fld) {
1277
+ const isState = (state?.states ?? []).some((st) => st.name === fld.obj);
1278
+ if (isState)
1279
+ return [{ type: 'text', props: { fieldOf: { obj: fld.obj, field: fld.field } } }];
1280
+ warnings.push(`插值 {{ ${name} }} 的对象「${fld.obj}」不是页面状态(如 Pinia store)→ 已生成占位文本;请把该字段提升为 ref(R78/R85)`);
1281
+ return [{ type: 'text', props: { text: ' ' } }];
1282
+ }
1283
+ }
833
1284
  throw new VueCompileError(`插值表达式 {{ ${name} }} 暂不支持(M1 仅支持纯状态名;三元/比较表达式 M2 白名单)`);
834
1285
  }
835
1286
  case NodeTypes.ELEMENT:
@@ -840,6 +1291,19 @@ function walk(node, classProps, comboRules, scopedRules, pseudoRules, warnings,
840
1291
  }
841
1292
  }
842
1293
  /** M2 模板内三元插值:`cond ? 'a' : 'b'` → Swift 三元字符串表达式;cond 需过 swiftExpr 白名单 */
1294
+ /**
1295
+ * R85 成员链匹配:`a.b` / `a.b.c` / `a.list.length`
1296
+ * 返回 { obj: 首段, field: 余下点路径 };非纯成员链(含调用/下标/运算符)返回 null。
1297
+ */
1298
+ function matchFieldPath(name) {
1299
+ if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(name))
1300
+ return null;
1301
+ const segs = name.split('.');
1302
+ // length/size 只允许出现在末段(作为属性读,而非方法调用)
1303
+ if (segs.slice(1, -1).some((s) => s === 'length' || s === 'size'))
1304
+ return null;
1305
+ return { obj: segs[0], field: segs.slice(1).join('.') };
1306
+ }
843
1307
  function parseTernaryExpr(exp) {
844
1308
  const t = exp.trim();
845
1309
  const m = t.match(/^(.+?)\s*\?\s*'([^']+)'\s*:\s*'([^']+)'$/);
@@ -850,6 +1314,29 @@ function parseTernaryExpr(exp) {
850
1314
  return null;
851
1315
  return `${cond} ? "${m[2]}" : "${m[3]}"`;
852
1316
  }
1317
+ /**
1318
+ * R91 模板表达式归一化(在成员链/状态判定之前统一处理):
1319
+ * ① 可选链 `a?.b` → `a.b`(原生端 vrnField 对缺失键返回空串,近似等价;仅提示一次);
1320
+ * ② 空值兜底 `a || 'x'` / `a ?? 'x'`(a 为纯状态名)→ `a ? 'x' : 'x'` 交给三元通道。
1321
+ * 其余形态(方法调用、字符串方法、模板字面量)不在此处理,按原逻辑报错。
1322
+ */
1323
+ function normalizeTemplateExpr(name, warnings) {
1324
+ let out = name;
1325
+ if (out.includes('?.')) {
1326
+ out = out.replace(/\?\./g, '.');
1327
+ const msg = `模板可选链「${name}」已归一化为「${out}」——原生端字段缺失时返回空串(近似 null 安全,R91)`;
1328
+ if (!warnings.includes(msg))
1329
+ warnings.push(msg);
1330
+ }
1331
+ const fb = out.match(/^([A-Za-z_$][\w$]*)\s*(?:\|\||\?\?)\s*'([^']*)'$/);
1332
+ if (fb)
1333
+ return `${fb[1]} ? '${fb[2]}' : '${fb[1]}'`;
1334
+ // R92 点路径上的兜底:`a.b ?? '默认'` / `a.b.c || '默认'`(原生端 vrnField 带 fallback)
1335
+ const fb2 = out.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+)\s*(?:\|\||\?\?)\s*'([^']*)'$/);
1336
+ if (fb2)
1337
+ return `@@fb@@${fb2[1]}@@${fb2[2]}`;
1338
+ return out;
1339
+ }
853
1340
  function textOf(n) {
854
1341
  return n?.content ?? '';
855
1342
  }
@@ -1017,6 +1504,22 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1017
1504
  ], ancestorClasses);
1018
1505
  return { type: 'for', vfor: vf, children: [item] };
1019
1506
  }
1507
+ // R76 Vue 内置容器组件(Transition/KeepAlive/Teleport/Suspense/Fragment)→ 透明展开:
1508
+ // 原生端无过渡动画/缓存语义,但内容必须保留(否则整页编译失败)
1509
+ if (['Transition', 'TransitionGroup', 'KeepAlive', 'Teleport', 'Suspense', 'Fragment'].includes(node.tag)) {
1510
+ const kids = [];
1511
+ for (const child of node.children) {
1512
+ kids.push(...walk(child, classProps, comboRules, scopedRules, pseudoRules, warnings, state, compTable, propRefs, loopScope, ancestorClasses));
1513
+ }
1514
+ if (kids.length === 0) {
1515
+ warnings.push(`<${node.tag}> 内无内容,已忽略`);
1516
+ return { type: 'group', props: { flexDirection: 'column' }, children: [{ type: 'text', props: { text: ' ' } }] };
1517
+ }
1518
+ warnings.push(`<${node.tag}> 已透明展开(原生端无过渡/缓存语义,仅保留内容,R76)`);
1519
+ if (kids.length === 1)
1520
+ return kids[0];
1521
+ return { type: 'group', props: { flexDirection: 'column' }, children: kids };
1522
+ }
1020
1523
  // <template>:逻辑容器(v-if 条件渲染 / 纯透传),不产生 UI 节点
1021
1524
  if (node.tag === 'template') {
1022
1525
  const children = [];
@@ -1142,7 +1645,7 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1142
1645
  // @click="handler" → 状态赋值(setter 内联)优先;否则保留 onClick(JS 运行时调用)
1143
1646
  // M3 规范化:无参调用 `inc()` → `inc`(Swift invoke 统一 evaluateScript("<名>()"));带参保留原样
1144
1647
  if (event === 'click' && handler) {
1145
- const set = state && resolveSetCall(handler, state);
1648
+ const set = state && resolveSetCall(handler, state, (m) => warnings.push(m));
1146
1649
  if (set) {
1147
1650
  Object.assign(props, { onSet: set });
1148
1651
  }
@@ -1229,13 +1732,13 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1229
1732
  parts.push({ text: child.content });
1230
1733
  }
1231
1734
  else if (child.type === NodeTypes.INTERPOLATION) {
1232
- const name = textOf(child.content).trim();
1735
+ const name = flattenStorePath(normalizeTemplateExpr(textOf(child.content).trim(), warnings));
1233
1736
  // 动态列表项属性 {{ item.field }}:基名(dot[0])命中循环变量才成立
1234
1737
  const dot = name.split('.');
1235
1738
  const base = dot[0];
1236
1739
  const loopVar = loopScope.find((l) => l.itemKey === base || l.indexKey === base);
1237
1740
  const dyn = loopVar && dot.length === 2 && dot[0] === loopVar.itemKey && /^\w+$/.test(dot[1]);
1238
- if (!text && !parts.some((p) => 'bind' in p || 'dynField' in p)) {
1741
+ if (!text && !parts.some((p) => 'bind' in p || 'dynField' in p || 'fieldOf' in p)) {
1239
1742
  // M2 模板内三元(D1 小范围):cond ? 'a' : 'b' → Swift 三元字符串表达式(Text(expr))
1240
1743
  const tri = parseTernaryExpr(name);
1241
1744
  if (tri) {
@@ -1247,20 +1750,51 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1247
1750
  parts.push({ dynField: { var: dot[0], field: dot[1] } });
1248
1751
  continue;
1249
1752
  }
1753
+ if (name.startsWith('\u0000store:')) {
1754
+ warnings.push(`插值 {{ ${name.slice(7)} }} 是 Pinia store 的 getter/action,R86 目前只折叠 state 字段 → 已生成占位文本`);
1755
+ parts.push({ text: '\u200B' });
1756
+ continue;
1757
+ }
1250
1758
  // M2:v-for 循环变量(item/index)优先于状态表
1251
1759
  if (/^\w+$/.test(name)) {
1252
1760
  // 纯单插值:组件 View 内 → ref(Text(prop));循环变量/页面状态 → bind
1253
1761
  if (propRefs) {
1254
1762
  refOnly = name;
1763
+ // 后面可能还有静态文本 → 同步进 parts,否则最终会走 props.text 分支把插值整段丢掉
1764
+ parts.push({ bind: name });
1255
1765
  }
1256
1766
  else if (loopVar || (state && state.states.some((s) => s.name === name))) {
1257
1767
  refOnly = name;
1768
+ parts.push({ bind: name });
1258
1769
  }
1259
1770
  else {
1260
1771
  throw new VueCompileError(`插值 {{ ${name} }}:未在 <script setup> 中声明为 ref 状态(M1 白名单:仅支持已声明状态)`);
1261
1772
  }
1262
1773
  continue;
1263
1774
  }
1775
+ // R92 点路径兜底(元素文本分支)
1776
+ {
1777
+ const fbm2 = name.match(/^@@fb@@([^@]+)@@([\s\S]*)$/);
1778
+ if (fbm2) {
1779
+ const p3 = matchFieldPath(flattenStorePath(fbm2[1]));
1780
+ if (p3 && (state?.states ?? []).some((st) => st.name === p3.obj)) {
1781
+ parts.push({ fieldOf: { obj: p3.obj, field: p3.field, fallback: fbm2[2] } });
1782
+ continue;
1783
+ }
1784
+ }
1785
+ }
1786
+ // R78/R85 对象字段插值 {{ obj.field }} / {{ obj.a.b }} / {{ obj.list.length }}(单插值)
1787
+ const fld2 = matchFieldPath(name);
1788
+ if (fld2) {
1789
+ const isState2 = (state?.states ?? []).some((st) => st.name === fld2.obj) || (propRefs?.has(fld2.obj) ?? false);
1790
+ if (isState2) {
1791
+ parts.push({ fieldOf: { obj: fld2.obj, field: fld2.field } });
1792
+ continue;
1793
+ }
1794
+ warnings.push(`插值 {{ ${name} }} 的对象「${fld2.obj}」不是页面状态(如 Pinia store)→ 已生成占位文本;请把该字段提升为 ref(R78/R85)`);
1795
+ parts.push({ text: ' ' });
1796
+ continue;
1797
+ }
1264
1798
  throw new VueCompileError(`插值表达式 {{ ${name} }} 暂不支持(M1 仅支持纯状态名;三元/比较表达式 M2 白名单;动态列表项属性 M4b)`);
1265
1799
  }
1266
1800
  // 混合段:静态文本之后的插值
@@ -1270,8 +1804,16 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1270
1804
  parts.push({ bind: name });
1271
1805
  else if (propRefs)
1272
1806
  parts.push({ bind: name });
1273
- else
1274
- throw new VueCompileError(`混合文本内插值 {{ ${name} }} 暂不支持(仅支持已声明状态 / 循环变量 / 动态列表项属性)`);
1807
+ else {
1808
+ // R85 混合文本内的成员链插值:`共 {{ board.month.label }} 条` / `{{ board.tags.length }}`
1809
+ const mp = matchFieldPath(name);
1810
+ if (mp && (state?.states ?? []).some((st) => st.name === mp.obj)) {
1811
+ parts.push({ fieldOf: { obj: mp.obj, field: mp.field } });
1812
+ }
1813
+ else {
1814
+ throw new VueCompileError(`混合文本内插值 {{ ${name} }} 暂不支持(仅支持已声明状态 / 循环变量 / 动态列表项属性 / 已声明对象的点路径 R85)`);
1815
+ }
1816
+ }
1275
1817
  }
1276
1818
  else if (child.type === NodeTypes.COMMENT) {
1277
1819
  // 忽略注释
@@ -1292,9 +1834,13 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1292
1834
  props.bind = refOnly;
1293
1835
  }
1294
1836
  }
1295
- else if (parts.length > 1 || (parts.length === 1 && ('bind' in parts[0] || 'dynField' in parts[0]))) {
1837
+ else if (parts.length > 1 || (parts.length === 1 && ('bind' in parts[0] || 'dynField' in parts[0] || 'fieldOf' in parts[0]))) {
1296
1838
  props.bindParts = parts;
1297
1839
  }
1840
+ else if (parts.length === 1 && 'text' in parts[0] && !text.trim()) {
1841
+ // 只有占位文本(如不可折叠的 store getter/action):渲染空文本而不是报错
1842
+ props.text = parts[0].text;
1843
+ }
1298
1844
  else {
1299
1845
  const t = text.trim();
1300
1846
  if (!t) {
@@ -1326,9 +1872,30 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1326
1872
  children.push(...walk(child, classProps, comboRules, scopedRules, pseudoRules, warnings, state, compTable, propRefs, loopScope, [...ancestorClasses, ...elementClasses]));
1327
1873
  }
1328
1874
  }
1329
- // group 空 children → 报错(容器必须可收敛;R17 容器叶子约束)
1875
+ // R88 续:容器节点不接受文本专属样式(color/fontSize/fontWeight/textAlign/lineHeight)
1876
+ // 原生端容器没有「字体继承」,这类属性会直接触发 IR 校验拒收 → 明确降级 + 警告
1877
+ if (mapping.type === 'group' || mapping.type === 'for') {
1878
+ const TEXT_ONLY = ['color', 'fontSize', 'fontWeight', 'textAlign', 'lineHeight'];
1879
+ const dropped = TEXT_ONLY.filter((k) => props[k] !== undefined);
1880
+ if (dropped.length > 0) {
1881
+ for (const k of dropped)
1882
+ delete props[k];
1883
+ warnings.push(`<${node.tag}> 是容器,文本样式(${dropped.join('/')})在原生端不继承 → 已忽略;请把样式下沉到具体文本节点(R88)`);
1884
+ }
1885
+ }
1886
+ // R77 空容器(<div class="spacer" /> 等占位写法)→ 降级为 spacer 占位 + 警告,而不是中断整个编译
1330
1887
  if (mapping.type === 'group' && children.length === 0) {
1331
- throw new VueCompileError(`<${node.tag}> 无内容(容器节点必须含子节点,R17)`);
1888
+ warnings.push(`<${node.tag}> 无子节点 → 降级为占位 spacer(R17/R77;可用 padding/height 表达留白)`);
1889
+ const keep = {};
1890
+ const src = props;
1891
+ for (const k of ['width', 'height', 'flex', 'marginTop', 'background']) {
1892
+ if (src[k] !== undefined)
1893
+ keep[k] = src[k];
1894
+ }
1895
+ const spacer = { type: 'spacer', props: keep };
1896
+ if (cond)
1897
+ spacer.condition = normCond(cond);
1898
+ return spacer;
1332
1899
  }
1333
1900
  // overflow:auto → scroll 节点(消费中转标记,避免进入 schema 校验白名单)
1334
1901
  const propsAny = props;
@@ -1339,8 +1906,14 @@ function walkElement(node, classProps, comboRules, scopedRules, pseudoRules, war
1339
1906
  const irNode = { type: (nodeType ?? mapping.type), props };
1340
1907
  if (children.length > 0)
1341
1908
  irNode.children = children;
1342
- if (cond)
1343
- irNode.condition = normCond(cond);
1909
+ if (cond) {
1910
+ const nc = normCond(cond);
1911
+ // R78 续(G12):条件里的对象字段(v-if="dialog.title")→ 标记,由 codegen 映射为「非空判断」
1912
+ const mCond = nc.match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/);
1913
+ const ownerKnown = mCond !== null &&
1914
+ ((state?.states ?? []).some((st) => st.name === mCond[1]) || (propRefs?.has(mCond[1]) ?? false));
1915
+ irNode.condition = ownerKnown && mCond ? `__vrnField__:${mCond[1]}:${mCond[2]}` : nc;
1916
+ }
1344
1917
  if (activeWhen && activeProps) {
1345
1918
  irNode.active = { when: activeWhen, props: activeProps };
1346
1919
  // active 的 color 注入所有后代 text(选中态金色覆盖子元素显式色)