modelstage 0.5.24 → 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 -56
- package/bin/modelstage.mjs +48 -12
- 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 +8 -2
- 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/http/write.mjs +8 -1
- package/src/server/plugins/registry.mjs +1 -1
- package/src/server/routes/data.mjs +76 -17
- package/src/server/serve.mjs +71 -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/shared/type-labels.mjs +31 -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 +149 -0
- package/src/web/debug/race-probe.js +36 -13
- package/src/web/engine/textures.js +1 -2
- package/src/web/index.html +2 -0
- 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 +64 -15
- 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 +16 -8
- package/src/web/roster/store.js +292 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +148 -544
- package/src/web/spine/canvas-runtime.js +4 -5
- package/src/web/spine/runtimes.js +1 -1
- package/tool/lib/check-perms.mjs +0 -114
- package/tool/lib/contract-checks.mjs +0 -582
- package/tool/lib/cubism-core.mjs +0 -122
- package/tool/lib/export-bundle.mjs +0 -328
- package/tool/lib/spine-runtime.mjs +0 -131
- package/tool/lib/test-lock.mjs +0 -178
- package/tool/lib/test-report.mjs +0 -133
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// roster/scan-stream.js —— 扫描进度:NDJSON 流读取 + 状态机 + 状态行文案(纯逻辑,不碰 DOM)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:
|
|
4
|
+
// ① 流读取(逐行切帧)与"什么时候算卡住"是可以脱离 DOM 单测的纯逻辑;
|
|
5
|
+
// ② 视图层只做"把文案贴到游戏行的状态位上",两者分开才好测(有 node 单测,见 test/scan-stream.test.mjs)。
|
|
6
|
+
//
|
|
7
|
+
// 数据形状(设计 §5.2,一帧一行 NDJSON):
|
|
8
|
+
// start { kind, rootId, rootPath, startedAt }
|
|
9
|
+
// progress { scanned, dirs, currentPath, elapsedMs }
|
|
10
|
+
// done { count, cached:false, elapsedMs }
|
|
11
|
+
// cached { count, ts }
|
|
12
|
+
// error { message, scanned, currentPath }
|
|
13
|
+
//
|
|
14
|
+
// 关键点:**P0 的流只带进度,不带模型数据**。拿到 done/cached 之后,模型清单仍走原来的
|
|
15
|
+
// `/api/catalog` 一次请求(那时服务端已落盘,命中缓存很快)。
|
|
16
|
+
|
|
17
|
+
import { SCAN_STALL_MS, resolveScanStallMs } from '../../shared/scan-limits.mjs';
|
|
18
|
+
|
|
19
|
+
/** 哪些内容类型有"扫描"这一说(其余类型没有对应端点,直接跳过进度) */
|
|
20
|
+
const SCAN_KINDS = new Set(['spine', 'live2d']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 该根要用哪条流式端点;没有对应扫描端点时返回 null(调用方直接走普通 JSON)。
|
|
24
|
+
* @param {{id: string, kind?: string}} root
|
|
25
|
+
* @param {{base?: string}} [opts]
|
|
26
|
+
* @returns {string|null}
|
|
27
|
+
*/
|
|
28
|
+
export function scanStreamUrl(root, opts = {}) {
|
|
29
|
+
const id = root && root.id ? String(root.id) : '';
|
|
30
|
+
const kind = root && root.kind ? String(root.kind) : '';
|
|
31
|
+
if (!id || !SCAN_KINDS.has(kind)) return null;
|
|
32
|
+
const base = (opts && opts.base) || '';
|
|
33
|
+
return `${base}/api/${kind}/models?root=${encodeURIComponent(id)}&stream=1`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 逐帧读 NDJSON。
|
|
38
|
+
*
|
|
39
|
+
* 为什么手写切行而不是 TextDecoderStream 管道:一行可能被任意切在两次 read 之间,
|
|
40
|
+
* 必须自己攒 buffer;手写也只有十几行,行为可控(尾行残缺时不抛错,直接丢弃)。
|
|
41
|
+
*
|
|
42
|
+
* @param {Response} response fetch 的响应(必须是流式的 body)
|
|
43
|
+
* @param {(frame: object) => void} onFrame 每解析出一帧回调一次
|
|
44
|
+
* @returns {Promise<void>} 流结束(正常结束或 body 读空)
|
|
45
|
+
*/
|
|
46
|
+
export async function readNdjsonStream(response, onFrame) {
|
|
47
|
+
const body = response && response.body;
|
|
48
|
+
if (!body || typeof body.getReader !== 'function') return;
|
|
49
|
+
const reader = body.getReader();
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
let buffer = '';
|
|
52
|
+
const emit = (line) => {
|
|
53
|
+
const text = line.trim();
|
|
54
|
+
if (!text) return;
|
|
55
|
+
let frame;
|
|
56
|
+
try { frame = JSON.parse(text); } catch { return; } // 坏行丢掉:进度是增强,不能因一行脏数据中断
|
|
57
|
+
if (frame && typeof frame === 'object') onFrame(frame);
|
|
58
|
+
};
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { value, done } = await reader.read();
|
|
61
|
+
if (done) break;
|
|
62
|
+
buffer += decoder.decode(value, { stream: true });
|
|
63
|
+
let cut = buffer.indexOf('\n');
|
|
64
|
+
while (cut >= 0) {
|
|
65
|
+
emit(buffer.slice(0, cut));
|
|
66
|
+
buffer = buffer.slice(cut + 1);
|
|
67
|
+
cut = buffer.indexOf('\n');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
buffer += decoder.decode();
|
|
71
|
+
emit(buffer);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 扫描状态机:idle → scanning → (loaded | stalled ⇄ scanning | failed)。
|
|
76
|
+
*
|
|
77
|
+
* 判死规则(设计 §7.3):收到**任意一帧**就重置计时;超阈值 ⇒ stalled,**只改状态与文案,
|
|
78
|
+
* 不自动取消请求**(服务端可能只是慢;自动取消会把"慢"变成"失败")。
|
|
79
|
+
* 定时器/时钟都可注入,便于单测(不引入假定时器库)。
|
|
80
|
+
*
|
|
81
|
+
* @param {{stallMs?: number, now?: () => number, setTimer?: Function, clearTimer?: Function,
|
|
82
|
+
* onChange?: (snap: object) => void}} [opts]
|
|
83
|
+
*/
|
|
84
|
+
export function createScanMonitor(opts = {}) {
|
|
85
|
+
const stallMs = resolveScanStallMs(opts.stallMs != null ? opts.stallMs : SCAN_STALL_MS);
|
|
86
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
87
|
+
const setTimer = typeof opts.setTimer === 'function' ? opts.setTimer : ((fn, ms) => setTimeout(fn, ms));
|
|
88
|
+
const clearTimer = typeof opts.clearTimer === 'function' ? opts.clearTimer : ((t) => clearTimeout(t));
|
|
89
|
+
const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};
|
|
90
|
+
|
|
91
|
+
const snap = {
|
|
92
|
+
phase: 'idle', // idle | scanning | stalled | failed | loaded
|
|
93
|
+
scanned: 0,
|
|
94
|
+
dirs: 0,
|
|
95
|
+
currentPath: '',
|
|
96
|
+
stalledFor: 0, // 已无进展多久(只在 stalled 时有意义)
|
|
97
|
+
message: '', // failed 的原因
|
|
98
|
+
lastFrameAt: 0,
|
|
99
|
+
};
|
|
100
|
+
let stallTimer = null;
|
|
101
|
+
let tickTimer = null; // stalled 期间每秒刷新"已 N 秒无进展"
|
|
102
|
+
let phaseBeforeStall = null;
|
|
103
|
+
|
|
104
|
+
const emit = () => { try { onChange({ ...snap }); } catch { /* 视图回调出错不影响状态机 */ } };
|
|
105
|
+
|
|
106
|
+
const clearTimers = () => {
|
|
107
|
+
if (stallTimer !== null) { clearTimer(stallTimer); stallTimer = null; }
|
|
108
|
+
if (tickTimer !== null) { clearTimer(tickTimer); tickTimer = null; }
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const armStall = () => {
|
|
112
|
+
if (stallTimer !== null) clearTimer(stallTimer);
|
|
113
|
+
stallTimer = setTimer(() => {
|
|
114
|
+
stallTimer = null;
|
|
115
|
+
if (snap.phase !== 'scanning') return;
|
|
116
|
+
phaseBeforeStall = snap.phase;
|
|
117
|
+
snap.phase = 'stalled';
|
|
118
|
+
snap.stalledFor = Math.max(0, now() - snap.lastFrameAt);
|
|
119
|
+
emit();
|
|
120
|
+
// 每秒刷新一次"已 N 秒无进展",让用户看到时间在走(状态本身不变)
|
|
121
|
+
tickTimer = setTimer(function tick() {
|
|
122
|
+
tickTimer = null;
|
|
123
|
+
if (snap.phase !== 'stalled') return;
|
|
124
|
+
snap.stalledFor = Math.max(0, now() - snap.lastFrameAt);
|
|
125
|
+
emit();
|
|
126
|
+
tickTimer = setTimer(tick, 1000);
|
|
127
|
+
}, 1000);
|
|
128
|
+
}, stallMs);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** 收到一帧之前先更新"最后收到帧的时间"并(必要时)从 stalled 回到 scanning */
|
|
132
|
+
const touch = () => {
|
|
133
|
+
snap.lastFrameAt = now();
|
|
134
|
+
if (snap.phase === 'stalled') {
|
|
135
|
+
snap.phase = 'scanning';
|
|
136
|
+
snap.stalledFor = 0;
|
|
137
|
+
if (tickTimer !== null) { clearTimer(tickTimer); tickTimer = null; }
|
|
138
|
+
phaseBeforeStall = null;
|
|
139
|
+
}
|
|
140
|
+
if (snap.phase === 'scanning') armStall();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
get state() { return { ...snap }; },
|
|
145
|
+
|
|
146
|
+
/** 开始一次(或重试一次)扫描:计数清零、状态回 scanning、重新起判死计时 */
|
|
147
|
+
begin() {
|
|
148
|
+
clearTimers();
|
|
149
|
+
snap.phase = 'scanning';
|
|
150
|
+
snap.scanned = 0;
|
|
151
|
+
snap.dirs = 0;
|
|
152
|
+
snap.currentPath = '';
|
|
153
|
+
snap.stalledFor = 0;
|
|
154
|
+
snap.message = '';
|
|
155
|
+
snap.lastFrameAt = now();
|
|
156
|
+
phaseBeforeStall = null;
|
|
157
|
+
armStall();
|
|
158
|
+
emit();
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** 处理一帧:任何帧都算"还活着"(含 error/done,它们之后的状态由帧类型决定) */
|
|
162
|
+
frame(frame) {
|
|
163
|
+
if (!frame || typeof frame !== 'object') return;
|
|
164
|
+
const type = String(frame.type || '');
|
|
165
|
+
if (type === 'error') {
|
|
166
|
+
// 失败也是"有响应",先重置计时再落状态,避免刚收到 error 就被判成卡住
|
|
167
|
+
snap.lastFrameAt = now();
|
|
168
|
+
clearTimers();
|
|
169
|
+
snap.phase = 'failed';
|
|
170
|
+
snap.message = String(frame.message || '扫描失败(服务端未给原因)');
|
|
171
|
+
if (Number.isFinite(Number(frame.scanned))) snap.scanned = Number(frame.scanned);
|
|
172
|
+
if (frame.currentPath) snap.currentPath = String(frame.currentPath);
|
|
173
|
+
emit();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (type === 'done' || type === 'cached') {
|
|
177
|
+
snap.lastFrameAt = now();
|
|
178
|
+
clearTimers();
|
|
179
|
+
snap.phase = 'loaded';
|
|
180
|
+
if (Number.isFinite(Number(frame.count))) snap.scanned = Number(frame.count);
|
|
181
|
+
snap.currentPath = '';
|
|
182
|
+
emit();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (type === 'progress') {
|
|
186
|
+
if (Number.isFinite(Number(frame.scanned))) snap.scanned = Number(frame.scanned);
|
|
187
|
+
if (Number.isFinite(Number(frame.dirs))) snap.dirs = Number(frame.dirs);
|
|
188
|
+
if (frame.currentPath) snap.currentPath = String(frame.currentPath);
|
|
189
|
+
touch();
|
|
190
|
+
emit();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// start 与未知帧:只算"活着的证据"
|
|
194
|
+
if (type === 'start' && frame.rootId) snap.currentPath = '';
|
|
195
|
+
touch();
|
|
196
|
+
emit();
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/** 请求层失败(非 error 帧:网络断了 / 非 2xx) */
|
|
200
|
+
fail(message) {
|
|
201
|
+
clearTimers();
|
|
202
|
+
snap.phase = 'failed';
|
|
203
|
+
snap.message = String(message || '扫描请求失败');
|
|
204
|
+
emit();
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
/** 流结束但没有 done/cached/error(连接被对端关掉):按失败处理,别假装完成 */
|
|
208
|
+
end() {
|
|
209
|
+
if (snap.phase === 'loaded' || snap.phase === 'failed') return;
|
|
210
|
+
clearTimers();
|
|
211
|
+
snap.phase = 'failed';
|
|
212
|
+
snap.message = '进度流提前结束(未收到完成帧)';
|
|
213
|
+
emit();
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/** 主动停止(关流/换游戏):不产生任何状态变更 */
|
|
217
|
+
abort() { clearTimers(); },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 把"已完成根的计数"并入基线(单调不减的来源)。
|
|
223
|
+
*
|
|
224
|
+
* 为什么需要基线:同一个游戏挂着多个根时是**逐根扫**的 —— 第一根扫完后它就从活跃集合里消失,
|
|
225
|
+
* 若聚合只统计活跃根,总数会从"135 条"掉回"0 条",看起来像重来了一次。
|
|
226
|
+
* 所以每根结束时把它的最终计数累加进基线,聚合 = 基线 + 活跃根。
|
|
227
|
+
*
|
|
228
|
+
* @param {{scanned:number, dirs:number}|null} baseline
|
|
229
|
+
* @param {{scanned?:number, dirs?:number}} snap 刚结束那根的最终快照
|
|
230
|
+
* @returns {{scanned:number, dirs:number}}
|
|
231
|
+
*/
|
|
232
|
+
export function mergeScanBaseline(baseline, snap) {
|
|
233
|
+
const base = baseline || { scanned: 0, dirs: 0 };
|
|
234
|
+
return {
|
|
235
|
+
scanned: (base.scanned || 0) + (Number(snap && snap.scanned) || 0),
|
|
236
|
+
dirs: (base.dirs || 0) + (Number(snap && snap.dirs) || 0),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 聚合一个游戏下所有**活跃**扫描(多根并行/串行时共用一行状态)。
|
|
242
|
+
*
|
|
243
|
+
* 阶段优先级:scanning > stalled > failed > loaded
|
|
244
|
+
* —— 只要还有一根在出帧,整体就算"扫描中";否则才报卡住/失败。
|
|
245
|
+
* (反过来的话,一根卡住而另一根仍在推进时会误报 stalled,正是设计 §7.3 要避免的误判)
|
|
246
|
+
* 计数:基线(已完成根)+ 所有活跃根之和 ⇒ 对用户而言单调不减。
|
|
247
|
+
*
|
|
248
|
+
* @param {Array<object>} snaps 活跃根的状态快照(createScanMonitor().state)
|
|
249
|
+
* @param {{scanned:number, dirs:number}} [baseline]
|
|
250
|
+
* @returns {object|null} null = 没有活跃扫描(调用方保持现状/交给正常渲染)
|
|
251
|
+
*/
|
|
252
|
+
export function aggregateScanSnapshots(snaps, baseline) {
|
|
253
|
+
const active = Array.isArray(snaps) ? snaps.filter(Boolean) : [];
|
|
254
|
+
if (!active.length) return null;
|
|
255
|
+
const has = (phase) => active.some((snap) => snap.phase === phase);
|
|
256
|
+
const phase = has('scanning') ? 'scanning' : has('stalled') ? 'stalled' : has('failed') ? 'failed' : 'loaded';
|
|
257
|
+
const base = baseline || { scanned: 0, dirs: 0 };
|
|
258
|
+
// 只累加**还在跑**的根:已 loaded 的根其计数已经并入基线,再算一次会翻倍
|
|
259
|
+
const running = active.filter((snap) => snap.phase !== 'loaded');
|
|
260
|
+
const sum = (key) => running.reduce((n, snap) => n + (Number(snap[key]) || 0), 0);
|
|
261
|
+
const stalledSnap = active.find((snap) => snap.phase === 'stalled') || null;
|
|
262
|
+
const failedSnap = active.find((snap) => snap.phase === 'failed') || null;
|
|
263
|
+
const scanningSnap = active.find((snap) => snap.phase === 'scanning') || null;
|
|
264
|
+
return {
|
|
265
|
+
phase,
|
|
266
|
+
scanned: (base.scanned || 0) + sum('scanned'),
|
|
267
|
+
dirs: (base.dirs || 0) + sum('dirs'),
|
|
268
|
+
currentPath: (stalledSnap && stalledSnap.currentPath) || (failedSnap && failedSnap.currentPath)
|
|
269
|
+
|| (scanningSnap && scanningSnap.currentPath) || '',
|
|
270
|
+
stalledFor: Math.max(0, ...active.map((snap) => Number(snap.stalledFor) || 0)),
|
|
271
|
+
message: (failedSnap && failedSnap.message) || '',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* **游戏行状态的唯一判定**(单一真相)。
|
|
277
|
+
*
|
|
278
|
+
* 为什么必须只有一处:曾经行上的状态位来自"扫描监控",而"加载失败 · 重试"来自 catalog 的 entry.status,
|
|
279
|
+
* 两者各画一套 ⇒ 同一行同时出现「扫描中 · 已读取 89 条」和「加载失败 · 重试」,
|
|
280
|
+
* 用户看到的是"明明还在读却说失败"。这里把两个来源合成一个枚举,视图只认它。
|
|
281
|
+
*
|
|
282
|
+
* 优先级(高 → 低):
|
|
283
|
+
* scanning 扫描流活着(未 stalled)⇒ 只能是"扫描中",**绝不允许同时显示失败**
|
|
284
|
+
* stalled 流超过阈值无帧 ⇒ 显示"疑似卡住"(可重试)
|
|
285
|
+
* loading 没有流在跑,但有根正在取清单
|
|
286
|
+
* error 取清单失败且**没有**活跃扫描(这才是真失败)
|
|
287
|
+
* ready 至少一个根有数据
|
|
288
|
+
* idle 从没加载过
|
|
289
|
+
*
|
|
290
|
+
* @param {{scan?: object|null, rootStates?: string[]}} input
|
|
291
|
+
* scan = aggregateScanSnapshots(...) 的返回值;rootStates = 每个根的 entry.status
|
|
292
|
+
* @returns {'scanning'|'stalled'|'loading'|'error'|'ready'|'idle'}
|
|
293
|
+
*/
|
|
294
|
+
export function resolveGroupPhase({ scan, rootStates } = {}) {
|
|
295
|
+
const states = Array.isArray(rootStates) ? rootStates.filter(Boolean) : [];
|
|
296
|
+
const phase = scan && scan.phase ? String(scan.phase) : '';
|
|
297
|
+
// 流在推进:无论 catalog 那边报什么,行状态只能是扫描中
|
|
298
|
+
if (phase === 'scanning') return 'scanning';
|
|
299
|
+
if (phase === 'stalled') return 'stalled';
|
|
300
|
+
if (states.includes('loading')) return 'loading';
|
|
301
|
+
if (states.length && states.every((state) => state === 'error')) return 'error';
|
|
302
|
+
if (states.includes('error') && !states.includes('ready') && !states.includes('loading')) return 'error';
|
|
303
|
+
if (states.includes('ready')) return 'ready';
|
|
304
|
+
if (phase === 'failed') return 'error';
|
|
305
|
+
if (states.includes('idle') || !states.length) return 'idle';
|
|
306
|
+
return 'ready';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* catalog 失败时该不该"暂缓判死"。
|
|
311
|
+
*
|
|
312
|
+
* 判据只有一条:该根的扫描流**仍在推进**(phase='scanning')⇒ 清单没回来是"还在扫",
|
|
313
|
+
* 不是"失败了";应当保持 loading,等 done 帧后自动补取一次。
|
|
314
|
+
* 流已 stalled(超过阈值无帧)/ failed / 已结束 ⇒ 不再等,按失败处理。
|
|
315
|
+
* @param {string} scanPhase createScanMonitor().state.phase
|
|
316
|
+
* @returns {boolean}
|
|
317
|
+
*/
|
|
318
|
+
export function shouldDeferCatalogFailure(scanPhase) {
|
|
319
|
+
return String(scanPhase || '') === 'scanning';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 状态行文案(设计 §7.2)。返回 '' 表示"不显示额外提示"(交给原有渲染)。
|
|
324
|
+
* @param {object} snap createScanMonitor().state
|
|
325
|
+
* @returns {string}
|
|
326
|
+
*/
|
|
327
|
+
export function formatScanStatus(snap) {
|
|
328
|
+
if (!snap) return '';
|
|
329
|
+
if (snap.phase === 'scanning') {
|
|
330
|
+
return `扫描中 · 已读取 ${snap.scanned} 条 / ${snap.dirs} 个目录`;
|
|
331
|
+
}
|
|
332
|
+
if (snap.phase === 'stalled') {
|
|
333
|
+
const seconds = Math.max(1, Math.round(snap.stalledFor / 1000));
|
|
334
|
+
const where = snap.currentPath ? ` · 当前 ${snap.currentPath}` : '';
|
|
335
|
+
return `疑似卡住 · 已读取 ${snap.scanned} 条 · ${seconds} 秒无进展${where}`;
|
|
336
|
+
}
|
|
337
|
+
if (snap.phase === 'failed') {
|
|
338
|
+
return `扫描失败 · ${snap.message || '未知原因'}`;
|
|
339
|
+
}
|
|
340
|
+
return '';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 该状态是否需要显示〔重试〕按钮 */
|
|
344
|
+
export function scanNeedsRetry(snap) {
|
|
345
|
+
return !!snap && (snap.phase === 'stalled' || snap.phase === 'failed');
|
|
346
|
+
}
|
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
// DOM 里真的渲染了几行、最近一次定位动作是什么"一次性摊平,测试只读这份快照。
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* @param {{state: object, listEl: HTMLElement,
|
|
7
|
+
* @param {{state: object, listEl: HTMLElement, groupStatus: (group: object) => string}} deps
|
|
8
8
|
*/
|
|
9
|
-
export function snapshotRoster({ state, listEl,
|
|
10
|
-
const
|
|
11
|
-
const chars = state.
|
|
9
|
+
export function snapshotRoster({ state, listEl, groupStatus }) {
|
|
10
|
+
const groups = state.groups.map((g) => {
|
|
11
|
+
const chars = state.groupChars.get(g.key);
|
|
12
12
|
return {
|
|
13
13
|
key: g.key,
|
|
14
14
|
name: g.name,
|
|
15
15
|
roots: g.roots.map((r) => r.id),
|
|
16
|
-
status:
|
|
16
|
+
status: groupStatus(g),
|
|
17
17
|
sources: g.roots.map((r) => (state.loaded.get(r.id) || {}).source || '').filter(Boolean),
|
|
18
18
|
chars: chars ? chars.chars.length : 0,
|
|
19
19
|
variants: chars ? chars.chars.reduce((n, c) => n + c.variants.length, 0) : 0,
|
|
@@ -21,15 +21,23 @@ export function snapshotRoster({ state, listEl, gameStatus }) {
|
|
|
21
21
|
};
|
|
22
22
|
});
|
|
23
23
|
return {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
groups,
|
|
25
|
+
expandedGroups: [...state.expandedGroups],
|
|
26
26
|
expandedChars: [...state.expandedChars],
|
|
27
27
|
keyword: state.keyword,
|
|
28
|
+
/** 类型筛选的选中集合(**存储原样**,可能含当前名册里不存在的失效项) */
|
|
29
|
+
typeFilter: state.typeFilter ? [...state.typeFilter] : [],
|
|
30
|
+
/** 正在扫描的根(断言"状态行数字在动"时读这里,不必解析文案) */
|
|
31
|
+
scan: state.scan ? [...state.scan.entries()].map(([rootId, cur]) => ({
|
|
32
|
+
rootId, phase: cur.monitor.state.phase, scanned: cur.monitor.state.scanned,
|
|
33
|
+
dirs: cur.monitor.state.dirs, currentPath: cur.monitor.state.currentPath,
|
|
34
|
+
stalledFor: cur.monitor.state.stalledFor,
|
|
35
|
+
})) : [],
|
|
28
36
|
selected: state.selected,
|
|
29
37
|
/** 最近一次"定位当前角色"的结果(visible / scrolled / skipped-user-scrolling / …) */
|
|
30
38
|
lastReveal: state.lastReveal || null,
|
|
31
39
|
domRows: {
|
|
32
|
-
|
|
40
|
+
groups: listEl.querySelectorAll('[data-group-key].tree-game').length,
|
|
33
41
|
chars: listEl.querySelectorAll('[data-char-key].tree-char').length,
|
|
34
42
|
variants: listEl.querySelectorAll('[data-model-id]').length,
|
|
35
43
|
},
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// roster/store.js —— 名册**唯一状态持有者**(状态收口)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它:名册原先有 7 份状态(loaded / scan / groupChars / expandedGroups /
|
|
4
|
+
// expandedChars / selected / scanBaseline)分散在 mount、ensureRootLoaded、trackScanProgress、
|
|
5
|
+
// loadGroup、ensureGroupLoaded、rebuildGroupChars、restoreLast、render 里各自直接改写。
|
|
6
|
+
// 写入点一多,任何新功能(例如"增量加条目")都会变成第 8、9 个写入点,行为难以推理。
|
|
7
|
+
//
|
|
8
|
+
// 约定:
|
|
9
|
+
// · **只有本文件可以写 state 的字段**;外部(view.js / snapshot.js)只读。
|
|
10
|
+
// · 所有变更走下面的动作方法;动作保持"最小原子":只改状态,不夹带 UI 逻辑。
|
|
11
|
+
// · 渲染时机分两种(别假设"改完就一定会重画"):
|
|
12
|
+
// ① 改结构/活跃分组的动作(setRoots / setActiveGroup)内部调 notify → 视图的 onChange;
|
|
13
|
+
// ② 其余动作**不** notify —— 由调用方在合适的时机自己补一次渲染(名册走 rAF 合批的 scheduleRender)。
|
|
14
|
+
// 为什么②不统一 notify:加载期一条路径会连改十几次状态,每次 notify 就是十几次整树重绘;
|
|
15
|
+
// 合批是名册首屏性能的关键(见 infra/schedule.js 文件头)。
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{buildChars: Function, nameIndex?: Map, onChange?: () => void}} deps
|
|
19
|
+
* buildChars(models, nameIndex, groupName) ⇒ { chars, stats }(由 view.js 注入,避免本文件依赖构建细节)
|
|
20
|
+
* nameIndex 名表索引(同样由 view.js 注入):本文件要"写完 models 就重算角色树",
|
|
21
|
+
* 而归并需要它 —— 让 store 自己持有,调用方就不必每次记得传
|
|
22
|
+
*/
|
|
23
|
+
export function createRosterStore({ buildChars, nameIndex, onChange } = {}) {
|
|
24
|
+
const state = {
|
|
25
|
+
/** 按 name 合并后的游戏列表(结构:{ key, name, roots[] }) */
|
|
26
|
+
groups: [],
|
|
27
|
+
/** rootId → { status, models, source, error, promise, pendingCatalogRetry } */
|
|
28
|
+
loaded: new Map(),
|
|
29
|
+
/** rootId → { monitor, controller, group }(扫描进行中的根) */
|
|
30
|
+
scan: new Map(),
|
|
31
|
+
/** groupKey → { chars, stats }(跨根归并后的角色树) */
|
|
32
|
+
groupChars: new Map(),
|
|
33
|
+
expandedGroups: new Set(),
|
|
34
|
+
expandedChars: new Set(),
|
|
35
|
+
selected: null,
|
|
36
|
+
keyword: '',
|
|
37
|
+
typeFilter: new Set(),
|
|
38
|
+
/** groupKey → { scanned, dirs }(多根逐根扫描时的计数基线) */
|
|
39
|
+
scanBaseline: new Map(),
|
|
40
|
+
/** 当前活跃的**分组**名(注意:语义是"分组",不假设它一定是游戏) */
|
|
41
|
+
activeGroup: null,
|
|
42
|
+
/** 本页生命周期内已经跑过进度流的根(防止重复订阅) */
|
|
43
|
+
scannedRoots: new Set(),
|
|
44
|
+
/** 后台形态探测队列快照:{ remaining, at, rootId }(UI 未实现,数据先就绪) */
|
|
45
|
+
probe: { remaining: null, at: null, rootId: null },
|
|
46
|
+
/** 最近一次"定位到当前项"的结果(滚动/已可见/用户正在滚动…) */
|
|
47
|
+
lastReveal: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const notify = () => {
|
|
51
|
+
if (typeof onChange === 'function') {
|
|
52
|
+
try { onChange(); } catch { /* 视图回调异常不影响状态 */ }
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** 读:某根的加载条目(不存在返回 null,不隐式创建) */
|
|
57
|
+
const rootEntry = (rootId) => state.loaded.get(String(rootId)) || null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 跨根归并该组的角色树(内部实现,`setRootModels` 与公开动作 `rebuildChars` 共用一份)。
|
|
61
|
+
*
|
|
62
|
+
* 只收 `status === 'ready'` 的根:还在 loading / 已 error 的根不参与归并,
|
|
63
|
+
* 否则半截数据会先被画出来(用户看到角色数忽多忽少)。
|
|
64
|
+
* @param {string} groupKey
|
|
65
|
+
*/
|
|
66
|
+
function rebuildCharsFor(groupKey) {
|
|
67
|
+
const group = state.groups.find((g) => g.key === String(groupKey));
|
|
68
|
+
if (!group) return null;
|
|
69
|
+
const models = [];
|
|
70
|
+
for (const root of group.roots) {
|
|
71
|
+
const entry = state.loaded.get(root.id);
|
|
72
|
+
if (entry && entry.status === 'ready') models.push(...(entry.models || []));
|
|
73
|
+
}
|
|
74
|
+
const built = buildChars(models, nameIndex, group.name);
|
|
75
|
+
state.groupChars.set(group.key, built);
|
|
76
|
+
return built;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const actions = {
|
|
80
|
+
state,
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- 根与游戏
|
|
83
|
+
/**
|
|
84
|
+
* 重建游戏列表(按 name 合并同名根)。
|
|
85
|
+
* @param {Array} roots /api/roots 的 roots
|
|
86
|
+
* @returns {Array} 合并后的 groups
|
|
87
|
+
*/
|
|
88
|
+
setRoots(roots) {
|
|
89
|
+
const byName = new Map();
|
|
90
|
+
for (const root of Array.isArray(roots) ? roots : []) {
|
|
91
|
+
const name = root.name || root.id;
|
|
92
|
+
if (!byName.has(name)) byName.set(name, { key: `group:${name}`, name, roots: [] });
|
|
93
|
+
byName.get(name).roots.push(root);
|
|
94
|
+
}
|
|
95
|
+
state.groups = [...byName.values()];
|
|
96
|
+
notify();
|
|
97
|
+
return state.groups;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** 某根所属的游戏 */
|
|
101
|
+
groupOfRoot(rootId) {
|
|
102
|
+
return state.groups.find((g) => g.roots.some((r) => r.id === rootId)) || null;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------- 根的加载状态
|
|
106
|
+
/**
|
|
107
|
+
* 写某根的加载状态(浅合并;不传的字段保持不变)。
|
|
108
|
+
* @param {string} rootId
|
|
109
|
+
* @param {object} patch 例:{ status:'loading' } / { status:'error', error:'…' }
|
|
110
|
+
*/
|
|
111
|
+
setRootStatus(rootId, patch) {
|
|
112
|
+
const id = String(rootId);
|
|
113
|
+
const cur = state.loaded.get(id) || { status: 'idle', models: [], source: '', error: null, promise: null };
|
|
114
|
+
state.loaded.set(id, { ...cur, ...(patch || {}) });
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/** 读某根条目 */
|
|
118
|
+
getRoot(rootId) {
|
|
119
|
+
return rootEntry(rootId);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** 清掉若干根的加载状态(重试用:让它们回到"未加载") */
|
|
123
|
+
resetRoots(rootIds) {
|
|
124
|
+
for (const id of rootIds || []) state.loaded.delete(String(id));
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 写某根的模型清单(权威全量)。
|
|
129
|
+
*
|
|
130
|
+
* **写完自动重算该根所属组的角色树**(派生状态跟着源状态走)。
|
|
131
|
+
* 为什么在这里而不是让调用方各自记得调 rebuildChars:调用方一多必然漏 ——
|
|
132
|
+
* 实测过一次:restoreLast 那条路径漏了归并 ⇒ 加载完成后行上 0 角色、
|
|
133
|
+
* 收起再展开才出现(第二次走的是另一条会归并的路径)。把重算钉在唯一的
|
|
134
|
+
* 写入入口上,新增调用方就不可能再漏。
|
|
135
|
+
* @param {string} rootId
|
|
136
|
+
* @param {Array} models
|
|
137
|
+
* @param {{authoritative?: boolean, source?: string}} [opts]
|
|
138
|
+
*/
|
|
139
|
+
setRootModels(rootId, models, opts = {}) {
|
|
140
|
+
const id = String(rootId);
|
|
141
|
+
const entry = state.loaded.get(id);
|
|
142
|
+
if (!entry) return;
|
|
143
|
+
entry.models = Array.isArray(models) ? models : [];
|
|
144
|
+
if (opts.source) entry.source = String(opts.source);
|
|
145
|
+
if (opts.authoritative !== false) entry.status = 'ready';
|
|
146
|
+
// 就绪状态已落地,此刻归并才拿得到这份 models(rebuildChars 只收 status==='ready' 的根)
|
|
147
|
+
const group = actions.groupOfRoot(id);
|
|
148
|
+
if (group) rebuildCharsFor(group.key);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------- 扫描
|
|
152
|
+
updateScan(rootId, payload) {
|
|
153
|
+
state.scan.set(String(rootId), payload);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
clearScan(rootId) {
|
|
157
|
+
state.scan.delete(String(rootId));
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
getScan(rootId) {
|
|
161
|
+
return state.scan.get(String(rootId)) || null;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
setScanBaseline(groupKey, base) {
|
|
165
|
+
state.scanBaseline.set(String(groupKey), base);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
getScanBaseline(groupKey) {
|
|
169
|
+
return state.scanBaseline.get(String(groupKey)) || null;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
clearScanBaseline(groupKey) {
|
|
173
|
+
state.scanBaseline.delete(String(groupKey));
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- 角色树
|
|
177
|
+
/**
|
|
178
|
+
* 用该组各根**已就绪**的模型重建角色树(跨根归并)。
|
|
179
|
+
*
|
|
180
|
+
* 正常流程里不需要手动调:`setRootModels` 写完就会自动重算(见那里的注释)。
|
|
181
|
+
* 留成公开动作是为了"幂等兜底":某些路径上模型早已就绪、本次没有新的写入
|
|
182
|
+
* (例如重复展开一个已加载的组),调用方仍可显式要求"确保已归并"。
|
|
183
|
+
* @param {string} groupKey
|
|
184
|
+
* @returns {object|null} { chars, stats }
|
|
185
|
+
*/
|
|
186
|
+
rebuildChars(groupKey) {
|
|
187
|
+
return rebuildCharsFor(groupKey);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------- 展开态
|
|
191
|
+
toggleGroup(groupKey) {
|
|
192
|
+
const key = String(groupKey);
|
|
193
|
+
const next = !state.expandedGroups.has(key);
|
|
194
|
+
if (next) state.expandedGroups.add(key); else state.expandedGroups.delete(key);
|
|
195
|
+
return next;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
expandGroup(groupKey) {
|
|
199
|
+
state.expandedGroups.add(String(groupKey));
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
collapseAll() {
|
|
203
|
+
state.expandedGroups.clear();
|
|
204
|
+
state.expandedChars.clear();
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
toggleChar(charKey) {
|
|
208
|
+
const key = String(charKey);
|
|
209
|
+
const next = !state.expandedChars.has(key);
|
|
210
|
+
if (next) state.expandedChars.add(key); else state.expandedChars.delete(key);
|
|
211
|
+
return next;
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
expandChar(charKey) {
|
|
215
|
+
state.expandedChars.add(String(charKey));
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/** 恢复持久化里的展开态(只保留仍存在的游戏) */
|
|
219
|
+
restoreExpanded({ groups, chars }) {
|
|
220
|
+
const keys = new Set(state.groups.map((g) => g.key));
|
|
221
|
+
for (const key of groups || []) if (keys.has(key)) state.expandedGroups.add(key);
|
|
222
|
+
for (const key of chars || []) state.expandedChars.add(key);
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------- 选中 / 关键词 / 筛选
|
|
226
|
+
/**
|
|
227
|
+
* 写选中项。
|
|
228
|
+
* @param {{modelId:string, groupKey:string, groupName:string|null, charKey:string}|null} payload
|
|
229
|
+
*/
|
|
230
|
+
selectModel(payload) {
|
|
231
|
+
state.selected = payload || null;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
setKeyword(keyword) {
|
|
235
|
+
state.keyword = String(keyword || '').trim().toLowerCase();
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
toggleTypeFilter(typeKey) {
|
|
239
|
+
const key = String(typeKey);
|
|
240
|
+
const next = !state.typeFilter.has(key);
|
|
241
|
+
if (next) state.typeFilter.add(key); else state.typeFilter.delete(key);
|
|
242
|
+
return next;
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
clearTypeFilter() {
|
|
246
|
+
state.typeFilter.clear();
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
/** 一次性把筛选集合替换掉(测试/外部恢复用;正常交互请用 toggleTypeFilter) */
|
|
250
|
+
setTypeFilter(keys) {
|
|
251
|
+
state.typeFilter = new Set(keys || []);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------- 分组 / 订阅标记 / 探测 / 定位
|
|
255
|
+
/**
|
|
256
|
+
* 写"当前活跃分组"(分组名,不是 groupKey)。
|
|
257
|
+
* @param {string|null} name
|
|
258
|
+
*/
|
|
259
|
+
setActiveGroup(name) {
|
|
260
|
+
state.activeGroup = name ? String(name) : null;
|
|
261
|
+
notify();
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/** 标记某根已订阅过进度流(防重复订阅) */
|
|
265
|
+
markRootSubscribed(rootId) {
|
|
266
|
+
state.scannedRoots.add(String(rootId));
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
unmarkRootSubscribed(rootId) {
|
|
270
|
+
state.scannedRoots.delete(String(rootId));
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
hasRootSubscribed(rootId) {
|
|
274
|
+
return state.scannedRoots.has(String(rootId));
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 写后台形态探测快照。
|
|
279
|
+
* @param {{remaining:number|null, at:number, rootId:string|null}} snapshot
|
|
280
|
+
*/
|
|
281
|
+
setProbe(snapshot) {
|
|
282
|
+
state.probe = { ...(snapshot || {}) };
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/** 写"最近一次定位结果"(reveal 的返回值,排障与断言读它) */
|
|
286
|
+
setLastReveal(result) {
|
|
287
|
+
state.lastReveal = result || null;
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return actions;
|
|
292
|
+
}
|