modelstage 0.5.28 → 0.6.1
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.
- package/CHANGELOG.md +83 -0
- package/README.md +2 -63
- package/config/models/live2d.assetbundls.json +1 -1
- package/config/models/live2d.azurlane.json +1 -1
- package/config/models/live2d.azurlane_bundle.json +1 -1
- package/config/models/live2d.azurlane_bundle.jsonl +2 -0
- package/config/models/live2d.azurlane_spine.json +1 -1
- package/config/models/live2d.bd2.json +1 -1
- package/config/models/live2d.bd2.jsonl +2 -0
- package/config/models/live2d.bluearchive.json +1 -1
- package/config/models/live2d.nikke.json +1 -1
- package/config/models/live2d.nikke.jsonl +2 -0
- package/config/models/live2d.nikke_2.json +1 -1
- package/config/models/live2d.npc.json +1 -1
- package/config/models/spine.assetbundls.json +1 -1
- package/config/models/spine.azurlane.json +1 -1
- package/config/models/spine.azurlane_bundle.json +1 -1
- package/config/models/spine.azurlane_bundle.jsonl +2 -0
- package/config/models/spine.azurlane_spine.json +1 -1
- package/config/models/spine.bd2.json +1 -1
- package/config/models/spine.bd2.jsonl +433 -0
- package/config/models/spine.bluearchive.json +1 -1
- package/config/models/spine.nikke.json +1 -1
- package/config/models/spine.nikke_2.json +1 -1
- package/config/models/spine.npc.json +1 -1
- package/package.json +2 -1
- package/src/server/assets/probe.mjs +1 -1
- package/src/server/core/config.mjs +2 -2
- package/src/server/core/log.mjs +1 -1
- package/src/server/data/catalog.mjs +11 -14
- package/src/server/data/live2d.mjs +139 -40
- package/src/server/data/manifest-cache.mjs +53 -10
- package/src/server/data/manifest-jsonl.mjs +264 -0
- package/src/server/data/manifest-stamp.mjs +52 -2
- package/src/server/data/scan-progress.mjs +305 -0
- package/src/server/data/scan-queue.mjs +99 -0
- package/src/server/data/spine.mjs +324 -106
- package/src/server/plugins/registry.mjs +1 -1
- package/src/server/routes/data.mjs +76 -17
- package/src/server/serve.mjs +9 -4
- package/src/server/unpack/plugin-store.mjs +1 -1
- package/src/server/unpack/runtime/exec.mjs +37 -8
- package/src/server/unpack/worker-client.mjs +23 -8
- package/src/shared/scan-limits.mjs +23 -0
- package/src/web/app/anim-list.js +1 -1
- package/src/web/app/backgrounds.js +4 -8
- package/src/web/app/dir-picker.js +3 -5
- package/src/web/app/memory-budget.js +0 -1
- package/src/web/app/memory-guard.js +0 -1
- package/src/web/app/model-actions.js +18 -15
- package/src/web/app/root-manager.js +2 -4
- package/src/web/app/transport-actions.js +1 -1
- package/src/web/app/ui-state.js +1 -1
- package/src/web/components.css +90 -0
- package/src/web/debug/race-probe.js +36 -13
- package/src/web/engine/textures.js +1 -2
- package/src/web/infra/api.js +10 -19
- package/src/web/infra/document.js +3 -3
- package/src/web/infra/plugin-host-unpack.mjs +81 -9
- package/src/web/infra/ready.js +177 -39
- package/src/web/live2d/bridge.js +1 -1
- package/src/web/live2d/runtime-adapter.js +2 -4
- package/src/web/panel/panel-settings.js +88 -3
- package/src/web/roster/events.js +159 -0
- package/src/web/roster/filter.js +4 -4
- package/src/web/roster/list.js +3 -3
- package/src/web/roster/load.js +440 -0
- package/src/web/roster/persist.js +46 -37
- package/src/web/roster/render.js +284 -0
- package/src/web/roster/reveal.js +4 -9
- package/src/web/roster/rows.js +35 -13
- package/src/web/roster/scan-stream.js +346 -0
- package/src/web/roster/snapshot.js +14 -8
- package/src/web/roster/store.js +292 -0
- package/src/web/roster/type-filter.js +5 -5
- package/src/web/roster/view.js +147 -634
- package/src/web/spine/canvas-runtime.js +4 -5
- package/src/web/spine/runtimes.js +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// roster/render.js —— 名册渲染(从 view.js 原样搬出:DOM 结构与文案一字不改)
|
|
2
|
+
//
|
|
3
|
+
// 负责什么:
|
|
4
|
+
// · 整棵树的渲染(一级游戏 / 二级角色 / 三级变体)与顶部计数文案;
|
|
5
|
+
// · 类型胶囊行(renderTypeFilter)与"类型筛选把所有条目挡掉"的提示;
|
|
6
|
+
// · 游戏状态判定(groupStatus)与扫描状态就地贴行(applyScanStatusToRow / aggregateScan);
|
|
7
|
+
// · 跨根归并角色树(rebuildGroupChars,实现在 store.rebuildChars)。
|
|
8
|
+
//
|
|
9
|
+
// 不负责什么:加载编排(load.js)、事件(events.js)、装配(view.js)。
|
|
10
|
+
// 依赖方向(单向):store ← load ← render ← view / events。
|
|
11
|
+
// 本文件只读 store 的状态、只调 store 的动作,不 import load;
|
|
12
|
+
// 快照投影(snapshot.js)通过 snapshotOf 注入 —— 因为快照要用本文件的 groupStatus,
|
|
13
|
+
// 而本文件要在 render 末尾产出快照,直接互相 import 就是环。
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
aggregateScanSnapshots, formatScanStatus, resolveGroupPhase, scanNeedsRetry,
|
|
17
|
+
} from './scan-stream.js';
|
|
18
|
+
import { visibleChars } from './filter.js';
|
|
19
|
+
import { deriveTypeCounts, filterCharsByType, usableTypeSelection } from './type-filter.js';
|
|
20
|
+
import { typeLabel } from '../../shared/type-labels.mjs';
|
|
21
|
+
import { el, renderGroupRow, renderCharRow, renderVariantRow } from './rows.js';
|
|
22
|
+
|
|
23
|
+
/** 渲染上限:每个游戏最多渲染多少角色行(避免 2000+ 角色一次铺满)。
|
|
24
|
+
* 默认值只是兜底,可用 window.__WEB_CONFIG__.renderLimitPerGame 覆盖 */
|
|
25
|
+
const RENDER_LIMIT_DEFAULT = 400;
|
|
26
|
+
function renderLimitPerGame() {
|
|
27
|
+
const custom = Number(window.__WEB_CONFIG__ && window.__WEB_CONFIG__.renderLimitPerGame);
|
|
28
|
+
return Number.isFinite(custom) && custom > 0 ? custom : RENDER_LIMIT_DEFAULT;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} deps 全部由 view 注入
|
|
33
|
+
* @param {object} deps.store 名册 store(唯一状态持有者)
|
|
34
|
+
* @param {{render:number}} deps.counters 调试计数器(与 view 共用同一引用,render 里 +1)
|
|
35
|
+
* @param {HTMLElement} deps.listEl 名册列表容器
|
|
36
|
+
* @param {HTMLElement} deps.countEl 顶部计数节点
|
|
37
|
+
* @param {HTMLElement|null} deps.typeFilterEl 类型胶囊行容器(取不到时整体降级为"没有类型筛选")
|
|
38
|
+
* @param {() => object} deps.snapshotOf 状态快照(render 末尾写进 dataset 供自动化断言)
|
|
39
|
+
* @param {(group: object) => number} [deps.queuePositionOf] 该分组在加载队列里的位次(1 起;0 = 不在队列)
|
|
40
|
+
*/
|
|
41
|
+
export function createRosterRender({ store, counters, listEl, countEl, typeFilterEl, snapshotOf, queuePositionOf }) {
|
|
42
|
+
const state = store.state;
|
|
43
|
+
|
|
44
|
+
/** 找到该游戏当前的游戏行(渲染是整棵重建的,所以每次都要重新查,不能缓存节点) */
|
|
45
|
+
function groupRowOf(group) {
|
|
46
|
+
if (!group) return null;
|
|
47
|
+
return [...listEl.querySelectorAll('[data-group-key].tree-game')]
|
|
48
|
+
.find((row) => row.dataset.groupKey === group.key) || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 把一个游戏下**所有正在扫描的根**聚合成一个状态(含"已完成根"的计数基线)。
|
|
53
|
+
* 具体口径在 scan-stream.js:aggregateScanSnapshots(纯函数,有单测);
|
|
54
|
+
* 这里只负责把视图里活跃的 monitor 与基线凑起来。
|
|
55
|
+
* @param {object|null} group
|
|
56
|
+
* @returns {object|null} null = 该游戏当前没有扫描在进行
|
|
57
|
+
*/
|
|
58
|
+
function aggregateScan(group) {
|
|
59
|
+
if (!group) return null;
|
|
60
|
+
const snaps = [];
|
|
61
|
+
for (const [rootId, cur] of state.scan) {
|
|
62
|
+
if (group.roots.some((r) => r.id === rootId)) snaps.push(cur.monitor.state);
|
|
63
|
+
}
|
|
64
|
+
return aggregateScanSnapshots(snaps, state.scanBaseline.get(group.key) || null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把扫描状态贴到游戏行的状态位上(**就地改文本**,不整棵重渲染)。
|
|
69
|
+
* 为什么不重渲染:一次扫描会发几十帧,每帧重建 400+ 行会把主线程占死 —— 而状态行就是一行文本。
|
|
70
|
+
* @param {object|null} group
|
|
71
|
+
* @param {object} snap createScanMonitor().state
|
|
72
|
+
*/
|
|
73
|
+
function applyScanStatusToRow(group, snap) {
|
|
74
|
+
const row = groupRowOf(group);
|
|
75
|
+
if (!row) return;
|
|
76
|
+
let slot = row.querySelector('.tree-count');
|
|
77
|
+
if (!slot) {
|
|
78
|
+
slot = el('span', 'tree-count', '');
|
|
79
|
+
row.append(slot);
|
|
80
|
+
}
|
|
81
|
+
const text = formatScanStatus(snap);
|
|
82
|
+
if (text) {
|
|
83
|
+
slot.textContent = text;
|
|
84
|
+
slot.classList.add('is-scanning');
|
|
85
|
+
slot.title = snap.currentPath ? `当前:${snap.currentPath}` : '';
|
|
86
|
+
} else if (slot.classList.contains('is-scanning')) {
|
|
87
|
+
// 扫描结束(loaded/idle)⇒ 把进度文案收拾掉;正常计数由下一次 render 补上
|
|
88
|
+
slot.textContent = '';
|
|
89
|
+
slot.classList.remove('is-scanning');
|
|
90
|
+
slot.title = '';
|
|
91
|
+
}
|
|
92
|
+
// 〔重试〕:只在 stalled/failed 出现;点击由 listEl 上的委托统一处理(见 bindEvents)
|
|
93
|
+
let retry = row.querySelector('[data-scan-retry]');
|
|
94
|
+
if (scanNeedsRetry(snap)) {
|
|
95
|
+
if (!retry) {
|
|
96
|
+
retry = el('button', 'tree-scan-retry', '重试');
|
|
97
|
+
retry.type = 'button';
|
|
98
|
+
retry.dataset.scanRetry = group.key;
|
|
99
|
+
retry.title = '重新订阅扫描进度(服务端有单飞,不会重复扫描)';
|
|
100
|
+
row.append(retry);
|
|
101
|
+
}
|
|
102
|
+
} else if (retry) {
|
|
103
|
+
retry.remove();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 某游戏的全部根是否都已就绪(有失败也算"可渲染",错误在游戏节点下提示) */
|
|
108
|
+
function groupStatus(group) {
|
|
109
|
+
const entries = group.roots.map((r) => state.loaded.get(r.id));
|
|
110
|
+
// 唯一判定:根加载状态 + 扫描聚合状态一起交给 resolveGroupPhase(纯函数,有单测)。
|
|
111
|
+
// 视图里不再有第二套推导 —— 那会让「扫描中」与「加载失败」同时出现。
|
|
112
|
+
return resolveGroupPhase({
|
|
113
|
+
scan: aggregateScan(group),
|
|
114
|
+
rootStates: entries.map((e) => (e && e.status ? e.status : 'idle')),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 重新归并某游戏的角色(跨根):把该游戏所有已就绪根的模型并成一个数组再归并。
|
|
120
|
+
* 为什么在游戏层归并:同一角色可能同时有 Live2D 与 Spine 变体(来自不同根),
|
|
121
|
+
* 按根各自归并会得到两个同名角色节点,造成不一致。
|
|
122
|
+
* @param {object} group
|
|
123
|
+
*/
|
|
124
|
+
function rebuildGroupChars(group) {
|
|
125
|
+
// 归并逻辑收在 store(store.rebuildChars 与这里原来是同一份实现)
|
|
126
|
+
store.rebuildChars(group.key);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 渲染类型胶囊行(搜索框下方那一行)。
|
|
131
|
+
*
|
|
132
|
+
* 为什么不复用工具栏按钮:胶囊要自动换行、要带计数、要表达 aria-pressed 的选中态,
|
|
133
|
+
* 与 .mini-btn 的"单行操作按钮"定位不同;只复用它的视觉语言(描边+底色),类名单列。
|
|
134
|
+
* @param {Array<{key: string, count: number}>} counts 类型与计数(已按数量降序)
|
|
135
|
+
* @param {Set<string>} activeTypes 当前**生效**的选中集合(已剔除当前不存在的失效项)
|
|
136
|
+
*/
|
|
137
|
+
function renderTypeFilter(counts, activeTypes) {
|
|
138
|
+
if (!typeFilterEl) return;
|
|
139
|
+
// 名册为空(一个类型都没有)⇒ 整行隐藏,不留一条空白(设计稿 §4.4)
|
|
140
|
+
if (!counts.length) {
|
|
141
|
+
typeFilterEl.hidden = true;
|
|
142
|
+
typeFilterEl.replaceChildren();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
typeFilterEl.hidden = false;
|
|
146
|
+
const nodes = counts.map(({ key, count }) => {
|
|
147
|
+
const chip = el('button', 'roster-type-chip', `${typeLabel(key)} ${count}`);
|
|
148
|
+
chip.type = 'button';
|
|
149
|
+
chip.dataset.typeKey = key;
|
|
150
|
+
chip.setAttribute('aria-pressed', activeTypes.has(key) ? 'true' : 'false');
|
|
151
|
+
chip.title = `只看 ${typeLabel(key)}(当前搜索下 ${count} 个变体)`;
|
|
152
|
+
return chip;
|
|
153
|
+
});
|
|
154
|
+
// 「清除」只在至少选 1 个时出现;不提供「全部」按钮(全部 = 全部取消选中)
|
|
155
|
+
if (activeTypes.size) {
|
|
156
|
+
const clear = el('button', 'roster-type-clear', '清除');
|
|
157
|
+
clear.type = 'button';
|
|
158
|
+
clear.dataset.role = 'clear-type-filter';
|
|
159
|
+
clear.setAttribute('aria-label', '清除类型筛选');
|
|
160
|
+
clear.title = '清除类型筛选(等价于不筛选)';
|
|
161
|
+
nodes.push(clear);
|
|
162
|
+
}
|
|
163
|
+
typeFilterEl.replaceChildren(...nodes);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 渲染整棵树(只渲染可见部分;展开的角色才生成变体行) */
|
|
167
|
+
function render() {
|
|
168
|
+
counters.render += 1;
|
|
169
|
+
listEl.replaceChildren();
|
|
170
|
+
|
|
171
|
+
// 首次渲染时插件指纹还没取到 ⇒ 这一帧不显示"不支持"标记(避免用过期结论误导),
|
|
172
|
+
// 指纹就绪后再重画一次。之所以放在 render 里而不是 mount 里:mount 是同步函数。
|
|
173
|
+
// 徽标颜色 / ⚠ / 文案全部来自条目自带的三字段(display/support/loadResult,契约 §一):
|
|
174
|
+
// 名册不再有"本地失败缓存"这一层,也就不需要指纹初始化与快照拉取。
|
|
175
|
+
|
|
176
|
+
const searching = state.keyword.length > 0;
|
|
177
|
+
let totalChars = 0;
|
|
178
|
+
let totalVariants = 0;
|
|
179
|
+
let rendered = 0;
|
|
180
|
+
let loadedGroups = 0;
|
|
181
|
+
// 类型胶囊的计数口径 = **当前关键词下**的可见变体数(不含类型过滤本身的影响,见设计稿 §5.3)。
|
|
182
|
+
// 所以先算 counts(只吃 keyword),再算 activeTypes,最后才把类型过滤作用到列表上。
|
|
183
|
+
const typeCounts = deriveTypeCounts({ groups: state.groups, groupChars: state.groupChars, keyword: state.keyword });
|
|
184
|
+
const activeTypes = usableTypeSelection([...state.typeFilter], typeCounts.map((entry) => entry.key));
|
|
185
|
+
renderTypeFilter(typeCounts, activeTypes);
|
|
186
|
+
// 被类型过滤挡掉的变体数 / 留下来的变体数:只用于"当前筛选下没有条目"的提示文案
|
|
187
|
+
let hiddenByType = 0;
|
|
188
|
+
let keptVariants = 0;
|
|
189
|
+
|
|
190
|
+
for (const group of state.groups) {
|
|
191
|
+
const status = groupStatus(group);
|
|
192
|
+
if (status === 'ready') loadedGroups += 1;
|
|
193
|
+
const charsByKeyword = visibleChars(group, state);
|
|
194
|
+
const chars = filterCharsByType(charsByKeyword, activeTypes);
|
|
195
|
+
if (activeTypes.size) {
|
|
196
|
+
const visibleCount = charsByKeyword.reduce((n, c) => n + c.variants.length, 0);
|
|
197
|
+
const keptCount = chars.reduce((n, c) => n + c.variants.length, 0);
|
|
198
|
+
hiddenByType += visibleCount - keptCount;
|
|
199
|
+
keptVariants += keptCount;
|
|
200
|
+
}
|
|
201
|
+
const open = state.expandedGroups.has(group.key) || (searching && chars.length > 0);
|
|
202
|
+
|
|
203
|
+
listEl.append(renderGroupRow(group, {
|
|
204
|
+
open: state.expandedGroups.has(group.key),
|
|
205
|
+
status,
|
|
206
|
+
charCount: chars.length,
|
|
207
|
+
queuePosition: typeof queuePositionOf === 'function' ? queuePositionOf(group) : 0,
|
|
208
|
+
}));
|
|
209
|
+
if (!open) continue;
|
|
210
|
+
|
|
211
|
+
if (status === 'loading') {
|
|
212
|
+
listEl.append(el('p', 'muted-hint tree-hint', '加载中…'));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (status === 'error') {
|
|
216
|
+
listEl.append(el('p', 'error-banner', `加载失败:${group.roots.map((r) => r.id).join(', ')}`));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (status === 'scanning' || status === 'stalled') {
|
|
220
|
+
// 扫描还在推进:不显示"没有模型"这种结论,也不出失败横幅
|
|
221
|
+
if (!chars.length) listEl.append(el('p', 'muted-hint tree-hint', '正在读取清单…'));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (status === 'idle') {
|
|
225
|
+
// 未加载:队列会自动轮到它,这里只说清"现在排在哪儿"
|
|
226
|
+
const pos = typeof queuePositionOf === 'function' ? queuePositionOf(group) : 0;
|
|
227
|
+
listEl.append(el('p', 'muted-hint tree-hint',
|
|
228
|
+
pos > 0 ? `排队中(第 ${pos} 位),轮到后自动加载` : '等待加载队列'));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
totalChars += chars.length;
|
|
233
|
+
const limit = renderLimitPerGame(); // 每次渲染读一次:改配置后刷新即生效
|
|
234
|
+
const selectedId = state.selected ? state.selected.modelId : null;
|
|
235
|
+
for (const c of chars) {
|
|
236
|
+
if (rendered >= limit) break;
|
|
237
|
+
rendered += 1;
|
|
238
|
+
const charOpen = state.expandedChars.has(c.key) || searching;
|
|
239
|
+
listEl.append(renderCharRow(group, c, charOpen));
|
|
240
|
+
// 变体数按数据统计(不依赖是否展开),与「N 游戏 / M 角色 / K 变体」口径一致
|
|
241
|
+
totalVariants += c.variants.length;
|
|
242
|
+
if (!charOpen) continue;
|
|
243
|
+
for (const v of c.variants) {
|
|
244
|
+
// 行内的颜色 / ⚠ / 文案全部由条目自带的三字段渲染(display/support/loadResult),
|
|
245
|
+
// 这里不再注入任何本地失败记录 —— 服务端是唯一权威(契约 §一)。
|
|
246
|
+
listEl.append(renderVariantRow(group, c, v, selectedId));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (!chars.length) {
|
|
250
|
+
listEl.append(el('p', 'muted-hint tree-hint', searching ? '无匹配' : '该游戏没有可加载模型'));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 类型筛选把所有条目都挡掉时给一句可读提示,不留空白(设计稿 §10.2 第 1 条)。
|
|
255
|
+
// 不自动改用户的选择:解包完成后条目会从 unknown 跳到 spine/live2d,
|
|
256
|
+
// 这里只如实说明"被挡掉了多少个",让用户自己决定是否取消筛选。
|
|
257
|
+
if (activeTypes.size && keptVariants === 0 && hiddenByType > 0) {
|
|
258
|
+
listEl.prepend(el('p', 'muted-hint tree-hint', `当前筛选下没有条目(${hiddenByType} 个已移到其它类型)`));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 计数文案:全量已加载时用统一口径(N 游戏 / M 角色 / K 变体),否则标注已加载多少
|
|
262
|
+
const allLoaded = loadedGroups === state.groups.length;
|
|
263
|
+
const prefix = allLoaded ? `${state.groups.length} 游戏` : `${state.groups.length} 游戏 / 已加载 ${loadedGroups}`;
|
|
264
|
+
countEl.textContent = searching
|
|
265
|
+
? `搜索「${state.keyword}」:${totalChars} 角色 / ${totalVariants} 变体`
|
|
266
|
+
: `${prefix}:${totalChars} 角色 / ${totalVariants} 变体`;
|
|
267
|
+
|
|
268
|
+
// 重建会把行换成新节点:仍在扫描的根要把状态行重新贴回去(否则一次别的渲染就把进度冲掉)
|
|
269
|
+
for (const group of state.groups) {
|
|
270
|
+
const agg = aggregateScan(group);
|
|
271
|
+
if (agg) { applyScanStatusToRow(group, agg); continue; }
|
|
272
|
+
// 没有活跃扫描:这一轮结束(该游戏已 ready)⇒ 丢掉基线,免得下次展开时把旧数字算进去
|
|
273
|
+
if (state.scanBaseline.has(group.key) && groupStatus(group) === 'ready') store.clearScanBaseline(group.key);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 供自动化断言:把状态投影成可读快照
|
|
277
|
+
document.documentElement.dataset.rosterReady = '1';
|
|
278
|
+
document.documentElement.dataset.rosterSnapshot = JSON.stringify(snapshotOf());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
render, renderTypeFilter, groupStatus, aggregateScan, applyScanStatusToRow, rebuildGroupChars, groupRowOf,
|
|
283
|
+
};
|
|
284
|
+
}
|
package/src/web/roster/reveal.js
CHANGED
|
@@ -13,7 +13,7 @@ export const SCROLL_IDLE_MS = 800;
|
|
|
13
13
|
/**
|
|
14
14
|
* 创建定位控制器。
|
|
15
15
|
* @param {{listEl: HTMLElement, getState: () => object, render: () => void}} deps
|
|
16
|
-
* getState 返回名册状态(
|
|
16
|
+
* getState 返回名册状态(expandedGroups / expandedChars / selected)
|
|
17
17
|
* render 重新渲染整棵树(展开父级后需要它把目标行画出来)
|
|
18
18
|
*/
|
|
19
19
|
export function createReveal({ listEl, getState, render }) {
|
|
@@ -46,8 +46,8 @@ export function createReveal({ listEl, getState, render }) {
|
|
|
46
46
|
if (!selected || !selected.modelId) return 'no-selection';
|
|
47
47
|
// ③ 折叠时先展开父级:没渲染出这一行,后面也就无从滚起
|
|
48
48
|
let expanded = false;
|
|
49
|
-
if (selected.
|
|
50
|
-
state.
|
|
49
|
+
if (selected.groupKey && !state.expandedGroups.has(selected.groupKey)) {
|
|
50
|
+
state.expandedGroups.add(selected.groupKey);
|
|
51
51
|
expanded = true;
|
|
52
52
|
}
|
|
53
53
|
if (selected.charKey && !state.expandedChars.has(selected.charKey)) {
|
|
@@ -68,10 +68,5 @@ export function createReveal({ listEl, getState, render }) {
|
|
|
68
68
|
return 'scrolled';
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
function sinceUserScroll() {
|
|
73
|
-
return Date.now() - lastUserScrollAt;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return { revealActive, markUserScroll, attachUserScrollListeners, sinceUserScroll };
|
|
71
|
+
return { revealActive, markUserScroll, attachUserScrollListeners };
|
|
77
72
|
}
|
package/src/web/roster/rows.js
CHANGED
|
@@ -52,19 +52,19 @@ function arrowEl(open) {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* 一级:游戏行(按资源根配置的 name 合并后的节点)。
|
|
55
|
-
*
|
|
55
|
+
* 一个游戏可能由多个资源根供给,所以徽标显示根数量、title 列出具体根 id,
|
|
56
56
|
* 数据属性 data-root-ids 供自动化断言核对"这些根都在同一个游戏节点下"。
|
|
57
|
-
* @param {{key: string, name: string, roots: object[]}}
|
|
57
|
+
* @param {{key: string, name: string, roots: object[]}} group
|
|
58
58
|
* @param {{open: boolean, status: string, charCount: number}} view
|
|
59
59
|
*/
|
|
60
|
-
export function
|
|
61
|
-
const rootIds =
|
|
62
|
-
const row = rowBase('tree-game', {
|
|
60
|
+
export function renderGroupRow(group, view) {
|
|
61
|
+
const rootIds = group.roots.map((r) => r.id);
|
|
62
|
+
const row = rowBase('tree-game', { groupKey: group.key, rootIds: rootIds.join(',') });
|
|
63
63
|
row.setAttribute('aria-expanded', String(view.open));
|
|
64
64
|
if (view.open) row.classList.add('is-open');
|
|
65
65
|
|
|
66
66
|
row.append(arrowEl(view.open));
|
|
67
|
-
row.append(el('span', 'tree-game-name',
|
|
67
|
+
row.append(el('span', 'tree-game-name', group.name));
|
|
68
68
|
// 多根时标注根数(单根不标注,避免噪音)
|
|
69
69
|
if (rootIds.length > 1) {
|
|
70
70
|
const badge = el('span', 'tree-badge', `${rootIds.length} 根`);
|
|
@@ -74,19 +74,41 @@ export function renderGameRow(game, view) {
|
|
|
74
74
|
row.append(el('span', 'tree-badge', rootIds[0] || ''));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// 状态位:ready/loading 是原有口径;idle(从没展开过)与 error 各给一个**显式入口**。
|
|
78
|
+
// 为什么不自动加载:一次性把 9 个根全拉一遍 = 9 次全量扫描(几分钟),页面会像"什么都打不开"。
|
|
79
|
+
// 加载只能由用户动作触发(展开这一行 / 点这两个按钮)。
|
|
80
|
+
// 状态只有"进度"含义,没有"要你点一下"的含义:加载由队列自动推进(见 load.js 的串行队列)。
|
|
81
|
+
// 失败态也保留重试按钮,因为那是用户唯一能做的动作。
|
|
77
82
|
if (view.status === 'ready') row.append(el('span', 'tree-count', `${view.charCount} 角色`));
|
|
78
|
-
else if (view.status === '
|
|
83
|
+
else if (view.status === 'scanning' || view.status === 'stalled') {
|
|
84
|
+
// 扫描中/疑似卡住:文案由 view.js 的扫描状态行写入(同一行同一个槽位),
|
|
85
|
+
// 这里只占位,避免和「加载失败」两套状态同时出现。
|
|
86
|
+
row.append(el('span', 'tree-count is-scanning', ''));
|
|
87
|
+
} else if (view.status === 'loading') row.append(el('span', 'tree-count', '加载中…'));
|
|
88
|
+
else if (view.status === 'error') {
|
|
89
|
+
const retry = el('button', 'tree-game-retry', '加载失败 · 重试');
|
|
90
|
+
retry.type = 'button';
|
|
91
|
+
retry.dataset.groupRetry = group.key;
|
|
92
|
+
retry.title = '重新请求这个游戏的资源根清单';
|
|
93
|
+
row.append(retry);
|
|
94
|
+
} else {
|
|
95
|
+
// 未加载:排队中(第 N 位)/ 等待队列。**没有按钮**,队列会自动轮到它。
|
|
96
|
+
const label = typeof view.queuePosition === 'number' && view.queuePosition > 0
|
|
97
|
+
? `排队中(第 ${view.queuePosition} 位)`
|
|
98
|
+
: '等待加载';
|
|
99
|
+
row.append(el('span', 'tree-count is-queued', label));
|
|
100
|
+
}
|
|
79
101
|
return row;
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
/**
|
|
83
105
|
* 二级:角色行(头像 + 显示名 + 原名标签 + 变体数)。
|
|
84
|
-
* @param {object}
|
|
106
|
+
* @param {object} group
|
|
85
107
|
* @param {object} c 角色(title/baseName/iconUrl/variants)
|
|
86
108
|
* @param {boolean} open
|
|
87
109
|
*/
|
|
88
|
-
export function renderCharRow(
|
|
89
|
-
const row = rowBase('tree-char', { charKey: c.key,
|
|
110
|
+
export function renderCharRow(group, c, open) {
|
|
111
|
+
const row = rowBase('tree-char', { charKey: c.key, groupKey: group.key });
|
|
90
112
|
row.setAttribute('aria-expanded', String(open));
|
|
91
113
|
if (open) row.classList.add('is-open');
|
|
92
114
|
|
|
@@ -114,16 +136,16 @@ export function renderCharRow(game, c, open) {
|
|
|
114
136
|
/**
|
|
115
137
|
* 三级:变体行(类型标签 + 服装名/目录名 + 原名后缀)。
|
|
116
138
|
* 行上带 data-root-id:证明同一角色节点下确实聚合了来自不同资源根的变体。
|
|
117
|
-
* @param {object}
|
|
139
|
+
* @param {object} group
|
|
118
140
|
* @param {object} c
|
|
119
141
|
* @param {{model: object, nm: object|null}} v
|
|
120
142
|
* @param {string|null} selectedId 当前选中的模型 id
|
|
121
143
|
*/
|
|
122
|
-
export function renderVariantRow(
|
|
144
|
+
export function renderVariantRow(group, c, v, selectedId) {
|
|
123
145
|
const m = v.model;
|
|
124
146
|
const { text, origName } = variantText(m, v.nm);
|
|
125
147
|
const row = rowBase('tree-variant', {
|
|
126
|
-
modelId: m.id,
|
|
148
|
+
modelId: m.id, groupKey: group.key, charKey: c.key, rootId: m.rootId || '',
|
|
127
149
|
});
|
|
128
150
|
|
|
129
151
|
const isSelected = selectedId === m.id;
|