modelstage 0.0.0-stage → 0.5.24

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.
Files changed (286) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +101 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +58 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +647 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +334 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/web/README.md +193 -0
  126. package/src/web/app/anim-list.js +222 -0
  127. package/src/web/app/background-solids.js +150 -0
  128. package/src/web/app/backgrounds.js +152 -0
  129. package/src/web/app/camera-log.js +28 -0
  130. package/src/web/app/context.js +72 -0
  131. package/src/web/app/dir-picker.js +242 -0
  132. package/src/web/app/entry-report.js +71 -0
  133. package/src/web/app/memory-bars.js +91 -0
  134. package/src/web/app/memory-budget.js +228 -0
  135. package/src/web/app/memory-guard.js +204 -0
  136. package/src/web/app/model-actions.js +682 -0
  137. package/src/web/app/renderer-setting.js +70 -0
  138. package/src/web/app/root-manager.js +254 -0
  139. package/src/web/app/status.js +138 -0
  140. package/src/web/app/topbar-actions.js +357 -0
  141. package/src/web/app/transport-actions.js +297 -0
  142. package/src/web/app/ui-state.js +559 -0
  143. package/src/web/app/url-params.js +171 -0
  144. package/src/web/app/view-settings.js +186 -0
  145. package/src/web/components.css +465 -0
  146. package/src/web/debug/diag-hud.js +95 -0
  147. package/src/web/debug/drag.js +369 -0
  148. package/src/web/debug/log.js +320 -0
  149. package/src/web/debug/model-cmp.js +292 -0
  150. package/src/web/debug/panel.css +474 -0
  151. package/src/web/debug/panel.js +712 -0
  152. package/src/web/debug/race-probe.js +82 -0
  153. package/src/web/debug/upload.js +130 -0
  154. package/src/web/engine/adapters/atlas.js +47 -0
  155. package/src/web/engine/adapters/index.js +22 -0
  156. package/src/web/engine/adapters/renderer.js +223 -0
  157. package/src/web/engine/adapters/skeleton.js +105 -0
  158. package/src/web/engine/adapters/texture.js +64 -0
  159. package/src/web/engine/animation.js +181 -0
  160. package/src/web/engine/appearance.js +136 -0
  161. package/src/web/engine/camera-adapter.js +212 -0
  162. package/src/web/engine/effect-chain.js +138 -0
  163. package/src/web/engine/engine-state.js +120 -0
  164. package/src/web/engine/filters.js +224 -0
  165. package/src/web/engine/fit.js +197 -0
  166. package/src/web/engine/frame-gate.js +33 -0
  167. package/src/web/engine/gl-driver.js +56 -0
  168. package/src/web/engine/gl.js +77 -0
  169. package/src/web/engine/index.js +20 -0
  170. package/src/web/engine/loader.js +55 -0
  171. package/src/web/engine/model-info.js +26 -0
  172. package/src/web/engine/pipeline.js +200 -0
  173. package/src/web/engine/player.js +427 -0
  174. package/src/web/engine/pma/blend.js +76 -0
  175. package/src/web/engine/pma/detect.js +118 -0
  176. package/src/web/engine/pma/index.js +8 -0
  177. package/src/web/engine/pma/resolve.js +112 -0
  178. package/src/web/engine/pma/upload.js +127 -0
  179. package/src/web/engine/pma.js +5 -0
  180. package/src/web/engine/render-frame.js +93 -0
  181. package/src/web/engine/runtime-capabilities.js +209 -0
  182. package/src/web/engine/runtime.js +37 -0
  183. package/src/web/engine/skins.js +165 -0
  184. package/src/web/engine/textures.js +174 -0
  185. package/src/web/index.html +115 -0
  186. package/src/web/infra/api.js +531 -0
  187. package/src/web/infra/diag.js +509 -0
  188. package/src/web/infra/document.js +269 -0
  189. package/src/web/infra/dom.js +31 -0
  190. package/src/web/infra/events.mjs +27 -0
  191. package/src/web/infra/log.js +153 -0
  192. package/src/web/infra/mem-stats.js +112 -0
  193. package/src/web/infra/plugin-host-registration.mjs +285 -0
  194. package/src/web/infra/plugin-host-report.mjs +32 -0
  195. package/src/web/infra/plugin-host-unload.mjs +79 -0
  196. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  197. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  198. package/src/web/infra/plugin-host.js +628 -0
  199. package/src/web/infra/ready.js +461 -0
  200. package/src/web/infra/schedule.js +51 -0
  201. package/src/web/infra/shortcuts.js +129 -0
  202. package/src/web/infra/slot-registry.js +402 -0
  203. package/src/web/infra/vfs.js +247 -0
  204. package/src/web/live2d/bridge.js +400 -0
  205. package/src/web/live2d/model.js +321 -0
  206. package/src/web/live2d/overlay.js +142 -0
  207. package/src/web/live2d/pixi-app.js +111 -0
  208. package/src/web/live2d/playback-probe.js +92 -0
  209. package/src/web/live2d/probe-geometry.js +329 -0
  210. package/src/web/live2d/probe.js +119 -0
  211. package/src/web/live2d/release.js +66 -0
  212. package/src/web/live2d/runtime-adapter.js +289 -0
  213. package/src/web/live2d/stage-ctx.js +152 -0
  214. package/src/web/live2d/stage.js +204 -0
  215. package/src/web/live2d/vfs-settings.js +174 -0
  216. package/src/web/live2d/view.js +89 -0
  217. package/src/web/main.js +629 -0
  218. package/src/web/panel/background-list.js +373 -0
  219. package/src/web/panel/bg-source.js +291 -0
  220. package/src/web/panel/color-picker.css +199 -0
  221. package/src/web/panel/color-picker.js +278 -0
  222. package/src/web/panel/dialog.css +116 -0
  223. package/src/web/panel/dialog.js +173 -0
  224. package/src/web/panel/dom.js +35 -0
  225. package/src/web/panel/icons.js +58 -0
  226. package/src/web/panel/memory-bar.js +195 -0
  227. package/src/web/panel/panel-settings.js +1263 -0
  228. package/src/web/panel/panel.css +1304 -0
  229. package/src/web/panel/render-choice.js +104 -0
  230. package/src/web/panel/schema.js +784 -0
  231. package/src/web/panel/settings-io.js +291 -0
  232. package/src/web/panel/shell-controls.js +274 -0
  233. package/src/web/panel/slider.js +210 -0
  234. package/src/web/panel/toolbar-schema.js +111 -0
  235. package/src/web/panel/toolbar.js +195 -0
  236. package/src/web/panel/transport-picker.js +375 -0
  237. package/src/web/panel/transport.js +321 -0
  238. package/src/web/panel/view.js +553 -0
  239. package/src/web/plugin/unpack-worker.js +185 -0
  240. package/src/web/render/camera/constants.js +60 -0
  241. package/src/web/render/camera/index.js +123 -0
  242. package/src/web/render/camera/spine.js +124 -0
  243. package/src/web/render/capture.js +30 -0
  244. package/src/web/render/interaction.js +527 -0
  245. package/src/web/render/live2d.js +627 -0
  246. package/src/web/render/playback.js +47 -0
  247. package/src/web/render/reset-view.js +29 -0
  248. package/src/web/render/spine.js +386 -0
  249. package/src/web/render/surface-switch.js +176 -0
  250. package/src/web/render/surface.js +571 -0
  251. package/src/web/render/surfaces.js +70 -0
  252. package/src/web/roster/filter.js +31 -0
  253. package/src/web/roster/list.js +276 -0
  254. package/src/web/roster/persist.js +52 -0
  255. package/src/web/roster/reveal.js +77 -0
  256. package/src/web/roster/rows.js +248 -0
  257. package/src/web/roster/snapshot.js +37 -0
  258. package/src/web/roster/view.js +670 -0
  259. package/src/web/spine/canvas-api-groups.js +267 -0
  260. package/src/web/spine/canvas-api.js +97 -0
  261. package/src/web/spine/canvas-document.js +80 -0
  262. package/src/web/spine/canvas-engine.js +193 -0
  263. package/src/web/spine/canvas-entry.js +28 -0
  264. package/src/web/spine/canvas-pixels.js +36 -0
  265. package/src/web/spine/canvas-runtime.js +127 -0
  266. package/src/web/spine/canvas-view.js +108 -0
  267. package/src/web/spine/canvas.js +7 -0
  268. package/src/web/spine/runtime-hint.js +73 -0
  269. package/src/web/spine/runtimes.js +212 -0
  270. package/src/web/style.css +493 -0
  271. package/tool/lib/bundle-probe-node.mjs +121 -0
  272. package/tool/lib/check-perms.mjs +114 -0
  273. package/tool/lib/contract-checks.mjs +582 -0
  274. package/tool/lib/cubism-core.mjs +122 -0
  275. package/tool/lib/export-bundle.mjs +328 -0
  276. package/tool/lib/paths.mjs +20 -0
  277. package/tool/lib/runtime-guides.mjs +65 -0
  278. package/tool/lib/runtime-package.mjs +403 -0
  279. package/tool/lib/spine-meta.mjs +227 -0
  280. package/tool/lib/spine-motions.mjs +104 -0
  281. package/tool/lib/spine-runtime.mjs +131 -0
  282. package/tool/lib/test-lock.mjs +178 -0
  283. package/tool/lib/test-report.mjs +133 -0
  284. package/tool/parse-jsonc.mjs +86 -0
  285. package/tool/resolve-live2d-runtimes.mjs +276 -0
  286. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,276 @@
