modelstage 0.0.0-stage → 0.5.28

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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +137 -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 +63 -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 +654 -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 +396 -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/shared/type-labels.mjs +31 -0
  126. package/src/web/README.md +193 -0
  127. package/src/web/app/anim-list.js +222 -0
  128. package/src/web/app/background-solids.js +150 -0
  129. package/src/web/app/backgrounds.js +152 -0
  130. package/src/web/app/camera-log.js +28 -0
  131. package/src/web/app/context.js +72 -0
  132. package/src/web/app/dir-picker.js +242 -0
  133. package/src/web/app/entry-report.js +71 -0
  134. package/src/web/app/memory-bars.js +91 -0
  135. package/src/web/app/memory-budget.js +228 -0
  136. package/src/web/app/memory-guard.js +204 -0
  137. package/src/web/app/model-actions.js +682 -0
  138. package/src/web/app/renderer-setting.js +70 -0
  139. package/src/web/app/root-manager.js +254 -0
  140. package/src/web/app/status.js +138 -0
  141. package/src/web/app/topbar-actions.js +357 -0
  142. package/src/web/app/transport-actions.js +297 -0
  143. package/src/web/app/ui-state.js +559 -0
  144. package/src/web/app/url-params.js +171 -0
  145. package/src/web/app/view-settings.js +186 -0
  146. package/src/web/components.css +524 -0
  147. package/src/web/debug/diag-hud.js +95 -0
  148. package/src/web/debug/drag.js +369 -0
  149. package/src/web/debug/log.js +320 -0
  150. package/src/web/debug/model-cmp.js +292 -0
  151. package/src/web/debug/panel.css +474 -0
  152. package/src/web/debug/panel.js +712 -0
  153. package/src/web/debug/race-probe.js +82 -0
  154. package/src/web/debug/upload.js +130 -0
  155. package/src/web/engine/adapters/atlas.js +47 -0
  156. package/src/web/engine/adapters/index.js +22 -0
  157. package/src/web/engine/adapters/renderer.js +223 -0
  158. package/src/web/engine/adapters/skeleton.js +105 -0
  159. package/src/web/engine/adapters/texture.js +64 -0
  160. package/src/web/engine/animation.js +181 -0
  161. package/src/web/engine/appearance.js +136 -0
  162. package/src/web/engine/camera-adapter.js +212 -0
  163. package/src/web/engine/effect-chain.js +138 -0
  164. package/src/web/engine/engine-state.js +120 -0
  165. package/src/web/engine/filters.js +224 -0
  166. package/src/web/engine/fit.js +197 -0
  167. package/src/web/engine/frame-gate.js +33 -0
  168. package/src/web/engine/gl-driver.js +56 -0
  169. package/src/web/engine/gl.js +77 -0
  170. package/src/web/engine/index.js +20 -0
  171. package/src/web/engine/loader.js +55 -0
  172. package/src/web/engine/model-info.js +26 -0
  173. package/src/web/engine/pipeline.js +200 -0
  174. package/src/web/engine/player.js +427 -0
  175. package/src/web/engine/pma/blend.js +76 -0
  176. package/src/web/engine/pma/detect.js +118 -0
  177. package/src/web/engine/pma/index.js +8 -0
  178. package/src/web/engine/pma/resolve.js +112 -0
  179. package/src/web/engine/pma/upload.js +127 -0
  180. package/src/web/engine/pma.js +5 -0
  181. package/src/web/engine/render-frame.js +93 -0
  182. package/src/web/engine/runtime-capabilities.js +209 -0
  183. package/src/web/engine/runtime.js +37 -0
  184. package/src/web/engine/skins.js +165 -0
  185. package/src/web/engine/textures.js +174 -0
  186. package/src/web/index.html +117 -0
  187. package/src/web/infra/api.js +531 -0
  188. package/src/web/infra/diag.js +509 -0
  189. package/src/web/infra/document.js +269 -0
  190. package/src/web/infra/dom.js +31 -0
  191. package/src/web/infra/events.mjs +27 -0
  192. package/src/web/infra/log.js +153 -0
  193. package/src/web/infra/mem-stats.js +112 -0
  194. package/src/web/infra/plugin-host-registration.mjs +285 -0
  195. package/src/web/infra/plugin-host-report.mjs +32 -0
  196. package/src/web/infra/plugin-host-unload.mjs +79 -0
  197. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  198. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  199. package/src/web/infra/plugin-host.js +628 -0
  200. package/src/web/infra/ready.js +461 -0
  201. package/src/web/infra/schedule.js +51 -0
  202. package/src/web/infra/shortcuts.js +129 -0
  203. package/src/web/infra/slot-registry.js +402 -0
  204. package/src/web/infra/vfs.js +247 -0
  205. package/src/web/live2d/bridge.js +400 -0
  206. package/src/web/live2d/model.js +321 -0
  207. package/src/web/live2d/overlay.js +142 -0
  208. package/src/web/live2d/pixi-app.js +111 -0
  209. package/src/web/live2d/playback-probe.js +92 -0
  210. package/src/web/live2d/probe-geometry.js +329 -0
  211. package/src/web/live2d/probe.js +119 -0
  212. package/src/web/live2d/release.js +66 -0
  213. package/src/web/live2d/runtime-adapter.js +289 -0
  214. package/src/web/live2d/stage-ctx.js +152 -0
  215. package/src/web/live2d/stage.js +204 -0
  216. package/src/web/live2d/vfs-settings.js +174 -0
  217. package/src/web/live2d/view.js +89 -0
  218. package/src/web/main.js +629 -0
  219. package/src/web/panel/background-list.js +373 -0
  220. package/src/web/panel/bg-source.js +291 -0
  221. package/src/web/panel/color-picker.css +199 -0
  222. package/src/web/panel/color-picker.js +278 -0
  223. package/src/web/panel/dialog.css +116 -0
  224. package/src/web/panel/dialog.js +173 -0
  225. package/src/web/panel/dom.js +35 -0
  226. package/src/web/panel/icons.js +58 -0
  227. package/src/web/panel/memory-bar.js +195 -0
  228. package/src/web/panel/panel-settings.js +1263 -0
  229. package/src/web/panel/panel.css +1304 -0
  230. package/src/web/panel/render-choice.js +104 -0
  231. package/src/web/panel/schema.js +784 -0
  232. package/src/web/panel/settings-io.js +291 -0
  233. package/src/web/panel/shell-controls.js +274 -0
  234. package/src/web/panel/slider.js +210 -0
  235. package/src/web/panel/toolbar-schema.js +111 -0
  236. package/src/web/panel/toolbar.js +195 -0
  237. package/src/web/panel/transport-picker.js +375 -0
  238. package/src/web/panel/transport.js +321 -0
  239. package/src/web/panel/view.js +553 -0
  240. package/src/web/plugin/unpack-worker.js +185 -0
  241. package/src/web/render/camera/constants.js +60 -0
  242. package/src/web/render/camera/index.js +123 -0
  243. package/src/web/render/camera/spine.js +124 -0
  244. package/src/web/render/capture.js +30 -0
  245. package/src/web/render/interaction.js +527 -0
  246. package/src/web/render/live2d.js +627 -0
  247. package/src/web/render/playback.js +47 -0
  248. package/src/web/render/reset-view.js +29 -0
  249. package/src/web/render/spine.js +386 -0
  250. package/src/web/render/surface-switch.js +176 -0
  251. package/src/web/render/surface.js +571 -0
  252. package/src/web/render/surfaces.js +70 -0
  253. package/src/web/roster/filter.js +31 -0
  254. package/src/web/roster/list.js +276 -0
  255. package/src/web/roster/persist.js +92 -0
  256. package/src/web/roster/reveal.js +77 -0
  257. package/src/web/roster/rows.js +248 -0
  258. package/src/web/roster/snapshot.js +39 -0
  259. package/src/web/roster/type-filter.js +97 -0
  260. package/src/web/roster/view.js +761 -0
  261. package/src/web/spine/canvas-api-groups.js +267 -0
  262. package/src/web/spine/canvas-api.js +97 -0
  263. package/src/web/spine/canvas-document.js +80 -0
  264. package/src/web/spine/canvas-engine.js +193 -0
  265. package/src/web/spine/canvas-entry.js +28 -0
  266. package/src/web/spine/canvas-pixels.js +36 -0
  267. package/src/web/spine/canvas-runtime.js +127 -0
  268. package/src/web/spine/canvas-view.js +108 -0
  269. package/src/web/spine/canvas.js +7 -0
  270. package/src/web/spine/runtime-hint.js +73 -0
  271. package/src/web/spine/runtimes.js +212 -0
  272. package/src/web/style.css +493 -0
  273. package/tool/lib/bundle-probe-node.mjs +121 -0
  274. package/tool/lib/paths.mjs +20 -0
  275. package/tool/lib/runtime-guides.mjs +65 -0
  276. package/tool/lib/runtime-package.mjs +403 -0
  277. package/tool/lib/spine-meta.mjs +227 -0
  278. package/tool/lib/spine-motions.mjs +104 -0
  279. package/tool/parse-jsonc.mjs +86 -0
  280. package/tool/resolve-live2d-runtimes.mjs +276 -0
  281. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,373 @@
