@dongmingzhixiu/vuexir-compiler 0.1.0 → 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.
@@ -1,7 +1,7 @@
1
1
  import type { IrNode } from '../ir/schema.js';
2
2
  declare const MANIFEST: (label: string) => string;
3
3
  declare const STYLES_XML = "<resources>\n <!-- R-SYSTEMBAR\uFF1A\u56FA\u5B9A\u6DF1\u8272\u4E3B\u9898 \u2192 \u7CFB\u7EDF\u680F\u56FE\u6807\u56FA\u5B9A\u6D45\u8272\uFF08windowLightStatusBar=false\uFF09\uFF0C\u4E0D\u8DDF\u968F\u7CFB\u7EDF\u660E\u6697 -->\n <style name=\"Theme.Vuexir\" parent=\"android:Theme.Material.NoActionBar\">\n <item name=\"android:windowBackground\">#0A0F1E</item>\n <item name=\"android:statusBarColor\">#0A0F1E</item>\n <item name=\"android:navigationBarColor\">#0A0F1E</item>\n <item name=\"android:windowLightStatusBar\">false</item>\n </style>\n</resources>\n";
4
- declare const MAIN_ACTIVITY: (projectName: string) => string;
4
+ declare const MAIN_ACTIVITY: (bundleId: string) => string;
5
5
  declare const THEME_KT: (bundleId: string) => string;
6
6
  declare const RUNTIME_KT: (bundleId: string) => string;
7
7
  declare const NAV_TEST_KT: (bundleId: string) => string;
@@ -40,12 +40,23 @@ declare function emitNode(node: IrNode, ctx: ComposeCtx, depth: number, loop?: {
40
40
  itemKey: string;
41
41
  indexKey?: string;
42
42
  }, useParamModifier?: boolean, parentStretch?: boolean): string;
43
- /** R40 组件表:页面同目录 + 向上逐级查找 components/*.vue(对位 gen-ios loadComponents,支持样例根共享组件)
44
- * 优先级:页面本地 components/ 覆盖祖先目录同名组件(先收集祖先、最后收集本地) */
45
43
  declare function loadComponents(pageFile: string): Record<string, string>;
46
44
  /** 组件 → 独立 Composable 文件(对位 iOS 组件独立 Swift 文件) */
47
45
  declare function emitComponentFile(name: string, ui: IrNode, bundleId: string): string;