1
+ // roster.js —— 名册数据层:资源根 → 模型列表 → 角色归并(M2)
2
+ //
3
+ // 做什么:
4
+ // 1. 按资源根取模型清单(三类来源,按根的 kind 与扫描结果选择);
5
+ // 2. 取该根的角色名表并与其它根合并(同一份名表只取一次);
6
+ // 3. 把模型归并成「角色 → 变体」,规则固定(不各自发明):
7
+ // · charKeyOf:去 cutscene_ 前缀 → 去 _hx 后缀 → 去尾部 _N(顺序不可换,见该文件注释)
8
+ // · assetCandidates:查表逐级放宽 原样 → 剥 _hx → 再剥 _N
9
+ // · 命中名表用英文名做分组键、标题显示「中文名 英文名」;未命中用 charKeyOf 结果兜底
10
+ // · 变体排序:立绘在前、技能(cutscene_)在后,再按资源名数字序
11
+ // · 角色排序:按显示名
12
+ // 4. 本模块只做数据,不碰 DOM(DOM 在 roster-view.js),便于单独验证归并结果。
13
+ //
14
+ // 数据源选择(实测依据):
15
+ // · 统一走 /api/catalog:它把 spine 模型、live2d 模型、容器单文件合并成一份清单,
16
+ // 所以发现阶段不再按"根的 kind"猜该问哪个扫描器(同一个根可以混合内容);
17
+ // · 容器单文件(资源包)由列举层用魔数标记 `container`,kind 取插件声明的产出类型;
18
+ // 改取 /api/live2d/models。
19
+
20
+ import { pickName } from '/shared/names.mjs';
21
+ import { getJson } from '../infra/api.js';
22
+ import { diagEvent } from '../app/status.js';
23
+ // 类型显示名来自面元数据表(label 字段)
24
+ import { byKind } from '../render/surfaces.js';
25
+
26
+ /** 资源名后缀归一化候选(与 char-tree.js 的 ASSET_SUFFIX_PATTERNS 一致) */
27
+ const ASSET_SUFFIX_PATTERNS = [/_hx$/i];
28
+
29
+ /**
30
+ * 基础角色键:去 cutscene_ 前缀 → 去 _hx → 去尾部 _N。
31
+ * 顺序不可换:`dafeng_2_hx` 先剥 _N 会因结尾非数字而剥不动,必须先剥 _hx。
32
+ * @param {string} name
33
+ * @returns {string}
34
+ */
35
+ export function charKeyOf(name) {
36
+ return String(name || '')
37
+ .replace(/^cutscene_/i, '')
38
+ .replace(/_hx$/i, '')
39
+ .replace(/_\d+$/, '');
40
+ }
41
+
42
+ /**
43
+ * 查表候选键(小写),逐级放宽:原样 → 剥 _hx → 再剥尾部 _N。
44
+ * @param {string} name
45
+ * @returns {string[]}
46
+ */
47
+ export function assetCandidates(name) {
48
+ // 键 = 真实资源文件名,**保留原始大小写**(设计:名表键原样,不做归一)
49
+ const raw = String(name || '');
50
+ const out = [raw];
51
+ for (const re of ASSET_SUFFIX_PATTERNS) {
52
+ const prev = out[out.length - 1];
53
+ const next = prev.replace(re, '');
54
+ if (next && next !== prev) out.push(next);
55
+ }
56
+ const prev = out[out.length - 1];
57
+ const noNum = prev.replace(/_\d+$/, '');
58
+ if (noNum && noNum !== prev) out.push(noNum);
59
+ return out;
60
+ }
61
+
62
+ /** 名表合并容器:byAsset(资源名 → 角色 id)+ characters(id → 角色信息) */
63
+ export function createNameIndex() {
64
+ return { byAsset: {}, characters: {}, cache: new Map() };
65
+ }
66
+
67
+ /**
68
+ * 把一份名表并进索引(多个资源根共用同一份名表时只并一次)。
69
+ * @param {{byAsset?: object, characters?: object}} index
70
+ * @param {{byAsset?: object, characters?: object}} table
71
+ */
72
+ export function mergeNameTable(index, table) {
73
+ if (!table) return;
74
+ Object.assign(index.byAsset, table.byAsset || {});
75
+ Object.assign(index.characters, table.characters || {});
76
+ index.cache.clear(); // 合并后旧查询结果作废
77
+ }
78
+
79
+ /**
80
+ * 查角色名(带缓存)。命中返回 {name, costume},未命中返回 null。
81
+ * @param {{byAsset: object, characters: object, cache: Map}} index
82
+ * @param {string} assetName
83
+ * @returns {{name: string, costume: string}|null}
84
+ */
85
+ export function lookupName(index, assetName) {
86
+ // 精确匹配:资产名与名表键都按**原样**比对,不做小写归一(名表键 = 真实文件名)。
87
+ // 归一化会让「只差大小写的两个资产」被错误合并,也会掩盖名表里写错的键。
88
+ const raw = String(assetName || '');
89
+ if (index.cache.has(raw)) return index.cache.get(raw);
90
+ let hit = null;
91
+ if (index.byAsset && index.characters) {
92
+ for (const key of assetCandidates(raw)) {
93
+ // byAsset 的值就只是角色 id 字符串,没有别的形态
94
+ const id = index.byAsset[key];
95
+ const c = id ? index.characters[id] : null;
96
+ const name = c ? pickName(c.name) : '';
97
+ if (name) {
98
+ hit = { name, costume: c ? pickName(c.costume) : '' };
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ index.cache.set(raw, hit);
104
+ return hit;
105
+ }
106
+
107
+ /**
108
+ * 取一个资源根的模型清单 —— **全仓唯一的 catalog 消费入口**。
109
+ *
110
+ * 为什么抽出来:此前有两处各写一遍、失败处理还不一致(url-params 静默吞 ⇒ 深链失效零提示;
111
+ * roster 直接抛 ⇒ 上层各自 catch)。现在统一成:失败一律
112
+ * ① 记一条 diag warn(带 root id 与原因)② 返回空清单 + 可读 reason(**不抛**),
113
+ * 由调用方按自己的语义决定"要不要把 reason 变成用户可见的失败状态"。
114
+ * 判据只看"有没有拿到 entries 数组",**不看 HTTP 状态码**
115
+ * (服务端对缺 root 的状态码正在从 404 改 400,按状态码写死会跟着坏)。
116
+ * `warnings` / `rootWarning` / `reason` 是**三档不同的东西**,别混:
117
+ * · reason = **拿不到清单**(rootId 不存在 / 5xx / 网络失败)—— 查询失败,可考虑重试
118
+ * · rootWarning = **清单拿到了,但这一根自己的扫描有问题**(服务端结构化字段,正常根为 null):
119
+ * 路径不存在 / 不是目录 / 不可读 / 扫描失败 / 扫完没有任何条目。
120
+ * **这是唯一可以拿去拼用户提示的一档**(文案由服务端给,带 rootId、不含绝对路径)
121
+ * · warnings = 通用说明(如"有 2 个插件能解包"),**混着与根无关的内容**。
122
+ * 拿它拼因果会得出"因为有两个插件所以没模型"这种错误结论 —— 只能进日志,不许进提示。
123
+ * @param {string} rootId
124
+ * @returns {Promise<{entries: object[], reason: string|null, warnings: string[], rootWarning: string|null}>}
125
+ */
126
+ export async function fetchRootEntries(rootId) {
127
+ const id = String(rootId || '');
128
+ if (!id) return { entries: [], reason: '资源根 id 为空', warnings: [], rootWarning: null };
129
+ let data = null;
130
+ try {
131
+ data = await getJson(`/api/catalog?root=${encodeURIComponent(id)}`);
132
+ } catch (e) {
133
+ const why = (e && e.message) || String(e);
134
+ diagEvent('catalog.fail', { root: id, reason: why }, { level: 'warn', src: 'app' });
135
+ return { entries: [], reason: `清单取不到:${why}`, warnings: [], rootWarning: null };
136
+ }
137
+ const warnings = Array.isArray(data && data.warnings) ? data.warnings.map((w) => String(w)) : [];
138
+ // 服务端的结构化"这一根怎么了"(正常根 null)。**只透传,不在这里编文案**
139
+ const rootWarning = (data && typeof data.rootWarning === 'string' && data.rootWarning) ? data.rootWarning : null;
140
+ const entries = Array.isArray(data && data.entries) ? data.entries : null;
141
+ if (!entries) {
142
+ diagEvent('catalog.fail', { root: id, reason: '响应里没有 entries 数组' }, { level: 'warn', src: 'app' });
143
+ return { entries: [], reason: '清单响应里没有 entries 数组', warnings, rootWarning };
144
+ }
145
+ // 成功但一条都没有、且服务端明确说了这一根有问题 ⇒ 记一条 warn:
146
+ // 否则 UI 只会显示"该游戏没有可加载模型",与"这个根真的没有模型"完全无法区分。
147
+ if (!entries.length && rootWarning) {
148
+ diagEvent('catalog.empty', { root: id, rootWarning }, { level: 'warn', src: 'app' });
149
+ }
150
+ return {
151
+ entries, reason: null, warnings, rootWarning,
152
+ // 服务端自报的清单来源(cache=命中清单缓存 / built=真扫盘重建);老服务端没有这个字段时为 null。
153
+ // 只透传、不在这里推断 —— 前端拿不到"服务端是命中还是重建"的任何本地证据。
154
+ serverSource: (data && (data.source === 'cache' || data.source === 'built')) ? data.source : null,
155
+ // 后台形态探测还剩多少条待探(服务端 §八 的附加字段)。
156
+ // **本次不做 UI**:这里只做透传,展示位由 view.js 挂到名册 state 上(见 state.probe 注释)。
157
+ // 老服务端没有这个字段 ⇒ null(不推断成 0,0 会被误读成"已探完")。
158
+ probeRemaining: (data && Number.isFinite(data.probeRemaining)) ? Number(data.probeRemaining) : null,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * 取一个资源根的模型清单。
164
+ * @param {{id: string, name: string, kind?: string}} root
165
+ * @returns {Promise<{models: object[], source: string}>}
166
+ * @throws 清单取不到时抛(上层 view.js 据此把该根标成 error 状态并显示原因,这条 UI 语义要保留)
167
+ */
168
+ export async function loadRootModels(root) {
169
+ // 单一发现路径:/api/catalog 一次给出该根下的**全部**模型条目
170
+ // (spine 模型 + live2d 模型 + 容器单文件),所以不必再"按根类型猜该问哪个扫描器",
171
+ // 同一个根里既能放普通模型目录、也能放容器文件。
172
+ // 容器条目的 kind 由插件声明(解包后是什么内容),并带 `container` 标记供装载层解包用。
173
+ const { entries: models, reason, serverSource, probeRemaining } = await fetchRootEntries(root.id);
174
+ // 抛给上层:view.js 捕获后把根标成 status='error' + 显示 error 文案。
175
+ // 若这里改成"返回空清单不抛",UI 会从"加载失败"退化成"这个根有 0 个模型"—— 信息反而更少。
176
+ if (reason) throw new Error(`${root.name || root.id}:${reason}`);
177
+ const containers = models.filter((m) => m.container).length;
178
+ diagEvent('bundle.parse', { root: encodeURIComponent(root.id), source: 'catalog', models: models.length, containers }, { level: 'info', src: 'app' });
179
+ // serverSource 只用于名册计时(perRoot.source),不参与渲染与缓存判断
180
+ // probeRemaining 同理只往上透传:名册把它挂在 state 上供将来面板读取(本次不做 UI)
181
+ return { models, source: 'catalog', serverSource: serverSource || null, probeRemaining: probeRemaining ?? null };
182
+ }
183
+
184
+ /**
185
+ * 取一个资源根的名表(未配置 names 时服务端返回 names:null,这里当作"没有名表")。
186
+ * @param {string} rootId
187
+ * @returns {Promise<object|null>}
188
+ */
189
+ export async function loadRootNames(rootId) {
190
+ try {
191
+ const r = await getJson(`/api/names?root=${encodeURIComponent(rootId)}`);
192
+ if (!r || !r.byAsset || !r.characters) return null;
193
+ return r;
194
+ } catch {
195
+ // 名表缺失/损坏不应阻断名册(无表时按资源名归组),但要把原因带给调用方显示
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /** 变体标签:Spine / Live2D / 资源包(与 char-tree.js 的 variantLabel 语义一致) */
201
+ export function variantLabel(m) {
202
+ if (!m) return 'Spine';
203
+ // 先判"是不是容器单文件":它的 kind 现在是内容类型(live2d),
204
+ // 若先判 kind 就会把资源包标成 Live2D,用户看不出它是包。
205
+ if (m.container) return '资源包';
206
+ // 类型名查面元数据表的 label(加新面时不必再来这里补一个三元式)
207
+ const meta = byKind(m.kind);
208
+ return meta ? meta.label : 'Spine';
209
+ }
210
+
211
+ /**
212
+ * 变体行的主文本:服装名(名表)优先,无名表时退回目录名/资源名。
213
+ * @param {object} m
214
+ * @param {object|null} nm 名表命中结果
215
+ * @returns {{text: string, origName: string}}
216
+ */
217
+ export function variantText(m, nm) {
218
+ const dirName = String(m.srcDir || '').split('/').slice(-1)[0] || '';
219
+ const origName = String(m.name || '');
220
+ const text = nm && nm.costume ? nm.costume : (dirName || origName);
221
+ return { text, origName };
222
+ }
223
+
224
+ /**
225
+ * 把一批模型归并成角色数组(照 char-tree.js 的 buildTree + finalizeTree 规则)。
226
+ * @param {object[]} models 同一游戏(可能来自多个资源根)的全部模型
227
+ * @param {object} nameIndex
228
+ * @param {string} [gameName] 游戏名;给了就拼进角色键(`char:游戏名|组名`,* 避免不同游戏下同名角色共用同一个展开键)
229
+ * @returns {{chars: object[], stats: {models: number, exact: number, suffix: number, miss: number}}}
230
+ */
231
+ export function buildChars(models, nameIndex, gameName = '') {
232
+ const stats = { models: models.length, exact: 0, suffix: 0, miss: 0 };
233
+ const charMap = new Map();
234
+
235
+ for (const m of models) {
236
+ const nm = lookupName(nameIndex, m.name);
237
+ if (nm) {
238
+ // via 只用于统计:原样命中 vs 剥后缀后才命中
239
+ const keys = assetCandidates(m.name);
240
+ const hitKey = keys.find((k) => nameIndex.byAsset[k]);
241
+ stats[hitKey === keys[0] ? 'exact' : 'suffix'] += 1;
242
+ } else {
243
+ stats.miss += 1;
244
+ }
245
+ const groupName = nm ? nm.name : charKeyOf(m.name);
246
+ const key = gameName ? `char:${gameName}|${groupName}` : `char:${groupName}`;
247
+ if (!charMap.has(key)) {
248
+ charMap.set(key, {
249
+ key,
250
+ groupName,
251
+ // 标题就是显示名(旧表时代 nameZh 几乎全空,等价于直接取 groupName)
252
+ title: groupName,
253
+ variants: [],
254
+ });
255
+ }
256
+ charMap.get(key).variants.push({ model: m, nm });
257
+ }
258
+
259
+ const chars = [...charMap.values()];
260
+ for (const c of chars) {
261
+ // 组内:立绘(非 cutscene_)在前,其余按资源名数字序
262
+ c.variants.sort((a, b) => rankVariant(a.model) - rankVariant(b.model)
263
+ || String(a.model.name).localeCompare(String(b.model.name), undefined, { numeric: true }));
264
+ c.baseName = c.variants.length ? charKeyOf(String(c.variants[0].model.name || '')) : '';
265
+ c.iconUrl = c.variants.map((v) => v.model.iconUrl).find(Boolean) || null;
266
+ }
267
+ // 角色排序:按显示名
268
+ chars.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
269
+
270
+ return { chars, stats };
271
+ }
272
+
273
+ /** 组内排序权重:技能/剧情变体(cutscene_)排后面 */
274
+ function rankVariant(model) {
275
+ return /^cutscene_/i.test(String(model.name || '')) ? 1 : 0;
276
+ }
@@ -0,0 +1,52 @@
1
+ // roster/persist.js —— 名册展开态与"上次选中"的持久化
2
+ //
3
+ // 键名与结构(modelstage.tree-state.v1):
4
+ // { games: [], chars: [], activeGame: '游戏名', lastByGame: { '游戏名': '变体id' }, lastModelId? }
5
+ // 单独成模块的原因:读写逻辑与渲染无关,且 view.js 需要守住单文件行数上限。
6
+
7
+ import * as uiState from '../app/ui-state.js';
8
+
9
+ /**
10
+ * 名册状态存储键(**当前唯一键**,不是历史遗留)。
11
+ *
12
+ * 键名沿用项目早期命名,改名会让已有用户的"上次选中的模型/展开态"丢失,故保持不变;
13
+ * 读写统一经 app/ui-state 工厂代理,字段结构 `{games, chars, activeGame, lastByGame}` 不动。
14
+ */
15
+ export const LS_KEY = uiState.TREE_STATE_KEY;
16
+
17
+ /** 读展开态与上次选中(结构的 {games, chars, activeGame, lastByGame}) */
18
+ export function readSaved() {
19
+ try {
20
+ const s = uiState.getTreeState();
21
+ return {
22
+ games: new Set(Array.isArray(s.games) ? s.games : []),
23
+ chars: new Set(Array.isArray(s.chars) ? s.chars : []),
24
+ activeGame: typeof s.activeGame === 'string' ? s.activeGame : null,
25
+ lastByGame: s.lastByGame && typeof s.lastByGame === 'object' ? s.lastByGame : {},
26
+ // 旧格式:只有单个 lastModelId(更早版本写入的),读出来用于兼容恢复
27
+ lastModelId: typeof s.lastModelId === 'string' ? s.lastModelId : null,
28
+ };
29
+ } catch {
30
+ return { games: new Set(), chars: new Set(), activeGame: null, lastByGame: {}, lastModelId: null };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 写展开态与每个游戏上次选中的变体(键名/结构沿用既有:activeGame 存游戏名)。
36
+ * @param {object} state 名册状态(读 expandedGames/expandedChars/activeGame/selected)
37
+ */
38
+ export function saveState(state) {
39
+ try {
40
+ const s = readSaved();
41
+ const lastByGame = { ...s.lastByGame };
42
+ if (state.activeGame && state.selected) lastByGame[state.activeGame] = state.selected.modelId;
43
+ uiState.setTreeState({
44
+ games: [...state.expandedGames],
45
+ chars: [...state.expandedChars],
46
+ activeGame: state.activeGame || s.activeGame,
47
+ lastByGame,
48
+ });
49
+ } catch {
50
+ // 隐私模式等写不了就忽略:持久化是增强,不影响当前会话
51
+ }
52
+ }
@@ -0,0 +1,77 @@
1
+ // roster/reveal.js —— 「定位当前角色」:把选中的那一行滚进可视区
2
+ //
3
+ // 三条纪律(F3):
4
+ // ① 行已可见就不动 —— 绝大多数选中都不需要滚,乱跳反而干扰阅读
5
+ // ② 用户正在滚 / 刚滚过 SCROLL_IDLE_MS 内不滚 —— 绝不跟用户抢滚动位置
6
+ // ③ 目标被折叠时不硬滚,先按"展开状态"把父级展开(渲染出这一行)再滚
7
+ //
8
+ // 为什么单独成模块:这段是纯"滚动策略",与树的渲染/数据加载无关,拆开后 view.js 也能守住行数上限。
9
+
10
+ /** 用户停手多久之后才允许自动滚动(毫秒) */
11
+ export const SCROLL_IDLE_MS = 800;
12
+
13
+ /**
14
+ * 创建定位控制器。
15
+ * @param {{listEl: HTMLElement, getState: () => object, render: () => void}} deps
16
+ * getState 返回名册状态(expandedGames / expandedChars / selected)
17
+ * render 重新渲染整棵树(展开父级后需要它把目标行画出来)
18
+ */
19
+ export function createReveal({ listEl, getState, render }) {
20
+ // 自己触发的 scrollIntoView 也会派发 scroll 事件:用它区分"用户的滚"与"我们的滚"
21
+ let autoScrollUntil = 0;
22
+ let lastUserScrollAt = 0;
23
+
24
+ /** 记录一次"用户滚动"(程序触发的滚动不计) */
25
+ function markUserScroll() {
26
+ if (Date.now() < autoScrollUntil) return;
27
+ lastUserScrollAt = Date.now();
28
+ }
29
+
30
+ /** 挂上用户滚动信号:滚轮 / 触摸 / 指针按下 / 滚动条拖动(scroll 事件同时覆盖键盘与拖滚动条) */
31
+ function attachUserScrollListeners() {
32
+ listEl.addEventListener('scroll', markUserScroll, { passive: true });
33
+ listEl.addEventListener('wheel', markUserScroll, { passive: true });
34
+ listEl.addEventListener('touchstart', markUserScroll, { passive: true });
35
+ listEl.addEventListener('pointerdown', markUserScroll, { passive: true });
36
+ }
37
+
38
+ /**
39
+ * 把当前选中的变体行滚进可视区。
40
+ * @returns {'visible'|'scrolled'|'skipped-user-scrolling'|'no-selection'|'no-row'}
41
+ * visible:本来就在可视区,没动;skipped-user-scrolling:用户正在滚,主动让位
42
+ */
43
+ function revealActive() {
44
+ const state = getState();
45
+ const selected = state && state.selected;
46
+ if (!selected || !selected.modelId) return 'no-selection';
47
+ // ③ 折叠时先展开父级:没渲染出这一行,后面也就无从滚起
48
+ let expanded = false;
49
+ if (selected.gameKey && !state.expandedGames.has(selected.gameKey)) {
50
+ state.expandedGames.add(selected.gameKey);
51
+ expanded = true;
52
+ }
53
+ if (selected.charKey && !state.expandedChars.has(selected.charKey)) {
54
+ state.expandedChars.add(selected.charKey);
55
+ expanded = true;
56
+ }
57
+ if (expanded) render();
58
+ // 按 dataset 值逐个比对:模型 id 里可能有引号等字符,拼选择器不可靠
59
+ const row = [...listEl.querySelectorAll('[data-model-id]')]
60
+ .find((node) => node.dataset.modelId === selected.modelId);
61
+ if (!row) return 'no-row';
62
+ const box = listEl.getBoundingClientRect();
63
+ const r = row.getBoundingClientRect();
64
+ if (r.top >= box.top - 1 && r.bottom <= box.bottom + 1) return 'visible'; // ① 已可见
65
+ if (Date.now() - lastUserScrollAt < SCROLL_IDLE_MS) return 'skipped-user-scrolling'; // ② 不抢
66
+ autoScrollUntil = Date.now() + 300;
67
+ row.scrollIntoView({ block: 'nearest' });
68
+ return 'scrolled';
69
+ }
70
+
71
+ /** 只读:最近一次用户滚动距今毫秒(断言用) */
72
+ function sinceUserScroll() {
73
+ return Date.now() - lastUserScrollAt;
74
+ }
75
+
76
+ return { revealActive, markUserScroll, attachUserScrollListeners, sinceUserScroll };
77
+ }
@@ -0,0 +1,248 @@
1
+ // roster-rows.js —— 名册三级树的「行」渲染(纯 DOM 工厂,无状态)(M2)
2
+ //
3
+ // 做什么:把一条树数据渲染成一个可点击的 treeitem 行,三级别各一个函数。
4
+ // 为什么单独一个文件:
5
+ // 1. 单文件 ≤400 行的硬约束(视图逻辑 + 行结构放一起会超);
6
+ // 2. 职责分离:本文件只管"一行长什么样",roster-view.js 只管"状态怎么变、事件怎么派"。
7
+ // 约定:每行都带 data-* 标识与 tabindex=0(键盘可达),事件由 roster-view.js 在容器上委托。
8
+
9
+ import { variantLabel, variantText } from './list.js';
10
+
11
+ // el 的唯一实现在 infra/dom.js。
12
+ // 本文件自己要用它(rowBase 等 15 处),所以必须 import 绑定到本地作用域;
13
+ // 同时对外转发导出,roster/view.js 的 `import { el, ... } from './rows.js'` 无需改动。
14
+ // 注意:只写 `export { el } from '...'` 是**不绑定本地**的 —— 那些内部调用会变成 undefined。
15
+ import { el } from '../infra/dom.js';
16
+ // 名册徽标的配色类来自面元数据表(badge 字段)
17
+ import { byKind } from '../render/surfaces.js';
18
+
19
+ export { el };
20
+
21
+ /** 建一个树行骨架(三级别共用:data 属性 + treeitem 语义 + 键盘可达) */
22
+ function rowBase(className, dataset) {
23
+ const row = el('div', `tree-row ${className}`);
24
+ Object.assign(row.dataset, dataset);
25
+ row.setAttribute('role', 'treeitem');
26
+ row.tabIndex = 0; // 键盘可达:Enter/空格与点击等价
27
+ return row;
28
+ }
29
+
30
+ /**
31
+ * 展开箭头:内联 SVG 而不是 ▸/▾ 文字。
32
+ * 为什么不用文字:无桌面字体环境(无头 chromium / 精简系统)里 ▸ ▾ 会渲染成豆腐块,
33
+ * 而它是"能不能展开"的唯一视觉提示,必须稳定可见。
34
+ * @param {boolean} open
35
+ */
36
+ function arrowEl(open) {
37
+ const NS = 'http://www.w3.org/2000/svg';
38
+ const svg = document.createElementNS(NS, 'svg');
39
+ svg.setAttribute('viewBox', '0 0 16 16');
40
+ svg.setAttribute('class', 'tree-arrow');
41
+ svg.setAttribute('aria-hidden', 'true');
42
+ const path = document.createElementNS(NS, 'path');
43
+ path.setAttribute('d', open ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4');
44
+ path.setAttribute('fill', 'none');
45
+ path.setAttribute('stroke', 'currentColor');
46
+ path.setAttribute('stroke-width', '2');
47
+ path.setAttribute('stroke-linecap', 'round');
48
+ path.setAttribute('stroke-linejoin', 'round');
49
+ svg.append(path);
50
+ return svg;
51
+ }
52
+
53
+ /**
54
+ * 一级:游戏行(按资源根配置的 name 合并后的节点)。
55
+ * 一个游戏可能由多个资源根供给(碧蓝航线 = 3 个根),所以徽标显示根数量、title 列出具体根 id,
56
+ * 数据属性 data-root-ids 供自动化断言核对"这些根都在同一个游戏节点下"。
57
+ * @param {{key: string, name: string, roots: object[]}} game
58
+ * @param {{open: boolean, status: string, charCount: number}} view
59
+ */
60
+ export function renderGameRow(game, view) {
61
+ const rootIds = game.roots.map((r) => r.id);
62
+ const row = rowBase('tree-game', { gameKey: game.key, rootIds: rootIds.join(',') });
63
+ row.setAttribute('aria-expanded', String(view.open));
64
+ if (view.open) row.classList.add('is-open');
65
+
66
+ row.append(arrowEl(view.open));
67
+ row.append(el('span', 'tree-game-name', game.name));
68
+ // 多根时标注根数(单根不标注,避免噪音)
69
+ if (rootIds.length > 1) {
70
+ const badge = el('span', 'tree-badge', `${rootIds.length} 根`);
71
+ badge.title = rootIds.join(' · ');
72
+ row.append(badge);
73
+ } else {
74
+ row.append(el('span', 'tree-badge', rootIds[0] || ''));
75
+ }
76
+
77
+ if (view.status === 'ready') row.append(el('span', 'tree-count', `${view.charCount} 角色`));
78
+ else if (view.status === 'loading') row.append(el('span', 'tree-count', '加载中…'));
79
+ return row;
80
+ }
81
+
82
+ /**
83
+ * 二级:角色行(头像 + 显示名 + 原名标签 + 变体数)。
84
+ * @param {object} game
85
+ * @param {object} c 角色(title/baseName/iconUrl/variants)
86
+ * @param {boolean} open
87
+ */
88
+ export function renderCharRow(game, c, open) {
89
+ const row = rowBase('tree-char', { charKey: c.key, gameKey: game.key });
90
+ row.setAttribute('aria-expanded', String(open));
91
+ if (open) row.classList.add('is-open');
92
+
93
+ row.append(arrowEl(open));
94
+
95
+ if (c.iconUrl) {
96
+ const img = document.createElement('img');
97
+ img.className = 'tree-icon';
98
+ img.src = c.iconUrl;
99
+ img.alt = '';
100
+ img.loading = 'lazy';
101
+ img.decoding = 'async';
102
+ row.append(img);
103
+ } else {
104
+ row.append(el('span', 'tree-icon'));
105
+ }
106
+
107
+ row.append(el('span', 'tree-char-title', c.title));
108
+ // 「原名」标签:归一化键与显示名不同才有信息增量(照 char-tree.js 的去重规则)
109
+ if (c.baseName && c.baseName !== String(c.title)) row.append(el('span', 'tree-orig', c.baseName));
110
+ row.append(el('span', 'tree-count', c.variants.length));
111
+ return row;
112
+ }
113
+
114
+ /**
115
+ * 三级:变体行(类型标签 + 服装名/目录名 + 原名后缀)。
116
+ * 行上带 data-root-id:证明同一角色节点下确实聚合了来自不同资源根的变体。
117
+ * @param {object} game
118
+ * @param {object} c
119
+ * @param {{model: object, nm: object|null}} v
120
+ * @param {string|null} selectedId 当前选中的模型 id
121
+ */
122
+ export function renderVariantRow(game, c, v, selectedId) {
123
+ const m = v.model;
124
+ const { text, origName } = variantText(m, v.nm);
125
+ const row = rowBase('tree-variant', {
126
+ modelId: m.id, gameKey: game.key, charKey: c.key, rootId: m.rootId || '',
127
+ });
128
+
129
+ const isSelected = selectedId === m.id;
130
+ if (isSelected) {
131
+ row.classList.add('is-selected');
132
+ row.setAttribute('aria-selected', 'true');
133
+ }
134
+
135
+ const label = variantLabel(m);
136
+ // 样式类只用来分组(容器单文件用 bundle 类),**不带颜色**。
137
+ // 颜色/归属/可装载性/失败原因全部来自服务端三字段(契约 §一 INV-D1):
138
+ // display.accent 徽标底色(null ⇒ 中性色)
139
+ // display.provider 归属提供者 id(展示名按 /api/plugins 的 label 查)
140
+ // display.candidates 候选提供者(按 matchPriority 降序)
141
+ // support.state ready | unknown | unsupported
142
+ // support.detail 不支持时的 { reason, message }(与装载失败文案同源)
143
+ // loadResult 最近一次真实装载尝试的结果
144
+ // 前端**不做任何判定**:不按 kind 猜色、不自己算"能不能装"、不读 localStorage。
145
+ const meta = byKind(m.kind);
146
+ const kindClass = m.container ? 'bundle' : (meta ? meta.badge : 'spine');
147
+ const typeBadge = el('span', `tree-type tree-type-${kindClass}`, label);
148
+ const display = (m && m.display) || {};
149
+ if (display.accent) typeBadge.style.setProperty('--badge-accent', String(display.accent));
150
+ // 悬浮文案:归属定了说"谁",没定就把候选列出来(用户悬停就知道该找哪个插件)
151
+ const cands = Array.isArray(display.candidates) ? display.candidates : [];
152
+ if (display.provider) typeBadge.title = `归属:${display.provider}`;
153
+ else if (cands.length) typeBadge.title = `归属待定(候选:${cands.join('、')})`;
154
+ row.append(typeBadge);
155
+ row.append(el('span', 'tree-variant-text', text));
156
+ if (origName && origName !== text) row.append(el('span', 'tree-variant-orig', ` · ${origName}`));
157
+
158
+ // ⚠ 只有一处来源:support 判"装不动",或 loadResult 判"上次失败"。
159
+ // 两者的文案都由服务端给(support.detail.message / loadResult.message),前端只展示。
160
+ // 契约保证 support.unsupported 与 loadResult.ok 不并存 ⇒ 这里不会出现"一边说不行一边说刚成功"。
161
+ const support = (m && m.support) || {};
162
+ const loadResult = (m && m.loadResult) || null;
163
+ const warnText = (support.state === 'unsupported' && support.detail && support.detail.message)
164
+ ? String(support.detail.message)
165
+ : (loadResult && loadResult.result === 'failed'
166
+ ? String(loadResult.message || loadResult.reason || '装载失败')
167
+ : '');
168
+ if (warnText) {
169
+ const badge = el('span', 'tree-variant-warn', '⚠');
170
+ // 打标记:applyEntryState 就地刷新时**只认这个标记**找回已有的 ⚠。
171
+ // 不打的话它会以为"这行还没有警告",于是再 append 一个 ⇒ 同一行挂两个 ⚠(实测踩过)。
172
+ badge.dataset.entryWarn = '1';
173
+ badge.title = warnText;
174
+ row.append(badge);
175
+ row.classList.add('is-incomplete');
176
+ }
177
+ row.title = `${origName}${m.srcDir && m.srcDir !== origName ? ` · ${m.srcDir}` : ''}${warnText ? `\n⚠ ${warnText}` : ''}`;
178
+ return row;
179
+ }
180
+
181
+ /**
182
+ * 按条目**就地**刷新徽标:只动命中那一行,**不重渲染整棵名册**。
183
+ *
184
+ * 为什么不能整棵重渲染:名册是带展开态 / 选中态 / 滚动位置的树,
185
+ * 重渲染会把这三样全部丢掉 —— 用户看到的是"点一下模型,列表跳回顶部、节点全折叠"。
186
+ *
187
+ * 服务端返回的 display/support/loadResult 是唯一真相(契约 §一 INV-D1):
188
+ * 本函数只把三个字段"画"到那一行,不接收前端自造的失败记录、不猜颜色。
189
+ * ⇒ 装载成功后服务端 recompute 一变,照着画就对(这就是"当场变色"的全部机制)。
190
+ *
191
+ * @param {object} model 名册条目(只需 id / name)
192
+ * @param {{display?:object, support?:object, loadResult?:object}|null} entryState 服务端三字段;
193
+ * null/缺省 = 该条目已无状态(等价于全部清空)
194
+ * @returns {boolean} 命中并处理了返回 true;该条目当前不在名册里(被折叠/搜索过滤)返回 false
195
+ */
196
+ export function applyEntryState(model, entryState) {
197
+ const id = String((model && (model.id || model.name)) || '');
198
+ if (!id) return false;
199
+ // 不用属性选择器:条目 id 里常有空格 / 斜杠 / 引号,拼进选择器必须转义,
200
+ // 而 CSS.escape 不是所有环境都有。名册数百行量级,直接遍历更稳也更清楚。
201
+ let row = null;
202
+ for (const candidate of document.querySelectorAll('.tree-row.tree-variant')) {
203
+ if (candidate.dataset && candidate.dataset.modelId === id) { row = candidate; break; }
204
+ }
205
+ if (!row) return false;
206
+
207
+ const st = entryState || {};
208
+ const display = st.display || {};
209
+ const support = st.support || {};
210
+ const loadResult = st.loadResult || null;
211
+
212
+ // ① 徽标颜色:只认 display.accent(null ⇒ 中性色,绝不按 kind 猜)
213
+ const typeBadge = row.querySelector('.tree-type');
214
+ if (typeBadge) {
215
+ if (display.accent) typeBadge.style.setProperty('--badge-accent', String(display.accent));
216
+ else typeBadge.style.removeProperty('--badge-accent');
217
+ const cands = Array.isArray(display.candidates) ? display.candidates : [];
218
+ if (display.provider) typeBadge.title = `归属:${display.provider}`;
219
+ else if (cands.length) typeBadge.title = `归属待定(候选:${cands.join('、')})`;
220
+ else typeBadge.title = '';
221
+ }
222
+
223
+ // ② ⚠:support 说"装不动" 或 loadResult 说"上次失败";文案由服务端给
224
+ const warnText = (support.state === 'unsupported' && support.detail && support.detail.message)
225
+ ? String(support.detail.message)
226
+ : (loadResult && loadResult.result === 'failed'
227
+ ? String(loadResult.message || loadResult.reason || '装载失败')
228
+ : '');
229
+ const existing = row.querySelector('[data-entry-warn="1"]');
230
+ if (warnText) {
231
+ if (existing) existing.title = warnText;
232
+ else {
233
+ const badge = el('span', 'tree-variant-warn', '⚠');
234
+ badge.dataset.entryWarn = '1';
235
+ badge.title = warnText;
236
+ row.append(badge);
237
+ }
238
+ row.classList.add('is-incomplete');
239
+ } else {
240
+ if (existing) existing.remove();
241
+ if (!row.querySelector('.tree-variant-warn')) row.classList.remove('is-incomplete');
242
+ }
243
+
244
+ // ③ 行级 title:正文(原名 / 目录)原样保留,只替换末尾那句警告
245
+ const baseTitle = String(row.title || '').replace(/\n⚠ [^\n]*$/, '');
246
+ row.title = warnText ? `${baseTitle}\n⚠ ${warnText}` : baseTitle;
247
+ return true;
248
+ }