1
+ // panel/background-list.js —— 右栏「主题背景 · 自定义图片」列表
2
+ //
3
+ // 一个列表做四件事:展示(缩略图 + 名称 + 大小)、设为背景、重命名(内联编辑)、删除(二次确认),
4
+ // 外加顶部的「上传图片」。数据全部来自服务端(app/backgrounds.js),刷新后仍在。
5
+ //
6
+ // 为什么失败提示要贴出具体文案:上传超限、类型不符、磁盘失败是三种不同的用户动作错误,
7
+ // 笼统一句"上传失败"用户不知道该改什么;后端给了 reason + 具体数值,这里原样透出。
8
+
9
+ import { el } from './dom.js';
10
+ import { stageLog, stageStatus } from '../app/status.js';
11
+ import { applyBackground, readViewSettings, setBackgroundColor, setCustomBackground } from '../app/view-settings.js';
12
+ import {
13
+ addSolid, listSolids, removeSolid, renameSolid, resetSolids,
14
+ } from '../app/background-solids.js';
15
+ import { openColorPicker } from './color-picker.js';
16
+ import { askConfirm, askText } from './dialog.js';
17
+ import { listBgSources, renderSourceGroup, selectedSourceId } from './bg-source.js';
18
+ import * as uiState from '../app/ui-state.js';
19
+ import {
20
+ listBackgrounds, pickFile, removeBackground, renameBackground, uploadBackground,
21
+ } from '../app/backgrounds.js';
22
+
23
+ /** 公共列表容器的选择器(现查,不闭包持有——面板会重建) */
24
+ const BOX_SEL = '#right-panel [data-ctl="ctl-bg-sources"]';
25
+
26
+ /** 当前列表数据(重命名/删除后就地更新,避免整表重取时闪一下) */
27
+ let items = [];
28
+
29
+ /** 二次确认的自动解除时间:误点了 8 秒内不动就恢复成「删除」 */
30
+ const DISARM_MS = 8000;
31
+
32
+ /**
33
+ * 在容器里显示一条状态文案(成功/失败都走这里,断言读 data-bg-msg)。
34
+ * @param {HTMLElement} box 容器
35
+ * @param {string} text 文案
36
+ * @param {'ok'|'error'} [kind] 类型
37
+ */
38
+ function say(box, text, kind = 'ok') {
39
+ const hint = box.querySelector('.bg-hint');
40
+ if (!hint) return;
41
+ hint.textContent = text;
42
+ hint.dataset.kind = kind;
43
+ box.dataset.bgMsg = text;
44
+ if (kind === 'error') stageLog('error', text, { source: 'background-list' }); else stageStatus(text, 'info', { show: true });
45
+ }
46
+
47
+
48
+ /**
49
+ * 设为舞台背景(服务端 url 直接当 background-image)。
50
+ * @param {object} item 列表项
51
+ */
52
+ function applyItem(item) {
53
+ const okSet = setCustomBackground(item.url);
54
+ const box = document.querySelector(BOX_SEL);
55
+ if (box) say(box, okSet ? `已设为背景:${item.name}` : '设置背景失败(舞台未就绪)', okSet ? 'ok' : 'error');
56
+ // 通知面板把「背景模式」档位同步到「自定义」(视觉一致)
57
+ if (typeof document !== 'undefined') {
58
+ document.dispatchEvent(new CustomEvent('panel:bg-applied', { detail: { mode: 'custom' } }));
59
+ }
60
+ }
61
+
62
+
63
+
64
+ /**
65
+ * 删除要二次确认:第一次点变成「确认删除」并自动解除,避免误删。
66
+ * @param {HTMLButtonElement} btn 删除按钮
67
+ * @param {object} item 列表项
68
+ * @param {HTMLElement} box 容器
69
+ */
70
+ async function confirmDelete(btn, item, box) {
71
+ // 自定义图删的是**服务端真实文件**(不可恢复)⇒ 必须明确确认;
72
+ // 纯色预设不需要(有「恢复默认」能一键找回),所以那边直接删。
73
+ const ok = await askConfirm({
74
+ title: '删除这张背景图?',
75
+ message: `「${item.name || item.id}」删除后不可恢复(图片文件会被一并删除)。`,
76
+ danger: true, // 确定键红色,且默认焦点落在「取消」防误触
77
+ confirmText: '删除',
78
+ });
79
+ if (!ok) { say(box, '已取消删除', 'ok'); return; }
80
+ removeBackground(realIdOf(item)).then((r) => {
81
+ if (!r.ok) { say(box, r.message, 'error'); return null; }
82
+ // 删掉的正好是当前背景图 ⇒ 舞台不能继续指向一个已 404 的地址,回落到棋盘格
83
+ const using = ((readViewSettings().background || {}).imageUrl || '') === item.url;
84
+ if (using) {
85
+ applyBackground('checkerboard');
86
+ document.dispatchEvent(new CustomEvent('panel:bg-applied', { detail: { mode: 'checkerboard' } }));
87
+ }
88
+ say(box, `已删除:${item.name}${using ? '(当前背景已回落到棋盘格)' : ''}`, 'ok');
89
+ return refreshBackgrounds();
90
+ });
91
+ }
92
+
93
+
94
+ /**
95
+ * 上传:选文件 → POST → 刷新列表。
96
+ * @param {HTMLElement} box 容器
97
+ */
98
+ async function startUpload(box) {
99
+ const file = await pickFile();
100
+ if (!file) return;
101
+ say(box, `正在上传:${file.name}…`, 'ok');
102
+ const r = await uploadBackground(file);
103
+ if (!r.ok) { say(box, r.message, 'error'); return; }
104
+ say(box, `已上传:${r.item.name}`, 'ok');
105
+ await refreshBackgrounds();
106
+ }
107
+
108
+ /**
109
+ * 刷新列表(面板重建、上传/改名/删除之后都调它)。
110
+ * 每次现查容器:面板重建会换掉旧 DOM,闭包持有旧节点会"刷了看不见"。
111
+ * @returns {Promise<number>} 列表条数(容器不存在时返回 -1)
112
+ */
113
+
114
+
115
+ // ============================================================ 纯色预设(ctl-bg-solids)
116
+ //
117
+ // 与上面的自定义图片列表同一套交互习惯(列表 + 行内操作 + 二次确认 + 失败原样透出),
118
+ // 数据来自服务端 /api/backgrounds/solids(见 app/background-solids.js)。
119
+ // 职责划分:颜色挑选交给 panel/color-picker.js(纯 UI),这里只管"命名 + 增删改 + 应用"。
120
+
121
+ /** 纯色列表容器(现查,不闭包持有——面板会重建) */
122
+ // 纯色预设与自定义图片**共用**同一个列表容器(schema 里那一个 kind:'dynamic-list' 的 ctl-bg-sources),
123
+ // 按当前背景模式决定往容器里渲染哪一种;所以这里必须与 BG_BOX_SEL 指向同一个容器。
124
+ // 写成一个不存在的 ctl-bg-solids 会让查询永远返回 null、纯色列表整块静默失效(本门禁 W2 抓到的)。
125
+ const SOLID_BOX_SEL = '#right-panel [data-ctl="ctl-bg-sources"]';
126
+
127
+ /** 当前纯色列表数据 */
128
+ let solidItems = [];
129
+
130
+ /** 纯色列表的状态文案(断言读 data-bg-solid-msg) */
131
+ function solidSay(box, text, kind = 'ok') {
132
+ box.dataset.bgSolidMsg = text;
133
+ const hint = box.querySelector('.bg-solid-hint');
134
+ if (!hint) return;
135
+ hint.textContent = text;
136
+ hint.dataset.kind = kind;
137
+ }
138
+
139
+ /**
140
+ * 命名:新建与改名共用这一个入口,走自绘弹窗 panel/dialog.js(返回 Promise)。
141
+ * 不用浏览器原生输入框:它同步阻塞主线程、样式不受主题控制。
142
+ * @param {string} defaultValue 原名(改名时预填,打开即全选,可直接覆盖)
143
+ * @returns {Promise<string|null>} 取消 ⇒ null
144
+ */
145
+ async function askName(defaultValue) {
146
+ const v = await askText({
147
+ title: '给这个颜色起个名字',
148
+ message: '最多 30 个字;同名会被服务端拒绝,换个名字即可。',
149
+ value: defaultValue || '',
150
+ maxlength: 30,
151
+ confirmText: '确定',
152
+ });
153
+ const t = (v || '').trim();
154
+ return t || null;
155
+ }
156
+
157
+ /**
158
+ * 应用某条预设(切到纯色模式并立即生效)。
159
+ * @param {object} item 预设条目
160
+ * @param {HTMLElement} box 容器
161
+ */
162
+ function applySolid(item, box) {
163
+ if (!item) return;
164
+ setBackgroundColor(item.color);
165
+ box.dataset.bgSolidApplied = item.color;
166
+ solidSay(box, `已应用「${item.name}」 ${item.color}`, 'ok');
167
+ }
168
+
169
+ /**
170
+ * 删除后的回落:删掉的正好是当前在用的颜色时,回落到第一条;列表空了就切回棋盘格。
171
+ * 这条必须先定义清楚——否则会留下"舞台还显示着已删除的颜色"这种未定义行为。
172
+ * @param {string} removedColor 被删掉的颜色值
173
+ * @param {HTMLElement} box 容器
174
+ */
175
+ function fallbackAfterRemove(removedColor, box) {
176
+ const cur = (readViewSettings().background || {}).color || '';
177
+ if (String(cur).toLowerCase() !== String(removedColor).toLowerCase()) return false;
178
+ const next = solidItems[0];
179
+ if (next) { applySolid(next, box); return false; }
180
+ applyBackground('checkerboard');
181
+ solidSay(box, '纯色预设已删空,已切回棋盘格', 'error');
182
+ return true; // 让调用方别再覆盖这条更重要的提示
183
+ }
184
+
185
+
186
+
187
+ /**
188
+ * 刷新纯色列表(面板重建、增删改、恢复默认之后都调它)。
189
+ * @returns {Promise<number>} 条数(容器不存在返回 -1)
190
+ */
191
+
192
+ /** 添加:调色盘取色 → 命名 → POST → 刷新 → **立即应用**(不然用户以为没成功) */
193
+ async function startAddSolid(box) {
194
+ const picked = await new Promise((resolve) => {
195
+ openColorPicker({
196
+ initial: (readViewSettings().background || {}).color || '#2a2a2a',
197
+ anchor: box,
198
+ onConfirm: (color) => resolve(color),
199
+ onCancel: () => resolve(null),
200
+ });
201
+ });
202
+ if (!picked) return;
203
+ const name = await askName(`颜色 ${picked}`);
204
+ if (!name) { solidSay(box, '已取消:没有命名', 'error'); return; }
205
+ const r = await addSolid(name, picked);
206
+ if (!r.ok) { solidSay(box, r.message || '添加失败', 'error'); return; }
207
+ await refreshSolids();
208
+ const item = r.item || solidItems.find((x) => x.color === picked);
209
+ if (item) applySolid(item, box);
210
+ }
211
+
212
+ /** 改名:id 与 color 不变,只换名字 */
213
+ async function startRenameSolid(item, box) {
214
+ const name = await askName(item.name);
215
+ if (!name || name === item.name) return;
216
+ const r = await renameSolid(realIdOf(item), name);
217
+ if (!r.ok) { solidSay(box, r.message || '改名失败', 'error'); return; }
218
+ await refreshSolids();
219
+ solidSay(box, `已改名:${name}`, 'ok');
220
+ }
221
+
222
+ /** 删除:两段式确认(防误点),删完按"是否正在用"决定回落 */
223
+ async function startRemoveSolid(btn, item, box) {
224
+ // 纯色直接删:有「恢复默认」能一键找回,二次确认反而碍事(自定义图那边才必须确认)
225
+ const r = await removeSolid(realIdOf(item));
226
+ if (!r.ok) { solidSay(box, r.message || '删除失败', 'error'); return; }
227
+ await refreshBgSources();
228
+ const switchedToCheckerboard = fallbackAfterRemove(item.color, box);
229
+ if (!switchedToCheckerboard) solidSay(box, `已删除「${item.name}」`, 'ok');
230
+ }
231
+
232
+ /** 恢复默认:两段式确认(误点会把自定义全清掉,所以必须二次确认) */
233
+ async function startResetSolids(btn, box) {
234
+ if (!btn.classList.contains('is-armed')) {
235
+ btn.classList.add('is-armed');
236
+ btn.textContent = '确认恢复?';
237
+ setTimeout(() => { btn.classList.remove('is-armed'); btn.textContent = '恢复默认'; }, DISARM_MS);
238
+ return;
239
+ }
240
+ const r = await resetSolids();
241
+ if (!r.ok) { solidSay(box, r.message || '恢复默认失败', 'error'); return; }
242
+ solidItems = r.items || [];
243
+ renderSolidList(box);
244
+ solidSay(box, `已恢复默认 ${solidItems.length} 条`, 'ok');
245
+ }
246
+
247
+
248
+ // ============================================================ 统一来源列表(右栏二级)
249
+ //
250
+ // 右栏现在是「四个平级按钮 + 按下展开对应列表」:
251
+ // 棋盘格 / 纯色▾ / 透明 / 自定义▾ —— 顶部的四个档位仍由 ctl-bg-mode 控件渲染,
252
+ // 这里只负责:① 两组列表的内容(调 bg-source.renderSourceGroup)
253
+ // ② 展开哪一组(openGroup)
254
+ // ③ 打开时把「当前项」勾出来
255
+
256
+ /**
257
+ * 当前展开的组:null = 都收起,'solid' / 'custom' = 展开对应列表。
258
+ * **状态存在 UI 状态工厂里**(不只是内存变量):刷新后能恢复"上次展开的是哪一组",
259
+ * 否则会出现"按钮回填了、列表却收起"的半截状态。
260
+ */
261
+ let openGroup = uiState.getBgOpen() || null;
262
+
263
+ /**
264
+ * 公共列表容器(现查:面板重建会换 DOM)。
265
+ * 纯色预设与自定义图片**共用这一个位置**:切换组时只换内容,按钮行不位移、视线不用上下找。
266
+ */
267
+ const SRC_SEL = BOX_SEL;
268
+
269
+ /** 按 openGroup 控制两组列表的可见性(列表内容常驻,切的是显隐) */
270
+ function applyGroupVisibility() {
271
+ const box = document.querySelector(SRC_SEL);
272
+ if (!box) return;
273
+ // 收起时**整块不占位**:清内容 + hidden(style.css 有全局 [hidden]{display:none!important} 兜底)
274
+ if (!openGroup) { box.replaceChildren(); box.hidden = true; }
275
+ else box.hidden = false;
276
+ const rp = document.getElementById('right-panel');
277
+ if (rp) rp.dataset.bgOpen = openGroup || '';
278
+ }
279
+
280
+ /**
281
+ * 取条目的**真实服务端 id**。
282
+ *
283
+ * 为什么需要:统一渲染层(bg-source)给的 `item.id` 是"带组前缀的渲染 id"
284
+ * (`solid:<真id>` / `custom:<url>`),用于唯一标识一行;而改名/删除接口要的是原始 id。
285
+ * 原始记录一直挂在 `item.raw` 上。
286
+ * @param {{id?:string, raw?:{id?:string}}} item
287
+ * @returns {string}
288
+ */
289
+ function realIdOf(item) {
290
+ return String((item && item.raw && item.raw.id) || (item && item.id) || '');
291
+ }
292
+
293
+ /** 图片改名:与纯色走同一套问答式命名(不再做行内输入框,两边口径一致) */
294
+ async function startRenameImage(item, box) {
295
+ const name = await askName(item.name);
296
+ if (!name || name === item.name) return;
297
+ const r = await renameBackground(realIdOf(item), name);
298
+ if (!r || !r.ok) { say(box, (r && r.message) || '改名失败', 'error'); return; }
299
+ await refreshBgSources();
300
+ say(box, `已改名:${name}`, 'ok');
301
+ }
302
+
303
+ /**
304
+ * 统一刷新两个列表(一次取数,两组都画,勾选态由 bg-source 决定)。
305
+ *
306
+ * 勾选口径:纯色按 color、自定义按 url;同色多条只认第一条;
307
+ * 当前 color 不在任何预设里 ⇒ 列表无勾,但「纯色」档位仍高亮(档位由 ctl-bg-mode 负责)。
308
+ * @returns {Promise<number>} 两条列表合计条数
309
+ */
310
+ export async function refreshBgSources() {
311
+ const box = document.querySelector(SRC_SEL);
312
+ if (!box) return -1;
313
+ const sources = await listBgSources();
314
+ const sel = selectedSourceId(readViewSettings().background || {}, sources);
315
+ const selId = sel.itemId || null;
316
+
317
+ if (sources.errors && sources.errors.length) box.dataset.bgSrcErrors = sources.errors.join(' / ');
318
+ else delete box.dataset.bgSrcErrors;
319
+
320
+ if (!openGroup) { applyGroupVisibility(); return 0; }
321
+ box.hidden = false;
322
+
323
+ if (openGroup === 'solid') {
324
+ renderSourceGroup(box, 'solid', sources.solid, {
325
+ selectedItemId: selId,
326
+ onApply: (item) => applySolid(item, box),
327
+ onRename: (item) => startRenameSolid(item, box),
328
+ onRemove: (btn, item) => startRemoveSolid(btn, item, box),
329
+ onAdd: () => startAddSolid(box),
330
+ onReset: (btn) => startResetSolids(btn, box),
331
+ });
332
+ return sources.solid.length;
333
+ }
334
+ renderSourceGroup(box, 'custom', sources.custom, {
335
+ selectedItemId: selId,
336
+ onApply: (item) => applyItem(item),
337
+ onRename: (item) => startRenameImage(item, box),
338
+ onRemove: (btn, item) => confirmDelete(btn, item, box),
339
+ onUpload: () => startUpload(box),
340
+ });
341
+ return sources.custom.length;
342
+ }
343
+
344
+ /** 兼容旧入口:两处都转发到统一实现(panel-settings 的调用点不用改) */
345
+ export async function refreshBackgrounds() { return refreshBgSources(); }
346
+ export async function refreshSolids() { return refreshBgSources(); }
347
+
348
+ /**
349
+ * 点「纯色▾」/「自定义▾」的语义:既是切到该模式,也是展开/收起它的列表。
350
+ * 已经是该组展开态时再点一次 = 收起(不重复应用同一个颜色)。
351
+ * 点「棋盘格」「透明」⇒ 两组都收起。
352
+ */
353
+ let toggleBound = false;
354
+ export function bindBgGroupToggle() {
355
+ if (toggleBound) return false;
356
+ toggleBound = true;
357
+ document.addEventListener('click', (ev) => {
358
+ const btn = ev.target && typeof ev.target.closest === 'function'
359
+ ? ev.target.closest('#right-panel [data-ctl="ctl-bg-mode"] [data-value]')
360
+ : null;
361
+ if (!btn) return;
362
+ const v = btn.dataset.value;
363
+ openGroup = (v === 'solid' || v === 'custom') ? (openGroup === v ? null : v) : null;
364
+ uiState.setBgOpen(openGroup || ''); // 记进工厂:刷新后按这个值恢复展开态
365
+ // 模式切换由 segmented 自身继续走(它会在下一拍更新档位与背景)
366
+ setTimeout(() => { refreshBgSources().catch(() => { /* 刷新失败保留现状 */ }); }, 0);
367
+ }, true);
368
+ // 其它来源(底栏/快捷键/自动恢复)改了背景 ⇒ 只更新勾选,不擅自展开/收起
369
+ document.addEventListener('panel:bg-applied', () => {
370
+ refreshBgSources().catch(() => { /* 忽略 */ });
371
+ });
372
+ return true;
373
+ }
@@ -0,0 +1,291 @@
1
+ // panel/bg-source.js —— 背景「来源」的统一数据层与渲染层(右栏与底栏共用这一份)
2
+ //
3
+ // 为什么要有这一层:背景来源有两处入口(右栏二级列表 / 底栏两级菜单),
4
+ // 如果两边各写一套"列出哪些来源 + 哪个是选中",加一种新来源(例如渐变)就得改两处,
5
+ // 而且两处的选中判定迟早会不一致。这里把「数据」与「渲染」各写一遍,两边都只调它。
6
+ //
7
+ // 选中判定口径(**颜色值/URL 是唯一真相,预设只是快捷方式**):
8
+ // · 纯色:按 background.color 匹配;两条预设同色时**只取第一条**(不要两条都打勾)
9
+ // · 自定义:按 background.imageUrl 匹配
10
+ // · 当前 color 不在任何预设里(手动改过 / 预设被删)⇒ 列表内无勾,但「纯色」这一档仍算选中
11
+ //
12
+ // 本文件只做"读数据 + 画列表 + 回调",不直接改背景(应用由调用方的 onApply 决定)。
13
+
14
+ import { el } from './dom.js';
15
+ import { listSolids, FALLBACK_COLOR } from '../app/background-solids.js';
16
+ import { listBackgrounds } from '../app/backgrounds.js';
17
+
18
+ /** 内置模式(无列表可展开的两项) */
19
+ export const BUILTIN_SOURCES = [
20
+ { id: 'checkerboard', name: '棋盘格' },
21
+ { id: 'transparent', name: '透明' },
22
+ ];
23
+
24
+ /**
25
+ * 规范化一个颜色值(小写 #rrggbb);非法返回空串。
26
+ * @param {unknown} v
27
+ * @returns {string}
28
+ */
29
+ export function normColor(v) {
30
+ const s = String(v || '').trim().toLowerCase();
31
+ return /^#[0-9a-f]{6}$/.test(s) ? s : '';
32
+ }
33
+
34
+ /**
35
+ * 取全部背景来源,按三组返回。
36
+ *
37
+ * @returns {Promise<{builtin:object[], solid:object[], custom:object[], errors:string[]}>}
38
+ * solid 项:`{ id:'solid:<color>', name, color }`
39
+ * custom 项:`{ id:'custom:<url>', name, url, thumb }`
40
+ */
41
+ export async function listBgSources() {
42
+ const errors = [];
43
+ const [solidsRes, imagesRes] = await Promise.all([
44
+ listSolids().catch((err) => ({ ok: false, items: [], message: String(err && err.message || err) })),
45
+ listBackgrounds().catch((err) => ({ ok: false, items: [], message: String(err && err.message || err) })),
46
+ ]);
47
+ if (!solidsRes.ok) errors.push(solidsRes.message || '取纯色预设失败');
48
+ if (!imagesRes.ok) errors.push(imagesRes.message || '取自定义图片失败');
49
+
50
+ const solid = (solidsRes.items || []).map((it) => ({
51
+ // id 必须**唯一**(用预设真实 id):曾经用 `solid:<color>` 当 id,
52
+ // 两条同色预设有同一个 id ⇒ 选中判定把两条都打上勾(实测踩过)。
53
+ id: `solid:${it.id || normColor(it.color) || FALLBACK_COLOR}`,
54
+ name: it.name,
55
+ color: normColor(it.color) || FALLBACK_COLOR,
56
+ raw: it,
57
+ }));
58
+ const custom = (imagesRes.items || []).map((it) => ({
59
+ id: `custom:${it.url}`,
60
+ name: it.name,
61
+ url: it.url,
62
+ thumb: it.thumbUrl || it.url,
63
+ raw: it,
64
+ }));
65
+ return { builtin: BUILTIN_SOURCES.slice(), solid, custom, errors };
66
+ }
67
+
68
+ /**
69
+ * 当前背景对应哪个来源(选中判定的唯一实现)。
70
+ *
71
+ * @param {{mode?:string, color?:string, imageUrl?:string}} background readViewSettings().background
72
+ * @param {{solid?:object[], custom?:object[]}} [groups] 已取到的分组(用于把 color/url 映射成具体某条)
73
+ * @returns {{groupId:'builtin'|'solid'|'custom', id:string, itemId:string|null}}
74
+ * `id` 是"档位 id"(builtin 用模式名、solid/custom 用组名),`itemId` 是列表里真正要打勾的那一条。
75
+ */
76
+ export function selectedSourceId(background, groups) {
77
+ const bg = background && typeof background === 'object' ? background : {};
78
+ const mode = String(bg.mode || 'checkerboard');
79
+ const color = normColor(bg.color);
80
+ const url = String(bg.imageUrl || '');
81
+
82
+ if (mode === 'custom') {
83
+ const hit = url && groups && Array.isArray(groups.custom)
84
+ ? groups.custom.find((it) => it.url === url)
85
+ : null;
86
+ return { groupId: 'custom', id: 'custom', itemId: hit ? hit.id : null };
87
+ }
88
+ if (mode === 'solid') {
89
+ // 同色多条只认第一条(find 天然只返回第一个匹配)
90
+ const hit = color && groups && Array.isArray(groups.solid)
91
+ ? groups.solid.find((it) => it.color === color)
92
+ : null;
93
+ return { groupId: 'solid', id: 'solid', itemId: hit ? hit.id : null };
94
+ }
95
+ // 棋盘格 / 透明:内置档位
96
+ return { groupId: 'builtin', id: mode === 'transparent' ? 'transparent' : 'checkerboard', itemId: null };
97
+ }
98
+
99
+ /**
100
+ * 渲染一组来源列表(右栏行内、底栏二级都用它)。
101
+ *
102
+ * 选中态 = **描边 + ✓ 双标识**(不只靠颜色,色盲/灰度下也能分辨)。
103
+ *
104
+ * @param {HTMLElement} host 容器(会被清空后重填)
105
+ * @param {'solid'|'custom'} groupId 组
106
+ * @param {object[]} items 该组条目(listBgSources 的结果)
107
+ * @param {object} opts
108
+ * @param {string|null} opts.selectedItemId 当前选中条目 id(null = 无勾)
109
+ * @param {(item:object)=>void} [opts.onApply] 点条目应用
110
+ * @param {(item:object)=>void} [opts.onRename] 改名(仅纯色/自定义)
111
+ * @param {(btn:HTMLElement, item:object)=>void} [opts.onRemove] 删除(由调用方做二次确认)
112
+ * @param {()=>void} [opts.onAdd] 底部「添加颜色」
113
+ * @param {()=>void} [opts.onReset] 底部「恢复默认」
114
+ * @param {()=>void} [opts.onUpload] 底部「上传图片」
115
+ * @param {string} [opts.emptyText] 空态文案
116
+ * @param {'panel'|'menu'} [opts.variant] 渲染风格(右栏行内 / 底栏菜单)
117
+ * @returns {{count:number}} 渲染条数
118
+ */
119
+ /** 当前打开的行菜单(同一时刻只允许一个) */
120
+ let openMenuEl = null;
121
+
122
+ /** 关掉当前行菜单(幂等) */
123
+ export function closeRowMenu() {
124
+ if (!openMenuEl) return;
125
+ try { openMenuEl.remove(); } catch { /* 已被移除 */ }
126
+ openMenuEl = null;
127
+ document.removeEventListener('click', onDocClick, true);
128
+ document.removeEventListener('keydown', onDocKey, true);
129
+ }
130
+
131
+ /**
132
+ * 在某行的 ⋯ 按钮旁弹出小菜单。
133
+ *
134
+ * 关闭行为三条都覆盖:点菜单外任意处 / Esc / 点了某一项。
135
+ * @param {HTMLElement} anchorBtn ⋯ 按钮
136
+ * @param {Array<{text:string, run:Function, danger?:boolean}>} entries 菜单项
137
+ */
138
+ export function openRowMenu(anchorBtn, entries) {
139
+ closeRowMenu(); // 互斥:开新的先关旧的
140
+ const items = (entries || []).filter(Boolean);
141
+ if (!anchorBtn || !items.length) return null;
142
+
143
+ const menu = el('div', 'bg-src-menu');
144
+ menu.setAttribute('role', 'menu');
145
+ for (const it of items) {
146
+ const btn = el('button', 'bg-src-menu-item' + (it.danger ? ' bg-src-menu-item--danger' : ''), it.text);
147
+ btn.type = 'button';
148
+ btn.setAttribute('role', 'menuitem');
149
+ btn.addEventListener('click', (e) => {
150
+ e.stopPropagation();
151
+ closeRowMenu();
152
+ try { it.run(); } catch { /* 单项失败不影响其它 */ }
153
+ });
154
+ menu.append(btn);
155
+ }
156
+ document.body.append(menu);
157
+
158
+ // 定位:贴 ⋯ 的右下角;越界就翻到左上(动态值,契约允许的唯一例外)
159
+ const r = anchorBtn.getBoundingClientRect();
160
+ const box = menu.getBoundingClientRect();
161
+ const vw = window.innerWidth || 0;
162
+ const vh = window.innerHeight || 0;
163
+ let left = r.right - box.width;
164
+ let top = r.bottom + 4;
165
+ if (vw && left + box.width > vw - 8) left = Math.max(8, vw - box.width - 8);
166
+ if (left < 8) left = 8;
167
+ if (vh && top + box.height > vh - 8) top = Math.max(8, r.top - box.height - 4);
168
+ menu.style.left = `${Math.round(left)}px`;
169
+ menu.style.top = `${Math.round(top)}px`;
170
+ openMenuEl = menu;
171
+
172
+ // 点外部关闭(用 setTimeout 让"本次点击"先冒泡完,否则点 ⋯ 会立刻被关掉)
173
+ setTimeout(() => {
174
+ document.addEventListener('click', onDocClick, true);
175
+ document.addEventListener('keydown', onDocKey, true);
176
+ }, 0);
177
+ return menu;
178
+ }
179
+
180
+ /** 点菜单外任意处 → 关 */
181
+ function onDocClick(ev) {
182
+ if (!openMenuEl) return;
183
+ if (openMenuEl.contains(ev.target)) return;
184
+ closeRowMenu();
185
+ document.removeEventListener('click', onDocClick, true);
186
+ document.removeEventListener('keydown', onDocKey, true);
187
+ }
188
+
189
+ /** Esc → 关 */
190
+ function onDocKey(ev) {
191
+ if (ev.key !== 'Escape' || !openMenuEl) return;
192
+ closeRowMenu();
193
+ ev.stopPropagation();
194
+ document.removeEventListener('click', onDocClick, true);
195
+ document.removeEventListener('keydown', onDocKey, true);
196
+ }
197
+
198
+ export function renderSourceGroup(host, groupId, items, opts = {}) {
199
+ const list = Array.isArray(items) ? items : [];
200
+ const o = opts || {};
201
+ const variant = o.variant === 'menu' ? 'menu' : 'panel';
202
+ const rows = [];
203
+
204
+ for (const item of list) {
205
+ const isSel = o.selectedItemId && item.id === o.selectedItemId;
206
+ const row = el('div', `bg-src-row bg-src-row--${variant}`);
207
+ row.dataset.srcId = item.id;
208
+ if (isSel) row.dataset.selected = '1'; // 选中态由 CSS 画描边(data-selected)
209
+ row.setAttribute('role', 'option');
210
+ row.setAttribute('aria-selected', String(!!isSel));
211
+
212
+ // ✓ 标记(与描边一起构成双标识)
213
+ const tick = el('span', 'bg-src-tick', isSel ? '✓' : '');
214
+ tick.setAttribute('aria-hidden', 'true');
215
+ row.append(tick);
216
+
217
+ // 色块 / 缩略图
218
+ if (groupId === 'solid') {
219
+ const sw = el('span', 'bg-src-swatch');
220
+ sw.style.setProperty('--swatch', item.color); // 运行时数据 ⇒ CSS 变量(非内联 background 字面量)
221
+ row.append(sw);
222
+ } else {
223
+ const thumb = el('span', 'bg-src-thumb');
224
+ if (item.thumb) thumb.style.setProperty('--thumb', `url("${item.thumb}")`);
225
+ row.append(thumb);
226
+ }
227
+
228
+ row.append(el('span', 'bg-src-name', item.name || item.id));
229
+
230
+ // 行内操作收进单个 ⋯ 菜单:
231
+ // 两个文字按钮挤在行尾会把 320px 宽的面板挤爆(手机上尤其明显),
232
+ // 且"改名/删"是低频操作,收进菜单后行高能从两行降到一行。
233
+ if (variant === 'panel') {
234
+ const more = el('button', 'bg-src-more', '⋯');
235
+ more.type = 'button';
236
+ more.title = '更多操作';
237
+ more.setAttribute('aria-label', `更多操作:${item.name || item.id}`);
238
+ more.dataset.srcMore = item.id;
239
+ // ⋯ 必须 stopPropagation:否则"点 ⋯"会连带触发整行的"应用该色"
240
+ more.addEventListener('click', (e) => {
241
+ e.stopPropagation();
242
+ openRowMenu(more, [
243
+ typeof o.onRename === 'function' ? { text: '改名', run: () => o.onRename(item) } : null,
244
+ typeof o.onRemove === 'function' ? { text: '删除', danger: true, run: () => o.onRemove(more, item) } : null,
245
+ ].filter(Boolean));
246
+ });
247
+ row.append(more);
248
+ }
249
+
250
+ row.addEventListener('click', () => { if (typeof o.onApply === 'function') o.onApply(item); });
251
+ rows.push(row);
252
+ }
253
+
254
+ // 小标题:告诉用户"现在看的是哪一组、有几条"(两组合用同一位置时,这个标题是唯一的组标识)
255
+ const unit = groupId === 'solid' ? '个' : '张';
256
+ const titleText = groupId === 'solid' ? '纯色预设' : '自定义图片';
257
+ const head = el('p', 'bg-src-title', `${titleText} · ${list.length} ${unit}`);
258
+ head.dataset.group = groupId;
259
+ host.replaceChildren(head, ...rows);
260
+
261
+ if (!rows.length) {
262
+ const emptyText = o.emptyText || (groupId === 'solid' ? '还没有颜色,点添加' : '还没有图片,点上传');
263
+ host.append(el('p', 'ctl-list-empty', emptyText));
264
+ }
265
+
266
+ // 底部操作(仅右栏行内)
267
+ if (variant === 'panel') {
268
+ const foot = el('div', 'bg-src-foot');
269
+ if (groupId === 'solid' && typeof o.onAdd === 'function') {
270
+ const add = el('button', 'bg-btn bg-btn-primary', '+ 添加颜色');
271
+ add.type = 'button';
272
+ add.addEventListener('click', o.onAdd);
273
+ foot.append(add);
274
+ }
275
+ if (groupId === 'custom' && typeof o.onUpload === 'function') {
276
+ const up = el('button', 'bg-btn bg-btn-primary', '上传图片');
277
+ up.type = 'button';
278
+ up.addEventListener('click', o.onUpload);
279
+ foot.append(up);
280
+ }
281
+ if (groupId === 'solid' && typeof o.onReset === 'function') {
282
+ const reset = el('button', 'bg-btn bg-btn-danger', '恢复默认');
283
+ reset.type = 'button';
284
+ reset.addEventListener('click', () => o.onReset(reset));
285
+ foot.append(reset);
286
+ }
287
+ if (foot.children.length) host.append(foot);
288
+ }
289
+
290
+ return { count: rows.length };
291
+ }