48
- declare function parseRouter(src: string, dir: string): {
46
+ /**
47
+ * 工程根推导(R75):路由文件可能在 src/router/index.ts,`@/` 别名需要相对「工程根」解析。
48
+ * 自 startDir 向上找 含 package.json 或 src/ 的目录(最多 6 层)。
49
+ */
50
+ export declare function findProjectRoot(startDir: string): string;
51
+ /**
52
+ * R75 router 解析器 v2:支持真实 Vue 工程的路由写法
53
+ * - 静态 import X from 'p' 与 动态 component: () => import('p')
54
+ * - `@/` 别名(映射到 <appDir>/src/)与相对路径
55
+ * - 多行路由对象(按 {} 配平扫描,不再依赖整行正则)
56
+ * - 路径参数(/detail/:id)→ 原生侧降级为无参路径 + 警告(R15)
57
+ * 返回 tabs/details + warnings(由调用方打印)
58
+ */
59
+ declare function parseRouter(src: string, dir: string, routerFile?: string): {
49
60
  tabs: {
50
61
  id: string;
51
62
  title: string;
@@ -59,6 +70,7 @@ declare function parseRouter(src: string, dir: string): {
59
70
  file: string;
60
71
  path: string;
61
72
  }[];
73
+ warnings: string[];
62
74
  };
63
75
  export interface RunTabsOptions {
64
76
  /** 产物根(默认 <repo>/compiler/generated/android 或 VRN_OUT_DIR) */
@@ -67,6 +79,8 @@ export interface RunTabsOptions {
67
79
  projectName?: string;
68
80
  /** bundle id(默认 com.vrn.<projectname 小写>) */
69
81
  bundleId?: string;
82
+ /** 显式路由文件(支持 src/router/index.ts 等非标准命名;默认 <appDir>/router.ts) */
83
+ routerFile?: string;
70
84
  /** P2:工程骨架模板由 @dongmingzhixiu/vuexir-templates 提供、CLI 注入(避免 compiler → templates 的依赖倒挂)
71
85
  * 回调式:runTabs 计算完 projectName/bundleId 后再渲染模板,避免调用方拿不到最终值 */
72
86
  renderGradle?: (projectName: string, bundleId: string) => string;
@@ -79,7 +79,7 @@ const STYLES_XML = `<resources>
79
79
  </style>
80
80
  </resources>
81
81
  `;
82
- const MAIN_ACTIVITY = (projectName) => `package com.vrn.${projectName.toLowerCase()}
82
+ const MAIN_ACTIVITY = (bundleId) => `package ${bundleId}
83
83
 
84
84
  import android.graphics.Color
85
85
  import android.os.Bundle
@@ -91,6 +91,8 @@ import androidx.activity.enableEdgeToEdge
91
91
  class MainActivity : ComponentActivity() {
92
92
  override fun onCreate(savedInstanceState: Bundle?) {
93
93
  super.onCreate(savedInstanceState)
94
+ Store.init(applicationContext)
95
+ VrnBridge.init(this)
94
96
  // R-SYSTEMBAR:应用固定深色主题(InkBg/InkBar),与系统明暗设置无关。
95
97
  // enableEdgeToEdge() 默认用 SystemBarStyle.auto(...),会跟随系统明暗切换图标明暗——
96
98
  // 系统处于浅色模式时给出深色图标,落在深色顶栏/底栏上不可读(真机反馈:系统栏文字看不清)。
@@ -144,8 +146,127 @@ fun VuexirTheme(content: @Composable () -> Unit) {
144
146
  const RUNTIME_KT = (bundleId) => `package ${bundleId}
145
147
 
146
148
  // Vuexir Android 运行时桥(对位 iOS RUNTIME_SWIFT)
149
+ /** R73 持久化桥:对位 iOS runtime 的 localStorage(UserDefaults)—— SharedPreferences 同步读写 */
150
+ /** R78 对象字段取值:状态里存 JSON 字符串时按 key 取值 */
151
+ // R85 点路径取值:path 支持 "a.b.c",末段可为 length/size(数组/对象/字符串长度)
152
+ fun vrnField(json: String, path: String, fallback: String = ""): String {
153
+ val raw = vrnFieldRaw(json, path)
154
+ return if (raw.isEmpty()) fallback else raw
155
+ }
156
+
157
+ fun vrnFieldRaw(json: String, path: String): String {
158
+ return try {
159
+ var cur: Any = org.json.JSONTokener(json).nextValue()
160
+ for (seg in path.split(".")) {
161
+ cur = when {
162
+ seg == "length" || seg == "size" -> when (cur) {
163
+ is org.json.JSONArray -> cur.length()
164
+ is org.json.JSONObject -> cur.length()
165
+ is String -> cur.length
166
+ else -> return ""
167
+ }
168
+ cur is org.json.JSONObject && cur.has(seg) -> cur.get(seg)
169
+ else -> return ""
170
+ }
171
+ }
172
+ when (cur) {
173
+ is String -> cur
174
+ is Boolean -> if (cur) "true" else "false"
175
+ is Double -> if (cur == Math.floor(cur) && !cur.isInfinite()) cur.toInt().toString() else cur.toString()
176
+ is Int, is Long -> cur.toString()
177
+ else -> cur.toString()
178
+ }
179
+ } catch (e: Throwable) {
180
+ ""
181
+ }
182
+ }
183
+
184
+ object Store {
185
+ private var prefs: android.content.SharedPreferences? = null
186
+ fun init(ctx: android.content.Context) {
187
+ if (prefs == null) prefs = ctx.getSharedPreferences("vrn", android.content.Context.MODE_PRIVATE)
188
+ }
189
+ fun get(key: String, def: String = ""): String = prefs?.getString(key, def) ?: def
190
+ fun set(key: String, value: String) { prefs?.edit()?.putString(key, value)?.apply() }
191
+ fun remove(key: String) { prefs?.edit()?.remove(key)?.apply() }
192
+ }
193
+
194
+ /**
195
+ * R81 原生桥(Android)——编译期注入;Vue 侧 bridge.toast(...) 由编译器翻译为 VrnBridge.toast(...)
196
+ * 与 @dongmingzhixiu/vuexir-templates 的 VRN_BRIDGE_KT 保持一致
197
+ */
198
+ object VrnBridge {
199
+ private var appCtx: android.content.Context? = null
200
+ private var currentActivity: android.app.Activity? = null
201
+ fun init(ctx: android.content.Context) {
202
+ if (appCtx == null) appCtx = ctx.applicationContext
203
+ if (ctx is android.app.Activity) currentActivity = ctx
204
+ }
205
+
206
+ fun vibrate(milliseconds: Int = 18) {
207
+ val ctx = appCtx ?: return
208
+ try {
209
+ val v = ctx.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? android.os.Vibrator
210
+ if (v == null || !v.hasVibrator()) return
211
+ if (android.os.Build.VERSION.SDK_INT >= 26) {
212
+ v.vibrate(android.os.VibrationEffect.createOneShot(milliseconds.toLong(), android.os.VibrationEffect.DEFAULT_AMPLITUDE))
213
+ } else {
214
+ @Suppress("DEPRECATION")
215
+ v.vibrate(milliseconds.toLong())
216
+ }
217
+ } catch (e: Throwable) {
218
+ // 无马达/无权限:静默忽略
219
+ }
220
+ }
221
+
222
+ fun copy(text: String) {
223
+ val ctx = appCtx ?: return
224
+ try {
225
+ val cm = ctx.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
226
+ cm.setPrimaryClip(android.content.ClipData.newPlainText("vrn", text))
227
+ } catch (e: Throwable) {
228
+ // 忽略
229
+ }
230
+ }
231
+
232
+ fun setStatusBar(style: String) {
233
+ val act = currentActivity ?: return
234
+ android.os.Handler(act.mainLooper).post {
235
+ val light = style != "dark"
236
+ val controller = androidx.core.view.WindowCompat.getInsetsController(act.window, act.window.decorView)
237
+ controller.isAppearanceLightStatusBars = !light
238
+ controller.isAppearanceLightNavigationBars = !light
239
+ }
240
+ }
241
+
242
+ fun keepScreenOn(on: Boolean) {
243
+ val act = currentActivity ?: return
244
+ android.os.Handler(act.mainLooper).post {
245
+ if (on) act.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
246
+ else act.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
247
+ }
248
+ }
249
+ fun toast(message: String) {
250
+ val ctx = appCtx ?: return
251
+ android.os.Handler(ctx.mainLooper).post { android.widget.Toast.makeText(ctx, message, android.widget.Toast.LENGTH_SHORT).show() }
252
+ }
253
+ fun openUrl(url: String) {
254
+ val ctx = appCtx ?: return
255
+ try {
256
+ val i = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
257
+ ctx.startActivity(i)
258
+ } catch (e: Throwable) {
259
+ toast("无法打开链接")
260
+ }
261
+ }
262
+ }
263
+
147
264
  object Runtime {
148
265
  var pushHandler: ((String) -> Unit)? = null
266
+ var backHandler: (() -> Unit)? = null
267
+ fun back() {
268
+ backHandler?.invoke()
269
+ }
149
270
  fun push(route: String) {
150
271
  pushHandler?.invoke(route)
151
272
  }
@@ -216,6 +337,10 @@ function kotlinStr(s) {
216
337
  }
217
338
  /** 条件表达式 → Kotlin(对位 iOS swiftCondExpr:=== / !== 归一 + 单引号字符串转双引号) */
218
339
  function kotlinCondExpr(expr) {
340
+ // R78 续(G12):对象字段条件 → 非空判断
341
+ const f = expr.match(/^__vrnField__:(\w+):(\w+)$/);
342
+ if (f)
343
+ return `vrnField(${f[1]}, "${f[2]}").isNotEmpty()`;
219
344
  let s = expr.replace(/!==/g, '!=').replace(/===/g, '==');
220
345
  s = s.replace(/'(\\[^]|[^'\\])*'/g, (m) => '"' + m.slice(1, -1).replace(/"/g, '\\"') + '"');
221
346
  return s;
@@ -377,17 +502,35 @@ function arrangementExpr(props, kind) {
377
502
  const parts = [];
378
503
  const horiz = kind === 'Row' ? 'horizontalArrangement' : 'verticalArrangement';
379
504
  const vert = kind === 'Row' ? 'verticalAlignment' : 'horizontalAlignment';
380
- if (props.gap !== undefined)
381
- parts.push(`${horiz} = Arrangement.spacedBy(${dp(props.gap)})`);
505
+ // R79:gap 与 justify-content 共用同一个排列参数 → 必须合并(否则 Kotlin 报 duplicate argument)
382
506
  const just = props.justifyContent;
383
- if (just === 'center')
507
+ const justAlign = just === 'center'
508
+ ? kind === 'Row'
509
+ ? 'Alignment.CenterHorizontally'
510
+ : 'Alignment.CenterVertically'
511
+ : just === 'end'
512
+ ? kind === 'Row'
513
+ ? 'Alignment.End'
514
+ : 'Alignment.Bottom'
515
+ : '';
516
+ if (props.gap !== undefined && justAlign !== '') {
517
+ parts.push(`${horiz} = Arrangement.spacedBy(${dp(props.gap)}, ${justAlign})`);
518
+ }
519
+ else if (props.gap !== undefined) {
520
+ parts.push(`${horiz} = Arrangement.spacedBy(${dp(props.gap)})`);
521
+ }
522
+ else if (just === 'center') {
384
523
  parts.push(`${horiz} = Arrangement.Center`);
385
- else if (just === 'end')
524
+ }
525
+ else if (just === 'end') {
386
526
  parts.push(`${horiz} = Arrangement.${kind === 'Row' ? 'End' : 'Bottom'}`);
387
- else if (just === 'spaceBetween')
527
+ }
528
+ else if (just === 'spaceBetween') {
388
529
  parts.push(`${horiz} = Arrangement.SpaceBetween`);
389
- else if (just === 'spaceAround')
530
+ }
531
+ else if (just === 'spaceAround') {
390
532
  parts.push(`${horiz} = Arrangement.SpaceAround`);
533
+ }
391
534
  const align = props.alignItems;
392
535
  if (align === 'center')
393
536
  parts.push(`${vert} = Alignment.${kind === 'Row' ? 'CenterVertically' : 'CenterHorizontally'}`);
@@ -422,7 +565,8 @@ function loopField(v, loop) {
422
565
  function inlineHandlerKotlin(fn) {
423
566
  if (fn.params && fn.params.trim() !== '')
424
567
  return null; // 带参 handler 不内联(A5 需数据桥)
425
- if (/localStorage|window\.|document\.|fetch\(|setTimeout|alert\(|console\.|return\b|function/.test(fn.body))
568
+ // R73:localStorage / Number() 现已支持(见 jsExprToKotlin);其余(window/document/fetch/setTimeout/return/嵌套函数)仍走协程或降级
569
+ if (/window\.|document\.|fetch\(|setTimeout|alert\(|console\.|return\b|function/.test(fn.body))
426
570
  return null;
427
571
  const out = [];
428
572
  for (const raw of fn.body.split(/[\n;]+/)) {
@@ -599,6 +743,39 @@ function asyncLineKotlin(line, ctx) {
599
743
  return null;
600
744
  return `if (${cond}) {`;
601
745
  }
746
+ // R81/R84 原生桥语句:bridge.toast('x') / bridge.vibrate(18) / bridge.copy('x') …
747
+ const bm = line.match(/^bridge\.(toast|openUrl|vibrate|copy|setStatusBar|keepScreenOn)\((.+)\);?$/);
748
+ if (bm) {
749
+ const arg = jsExprToKotlin(bm[2].trim());
750
+ return arg === null ? null : `VrnBridge.${bm[1]}(${arg})`;
751
+ }
752
+ // G15 router 语句:router.push('x') / router.back() → Runtime.push / Runtime.back
753
+ const rp = line.match(/^router\.push\((.+)\);?$/);
754
+ if (rp) {
755
+ const a = jsExprToKotlin(rp[1].trim());
756
+ return a === null ? null : `Runtime.push(${a})`;
757
+ }
758
+ if (/^router\.back\(\)\s*;?$/.test(line))
759
+ return 'Runtime.back()';
760
+ // R73 localStorage(语句形态):setItem / removeItem
761
+ const lsSet = line.match(/^localStorage\.setItem\((.+),\s*(.+)\);?$/);
762
+ if (lsSet) {
763
+ const k = jsExprToKotlin(lsSet[1].trim());
764
+ const v = jsExprToKotlin(lsSet[2].trim());
765
+ if (k === null || v === null)
766
+ return null;
767
+ return `Store.set(${k}, ${v})`;
768
+ }
769
+ if (/^localStorage\.removeItem\(/.test(line)) {
770
+ const k = jsExprToKotlin(line.replace(/^localStorage\.removeItem\(/, '').replace(/\);?$/, ''));
771
+ return k === null ? null : `Store.remove(${k})`;
772
+ }
773
+ // 通用 `const x = <expr>` → `val x = <expr>`(置于 localStorage/fetch/JSON 专项之后)
774
+ const constAssign = line.match(/^const\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?);?$/);
775
+ if (constAssign) {
776
+ const v = jsExprToKotlin(constAssign[2].trim());
777
+ return v === null ? null : `val ${constAssign[1]} = ${v}`;
778
+ }
602
779
  // console.log(x) → Log.d
603
780
  const logLine = line.match(/^console\.log\((.+)\);?$/);
604
781
  if (logLine) {
@@ -648,6 +825,17 @@ function jsExprToKotlin(expr) {
648
825
  return s;
649
826
  // .length → .length()(JSONArray)
650
827
  s = s.replace(/\.length\b(?!\()/g, '.length()');
828
+ // R73 localStorage → Store(函数式写法:getItem 直接用;setItem/removeItem 走语句分支)
829
+ s = s.replace(/localStorage\.getItem\((.+)\)$/g, (_m, k) => `Store.get(${k})`);
830
+ // R81 bridge.* → VrnBridge.*(表达式形态)
831
+ s = s.replace(/\bbridge\.(toast|openUrl|vibrate|copy|setStatusBar|keepScreenOn)\(/g, (_m, fn) => `VrnBridge.${fn}(`);
832
+ // JS 单引号字符串 → Kotlin 双引号(Kotlin 的单引号是 Char 字面量,'x' 编译不过)
833
+ s = s.replace(/'([^']*)'/g, (_m, inner) => `"${inner.replace(/"/g, '\\"')}"`);
834
+ // JS 类型转换 → Kotlin
835
+ s = s.replace(/Number\((.+)\)$/g, (_m, inner) => `${inner}.toInt()`);
836
+ s = s.replace(/String\((.+)\)$/g, (_m, inner) => `${inner}.toString()`);
837
+ // JSON.stringify(x) → x.toString()(Kotlin 无 JSON 全局对象;与 JS 侧动态列表协议一致)
838
+ s = s.replace(/JSON\.stringify\(([^()]*)\)/g, (_m, inner) => `${inner}.toString()`);
651
839
  // 成员访问 a.b(对象状态字段)
652
840
  s = s.replace(/\bstateStore\./g, '');
653
841
  // 严格等号 → Kotlin
@@ -711,8 +899,10 @@ function emitNodeInner(node, ctx, depth, loop, useParamModifier = false, parentS
711
899
  if (typeof activeBg === 'string' && nd.active) {
712
900
  const when = kotlinCondExpr(nd.active.when);
713
901
  const active = colorExpr(activeBg);
714
- const base = typeof props.background === 'string' ? colorExpr(props.background) : undefined;
715
- return [`Modifier.background(${base ? `if (${when}) ${active} else ${base}` : active})`];
902
+ // 无基础背景时回退透明(对位 iOS `.background(when ? color : Color.clear)`)——
903
+ // 否则选中态会变成「所有行都高亮」(此前 bug)
904
+ const base = typeof props.background === 'string' ? colorExpr(props.background) : 'Color.Transparent';
905
+ return [`Modifier.background(if (${when}) ${active} else ${base})`];
716
906
  }
717
907
  return visualModifiers(props);
718
908
  };
@@ -824,11 +1014,29 @@ function emitNodeInner(node, ctx, depth, loop, useParamModifier = false, parentS
824
1014
  return String(seg.text).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '${\'$\'}').replace(/\n/g, '\\n');
825
1015
  if ('bind' in seg)
826
1016
  return '${' + loopField(seg.bind, loop) + '}';
1017
+ if ('fieldOf' in seg)
1018
+ return ('${vrnField(' +
1019
+ seg.fieldOf.obj +
1020
+ ', "' +
1021
+ seg.fieldOf.field +
1022
+ '"' +
1023
+ (seg.fieldOf.fallback !== undefined ? ', "' + seg.fieldOf.fallback.replace(/"/g, '\\"') + '"' : '') +
1024
+ ')}');
827
1025
  return '${' + loopField(seg.dynField.var, loop) + '["' + seg.dynField.field + '"] ?: ""}';
828
1026
  })
829
1027
  .join('');
830
1028
  textExpr = '"' + segs + '"';
831
1029
  }
1030
+ else if (p.fieldOf) {
1031
+ textExpr =
1032
+ '"${vrnField(' +
1033
+ p.fieldOf.obj +
1034
+ ', "' +
1035
+ p.fieldOf.field +
1036
+ '"' +
1037
+ (p.fieldOf.fallback !== undefined ? ', "' + p.fieldOf.fallback.replace(/"/g, '\\"') + '"' : '') +
1038
+ ')}"';
1039
+ }
832
1040
  else if (p.dynField) {
833
1041
  textExpr = '"${' + loopField(p.dynField.var, loop) + '["' + p.dynField.field + '"] ?: ""}"';
834
1042
  }
@@ -1028,6 +1236,26 @@ function emitNodeInner(node, ctx, depth, loop, useParamModifier = false, parentS
1028
1236
  /* ================= Router 解析(对位 gen-ios.ts parseRouter) ================= */
1029
1237
  /** R40 组件表:页面同目录 + 向上逐级查找 components/*.vue(对位 gen-ios loadComponents,支持样例根共享组件)
1030
1238
  * 优先级:页面本地 components/ 覆盖祖先目录同名组件(先收集祖先、最后收集本地) */
1239
+ /** R86 扫描工程内的 Pinia store 源码(src/stores/*.ts),供编译器做状态折叠 */
1240
+ function loadStoreSources(anchor) {
1241
+ const table = {};
1242
+ let up = anchor;
1243
+ for (let i = 0; i < 6 && up !== dirname(up); i++) {
1244
+ for (const cand of [join(up, 'stores'), join(up, 'src', 'stores')]) {
1245
+ if (existsSync(cand)) {
1246
+ for (const f of readdirSync(cand)) {
1247
+ if (!/\.(ts|js|mts)$/.test(f))
1248
+ continue;
1249
+ table[f] = readFileSync(join(cand, f), 'utf-8');
1250
+ }
1251
+ if (Object.keys(table).length > 0)
1252
+ return table;
1253
+ }
1254
+ }
1255
+ up = dirname(up);
1256
+ }
1257
+ return table;
1258
+ }
1031
1259
  function loadComponents(pageFile) {
1032
1260
  const dirs = [];
1033
1261
  let up = dirname(pageFile);
@@ -1101,51 +1329,139 @@ function tabLabel(title) {
1101
1329
  const t = title.replace(/\s+/g, '');
1102
1330
  return t.length > 2 ? t.match(/.{1,2}/g).join('\\n') : t;
1103
1331
  }
1104
- function parseRouter(src, dir) {
1332
+ /**
1333
+ * 工程根推导(R75):路由文件可能在 src/router/index.ts,`@/` 别名需要相对「工程根」解析。
1334
+ * 自 startDir 向上找 含 package.json 或 src/ 的目录(最多 6 层)。
1335
+ */
1336
+ export function findProjectRoot(startDir) {
1337
+ let d = startDir;
1338
+ for (let i = 0; i < 6; i += 1) {
1339
+ if (existsSync(join(d, 'package.json')) || existsSync(join(d, 'src')))
1340
+ return d;
1341
+ const parent = dirname(d);
1342
+ if (parent === d)
1343
+ break;
1344
+ d = parent;
1345
+ }
1346
+ return startDir;
1347
+ }
1348
+ /**
1349
+ * R75 router 解析器 v2:支持真实 Vue 工程的路由写法
1350
+ * - 静态 import X from 'p' 与 动态 component: () => import('p')
1351
+ * - `@/` 别名(映射到 <appDir>/src/)与相对路径
1352
+ * - 多行路由对象(按 {} 配平扫描,不再依赖整行正则)
1353
+ * - 路径参数(/detail/:id)→ 原生侧降级为无参路径 + 警告(R15)
1354
+ * 返回 tabs/details + warnings(由调用方打印)
1355
+ */
1356
+ function parseRouter(src, dir, routerFile) {
1357
+ const warnings = [];
1105
1358
  const imports = {};
1106
1359
  for (const m of src.matchAll(/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g))
1107
1360
  imports[m[1]] = m[2];
1361
+ /** 别名/相对路径 → 磁盘路径(先找 @/ → src,再退回 appDir 根) */
1362
+ const baseDir = routerFile ? dirname(routerFile) : dir;
1363
+ const resolveFile = (spec) => {
1364
+ const cands = [];
1365
+ if (spec.startsWith('@/')) {
1366
+ const rel = spec.slice(2);
1367
+ cands.push(join(dir, 'src', rel), join(dir, rel));
1368
+ }
1369
+ else if (spec.startsWith('./') || spec.startsWith('../')) {
1370
+ // 相对路径以「路由文件所在目录」为基准
1371
+ cands.push(join(baseDir, spec), join(dir, spec));
1372
+ }
1373
+ else {
1374
+ cands.push(join(dir, spec), join(dir, 'src', spec));
1375
+ }
1376
+ return cands.find((c) => existsSync(c)) ?? cands[0];
1377
+ };
1378
+ // 配平扫描所有「含 path:」的对象块
1379
+ const blocks = [];
1380
+ for (let i = 0; i < src.length; i += 1) {
1381
+ if (src[i] !== '{')
1382
+ continue;
1383
+ let depth = 0;
1384
+ let quote = '';
1385
+ let j = i;
1386
+ for (; j < src.length; j += 1) {
1387
+ const ch = src[j];
1388
+ if (quote) {
1389
+ if (ch === quote)
1390
+ quote = '';
1391
+ continue;
1392
+ }
1393
+ if (ch === '"' || ch === "'" || ch === '`') {
1394
+ quote = ch;
1395
+ continue;
1396
+ }
1397
+ if (ch === '{')
1398
+ depth += 1;
1399
+ else if (ch === '}') {
1400
+ depth -= 1;
1401
+ if (depth === 0)
1402
+ break;
1403
+ }
1404
+ }
1405
+ const block = src.slice(i, j + 1);
1406
+ if (/\bpath\s*:/.test(block))
1407
+ blocks.push(block);
1408
+ i = j;
1409
+ }
1108
1410
  const routes = [];
1109
- for (const m of src.matchAll(/\{\s*path:\s*'([^']*)'\s*,\s*name:\s*'([^']*)'\s*,\s*component:\s*(\w+)(?:\s*,\s*meta:\s*\{([^}]*)\})?\s*\}/g)) {
1411
+ for (const block of blocks) {
1412
+ const pm = block.match(/\bpath\s*:\s*['"]([^'"]*)['"]/);
1413
+ if (!pm)
1414
+ continue;
1415
+ const rawPath = pm[1];
1416
+ const nm = block.match(/\bname\s*:\s*['"]([^'"]*)['"]/);
1417
+ const cm = block.match(/\bcomponent\s*:\s*(?:\(\s*\)\s*=>\s*)?import\(\s*['"]([^'"]+)['"]\s*\)|\bcomponent\s*:\s*([A-Za-z_$][\w$]*)/);
1418
+ const compSpec = cm?.[1];
1419
+ const compIdent = cm?.[2];
1420
+ const file = compSpec ? resolveFile(compSpec) : compIdent ? resolveFile(imports[compIdent] ?? '') : '';
1421
+ // meta 解析(允许单行/多行)
1110
1422
  const meta = {};
1111
- const raw = m[4] ?? '';
1112
- for (const kv of raw.split(',')) {
1113
- const mm = kv.match(/^\s*(\w+):\s*(true|false|'([^']*)'|"([^"]*)"|\w+)\s*$/);
1114
- if (!mm)
1115
- continue;
1116
- meta[mm[1]] = mm[2] === 'true' ? 'true' : mm[2] === 'false' ? 'false' : mm[3] ?? mm[4] ?? mm[2];
1423
+ const mm = block.match(/\bmeta\s*:\s*\{([\s\S]*?)\}/);
1424
+ if (mm) {
1425
+ for (const kv of mm[1].split(',')) {
1426
+ const r = kv.match(/^\s*(\w+)\s*:\s*(true|false|'([^']*)'|"([^"]*)"|\w+)\s*$/);
1427
+ if (!r)
1428
+ continue;
1429
+ meta[r[1]] = r[2] === 'true' ? 'true' : r[2] === 'false' ? 'false' : r[3] ?? r[4] ?? r[2];
1430
+ }
1117
1431
  }
1118
- routes.push({ path: m[1], name: m[2], component: m[3], meta });
1432
+ const path = rawPath.replace(/\/:\w+\??/g, '');
1433
+ if (path !== rawPath)
1434
+ warnings.push(`路由「${rawPath}」含路径参数,原生侧降级为无参路径「${path || '/'}」(R15)`);
1435
+ routes.push({
1436
+ path: path === '' ? '/' : path,
1437
+ name: nm?.[1] ?? (path.replace(/\//g, '-').replace(/^-|-$/g, '') || 'home'),
1438
+ component: compIdent ?? '',
1439
+ file,
1440
+ meta,
1441
+ });
1119
1442
  }
1120
- const tabs = routes
1121
- .filter((r) => r.meta.tab === 'true')
1122
- .filter((r) => {
1123
- const p = join(dir, imports[r.component] ?? '');
1124
- if (!existsSync(p))
1443
+ const hasText = (file) => {
1444
+ if (!file || !existsSync(file))
1125
1445
  return true;
1126
- const s = readFileSync(p, 'utf-8');
1127
- const tpl = s.match(/<template>([\s\S]*)<\/template>/)?.[1] ?? '';
1128
- const textOnly = tpl.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
1129
- return textOnly.length > 0;
1130
- })
1446
+ const tpl = readFileSync(file, 'utf-8').match(/<template>([\s\S]*)<\/template>/)?.[1] ?? '';
1447
+ return tpl.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().length > 0;
1448
+ };
1449
+ const tabs = routes
1450
+ .filter((r) => r.meta.tab === 'true' && hasText(r.file))
1131
1451
  .map((r, i) => ({
1132
1452
  id: r.name,
1133
1453
  title: r.meta.title ?? r.name,
1134
- file: join(dir, imports[r.component] ?? ''),
1454
+ file: r.file,
1135
1455
  icon: r.meta.icon ?? TAB_ICONS[i % TAB_ICONS.length],
1136
1456
  path: r.path,
1137
1457
  }));
1138
1458
  const details = routes
1139
1459
  .filter((r) => r.meta.tab !== 'true')
1140
- .map((r) => ({
1141
- id: r.name,
1142
- title: r.meta.title ?? r.name,
1143
- file: join(dir, imports[r.component] ?? ''),
1144
- path: r.path,
1145
- }));
1146
- if (tabs.length === 0)
1147
- throw new Error('router.ts 未声明 tab 路由');
1148
- return { tabs, details };
1460
+ .map((r) => ({ id: r.name, title: r.meta.title ?? r.name, file: r.file, path: r.path }));
1461
+ if (tabs.length === 0) {
1462
+ warnings.push('router 未解析到任何 tab 路由(routes 需含 meta: { tab: true })');
1463
+ }
1464
+ return { tabs, details, warnings };
1149
1465
  }
1150
1466
  /* ================= 工程生成(对位 runTabs) ================= */
1151
1467
  /** Material Icons 映射(router meta.icon:iOS SF Symbols / 简写 → compose material icon) */
@@ -1169,8 +1485,10 @@ const ICON_MAP = {
1169
1485
  explore: 'Icons.Filled.Explore', work: 'Icons.Filled.Work', settings: 'Icons.Filled.Settings',
1170
1486
  };
1171
1487
  function runTabs(appDir, opts = {}) {
1172
- const routerSrc = readFileSync(join(appDir, 'router.ts'), 'utf-8');
1173
- const { tabs, details } = parseRouter(routerSrc, appDir);
1488
+ const routerSrc = readFileSync(opts.routerFile ?? join(appDir, 'router.ts'), 'utf-8');
1489
+ const { tabs, details, warnings: routerWarnings } = parseRouter(routerSrc, findProjectRoot(appDir), opts.routerFile ?? join(appDir, 'router.ts'));
1490
+ for (const w of routerWarnings)
1491
+ console.log(`[gen-android] 路由提示: ${w}`);
1174
1492
  console.log(`[gen-android] 多页模式: router.ts → ${tabs.length} 个 tab 页面` + (details.length ? ` + ${details.length} 个 push 页面` : ''));
1175
1493
  const projectName = opts.projectName ? toPascal(opts.projectName) : toPascal(basename(appDir));
1176
1494
  const bundleId = opts.bundleId ?? `com.vrn.${projectName.toLowerCase()}`;
@@ -1191,7 +1509,7 @@ function runTabs(appDir, opts = {}) {
1191
1509
  mkdirSync(resDir, { recursive: true });
1192
1510
  writeFileSync(join(resDir, 'styles.xml'), STYLES_XML);
1193
1511
  writeFileSync(join(pkgDir, 'Theme.kt'), THEME_KT(bundleId));
1194
- writeFileSync(join(pkgDir, 'MainActivity.kt'), MAIN_ACTIVITY(projectName));
1512
+ writeFileSync(join(pkgDir, 'MainActivity.kt'), MAIN_ACTIVITY(bundleId));
1195
1513
  writeFileSync(join(pkgDir, 'Runtime.kt'), RUNTIME_KT(bundleId));
1196
1514
  // NavTest 断言绑定 vuexir-demo 文案:仅对参考样例生成,其他样例跳过(防无意义失败用例)
1197
1515
  if (projectName.toLowerCase() === 'vuexirdemo') {
@@ -1221,7 +1539,12 @@ function runTabs(appDir, opts = {}) {
1221
1539
  }
1222
1540
  const src = readFileSync(tab.file, 'utf-8');
1223
1541
  const pageId = toPascal(tab.id);
1224
- const r = vueToIr(src, { screenId: tab.id, title: tab.title, components: loadComponents(tab.file) });
1542
+ const r = vueToIr(src, {
1543
+ screenId: tab.id,
1544
+ title: tab.title,
1545
+ components: loadComponents(tab.file),
1546
+ storeSources: loadStoreSources(tab.file),
1547
+ });
1225
1548
  if (r.warnings.length > 0) {
1226
1549
  console.log(`[gen-android] 编译提示(${tab.id},${r.warnings.length} 条):`);
1227
1550
  for (const w of r.warnings)
@@ -1254,7 +1577,12 @@ function runTabs(appDir, opts = {}) {
1254
1577
  for (const d of details) {
1255
1578
  const src = readFileSync(d.file, 'utf-8');
1256
1579
  const pageId = toPascal(d.id);
1257
- const r = vueToIr(src, { screenId: d.id, title: d.title, components: loadComponents(d.file) });
1580
+ const r = vueToIr(src, {
1581
+ screenId: d.id,
1582
+ title: d.title,
1583
+ components: loadComponents(d.file),
1584
+ storeSources: loadStoreSources(d.file),
1585
+ });
1258
1586
  if (r.warnings.length > 0) {
1259
1587
  console.log(`[gen-android] 编译提示(${d.id},${r.warnings.length} 条):`);
1260
1588
  for (const w of r.warnings)
@@ -36,6 +36,7 @@ export declare function buildTheme(tokens: {
36
36
  fontNames: Map<string, string>;
37
37
  };
38
38
  /** R42 生成 Theme.swift(颜色/字体去重常量) */
39
+ export declare const VRN_HELPERS_SWIFT = "import Foundation\n\n// vuexir \u8FD0\u884C\u65F6\u8F85\u52A9\uFF08R78 \u5BF9\u8C61\u5B57\u6BB5\u53D6\u503C\uFF09\n/// R78 \u5BF9\u8C61\u5B57\u6BB5\u53D6\u503C\uFF1A\u72B6\u6001\u91CC\u5B58 JSON \u5B57\u7B26\u4E32\u65F6\u6309 key \u53D6\u503C\uFF08\u975E JSON \u2192 \u8FD4\u56DE\u7A7A\u4E32\uFF09\n/// R85 \u70B9\u8DEF\u5F84\u53D6\u503C\uFF1Akey \u652F\u6301 \"a.b.c\" \u4E0E\u672B\u6BB5 \".length\"\uFF08\u6570\u7EC4/\u5B57\u5178/\u5B57\u7B26\u4E32\u957F\u5EA6\uFF09\nfunc vrnField(_ json: String, _ path: String, _ fallback: String = \"\") -> String {\n let raw = vrnFieldRaw(json, path)\n return raw.isEmpty ? fallback : raw\n}\n\n/// \u539F\u59CB\u53D6\u503C\uFF08\u4E0D\u5957\u515C\u5E95\uFF09\uFF1A\u70B9\u8DEF\u5F84\u4E0B\u94BB\uFF0C\u7F3A\u5931\u8FD4\u56DE\u7A7A\u4E32\nprivate func vrnFieldRaw(_ json: String, _ path: String) -> String {\n guard let data = json.data(using: .utf8),\n let root = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { return \"\" }\n var cur: Any = root\n for seg in path.split(separator: \".\").map(String.init) {\n if seg == \"length\" || seg == \"size\" {\n if let a = cur as? [Any] { cur = a.count }\n else if let d = cur as? [String: Any] { cur = d.count }\n else if let s = cur as? String { cur = s.count }\n else { return \"\" }\n continue\n }\n if let d = cur as? [String: Any], let next = d[seg] { cur = next } else { return \"\" }\n }\n if let s = cur as? String { return s }\n if let b = cur as? Bool { return b ? \"true\" : \"false\" }\n if let n = cur as? NSNumber { return n.stringValue }\n return String(describing: cur)\n}\n";
39
40
  export declare function generateThemeSource(palette: {
40
41
  colorNames: Map<string, string>;
41
42
  fontNames: Map<string, string>;