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
package/src/web/roster/filter.js
CHANGED
|
@@ -20,12 +20,12 @@ export function charMatches(c, kw) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** 某游戏在搜索下可见的角色(未加载的游戏不参与过滤) */
|
|
23
|
-
export function visibleChars(
|
|
24
|
-
const entry = state.
|
|
23
|
+
export function visibleChars(group, state) {
|
|
24
|
+
const entry = state.groupChars.get(group.key);
|
|
25
25
|
if (!entry) return [];
|
|
26
26
|
const kw = state.keyword;
|
|
27
27
|
if (!kw) return entry.chars;
|
|
28
|
-
const gameHit = String(
|
|
29
|
-
||
|
|
28
|
+
const gameHit = String(group.name).toLowerCase().includes(kw)
|
|
29
|
+
|| group.roots.some((r) => String(r.id).toLowerCase().includes(kw));
|
|
30
30
|
return gameHit ? entry.chars : entry.chars.filter((c) => charMatches(c, kw));
|
|
31
31
|
}
|
package/src/web/roster/list.js
CHANGED
|
@@ -225,10 +225,10 @@ export function variantText(m, nm) {
|
|
|
225
225
|
* 把一批模型归并成角色数组(照 char-tree.js 的 buildTree + finalizeTree 规则)。
|
|
226
226
|
* @param {object[]} models 同一游戏(可能来自多个资源根)的全部模型
|
|
227
227
|
* @param {object} nameIndex
|
|
228
|
-
* @param {string} [
|
|
228
|
+
* @param {string} [groupName] 游戏名;给了就拼进角色键(`char:游戏名|组名`,* 避免不同游戏下同名角色共用同一个展开键)
|
|
229
229
|
* @returns {{chars: object[], stats: {models: number, exact: number, suffix: number, miss: number}}}
|
|
230
230
|
*/
|
|
231
|
-
export function buildChars(models, nameIndex,
|
|
231
|
+
export function buildChars(models, nameIndex, groupName = '') {
|
|
232
232
|
const stats = { models: models.length, exact: 0, suffix: 0, miss: 0 };
|
|
233
233
|
const charMap = new Map();
|
|
234
234
|
|
|
@@ -243,7 +243,7 @@ export function buildChars(models, nameIndex, gameName = '') {
|
|
|
243
243
|
stats.miss += 1;
|
|
244
244
|
}
|
|
245
245
|
const groupName = nm ? nm.name : charKeyOf(m.name);
|
|
246
|
-
const key =
|
|
246
|
+
const key = groupName ? `char:${groupName}|${groupName}` : `char:${groupName}`;
|
|
247
247
|
if (!charMap.has(key)) {
|
|
248
248
|
charMap.set(key, {
|
|
249
249
|
key,
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// roster/load.js —— 名册加载编排(从 view.js 原样搬出:函数体、文案、调用顺序一字不改)
|
|
2
|
+
//
|
|
3
|
+
// 负责什么:
|
|
4
|
+
// · 资源根 → /api/catalog 的加载编排(entry.promise 让并发调用方**可等待**);
|
|
5
|
+
// · 扫描进度流(NDJSON)的订阅、进度贴行、重试;
|
|
6
|
+
// · 「名册加载计时」(每根耗时 / 总耗时 / 就绪那句 info)与后台形态探测进度;
|
|
7
|
+
// · 惰性加载策略:只加载被点开的那一个游戏(启动不扇出)。
|
|
8
|
+
//
|
|
9
|
+
// 不负责什么:DOM 结构与文案排版(render.js)、DOM 事件(events.js)、装配(view.js)。
|
|
10
|
+
//
|
|
11
|
+
// 依赖方向(单向,无环):store ← load ← render/events ← view。
|
|
12
|
+
// 本文件只 import 叶子模块(scan-stream / list / infra/api / app/status);
|
|
13
|
+
// 与渲染相关的能力(render / scheduleRender / flushRender / aggregateScan /
|
|
14
|
+
// applyScanStatusToRow / rebuildGroupChars)**由 view 在装配时注入**,
|
|
15
|
+
// 这样 load 不会反向依赖 render —— 否则 load ↔ render 就是环。
|
|
16
|
+
//
|
|
17
|
+
// 状态写入:原先散在本段的 18 处 entry 字段写入(status / models / error / source /
|
|
18
|
+
// promise / pendingCatalogRetry)全部改走 store 动作(setRootStatus / setRootModels),
|
|
19
|
+
// 本文件不再直接改写 state.loaded 里的对象 —— 这是"只剩 store 改状态"的最后一块。
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createScanMonitor, mergeScanBaseline, readNdjsonStream, scanStreamUrl,
|
|
23
|
+
shouldDeferCatalogFailure,
|
|
24
|
+
} from './scan-stream.js';
|
|
25
|
+
import { loadRootModels, loadRootNames, mergeNameTable } from './list.js';
|
|
26
|
+
// 展开态/选中态的持久化(loadGroup 末尾要落盘,与原实现同一处、同一时机)
|
|
27
|
+
import { saveState } from './persist.js';
|
|
28
|
+
import { formatError } from '../infra/api.js';
|
|
29
|
+
// 名册就绪那句 info 走项目统一的日志通道(与 catalog/bundle 那些 diag 同一出口)
|
|
30
|
+
import { stageLog } from '../app/status.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} deps 全部由 view 注入(本文件不 import 任何上层模块)
|
|
34
|
+
* @param {object} deps.store 名册 store(唯一状态持有者;读写一律走它的动作)
|
|
35
|
+
* @param {{render:number, ensureRootLoaded:number, renderScheduled:number}} deps.counters
|
|
36
|
+
* 调试计数器对象(与 view 共用**同一个引用**,只做 ++)
|
|
37
|
+
* @param {Map} deps.nameIndex 名表索引(跨根共用一份)
|
|
38
|
+
* @param {Set} deps.namesLoaded 已取过名表的根(防重复取)
|
|
39
|
+
* @param {Map} deps.modelById modelId → { model, rootId }(名册持有的权威对象来源)
|
|
40
|
+
* @param {() => void} deps.render 立即整棵重绘
|
|
41
|
+
* @param {() => void} deps.scheduleRender 合并到下一帧重绘
|
|
42
|
+
* @param {() => void} deps.flushRender 强制落地待渲染请求
|
|
43
|
+
* @param {(group: object) => void} deps.rebuildGroupChars 重新跨根归并角色
|
|
44
|
+
* @param {(group: object) => object|null} deps.aggregateScan 把该游戏各根的扫描聚合成一个状态
|
|
45
|
+
* @param {(group: object, snap: object) => void} deps.applyScanStatusToRow 就地贴扫描状态
|
|
46
|
+
*/
|
|
47
|
+
export function createRosterLoad({
|
|
48
|
+
store, counters, nameIndex, namesLoaded, modelById,
|
|
49
|
+
render, scheduleRender, flushRender, rebuildGroupChars, aggregateScan, applyScanStatusToRow,
|
|
50
|
+
}) {
|
|
51
|
+
const state = store.state;
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------ 名册加载计时(A)
|
|
54
|
+
//
|
|
55
|
+
// 口径(写清是为了以后不要"人均一套秒表"):
|
|
56
|
+
// · 起点 = 第一次调用 ensureRootLoaded(第一个根开始取 catalog)
|
|
57
|
+
// · 终点 = **所有已知根都 settle**(ready 与 error 都算 settle)
|
|
58
|
+
// 为什么把 error 也算:不然一个根失败会让总时长永远不结束,这个数就永远出不来。
|
|
59
|
+
// · 计时一律 performance.now()(单调),不用 Date.now()(会被系统时间校准拉走)。
|
|
60
|
+
// · 不做阈值判断、不认根名:这里只报事实。
|
|
61
|
+
let rosterT0 = null;
|
|
62
|
+
/** rootId → { rootId, ms, count, source, at, error? }(同一根只留最新一次) */
|
|
63
|
+
const rosterPerRoot = new Map();
|
|
64
|
+
/** 「名册就绪」只打一次:之后再有根被补加载(懒加载/搜索)不再重复刷屏 */
|
|
65
|
+
let rosterLogged = false;
|
|
66
|
+
|
|
67
|
+
/** 记一个根的加载结果;若此刻所有已知根都已 settle,打一条 info 日志并定稿 */
|
|
68
|
+
function recordRootLoad(rootId, t0, count, source, error) {
|
|
69
|
+
rosterPerRoot.set(String(rootId), {
|
|
70
|
+
rootId: String(rootId),
|
|
71
|
+
ms: Math.round(performance.now() - t0),
|
|
72
|
+
count: Number(count) || 0,
|
|
73
|
+
source: error ? 'error' : (source || 'built'),
|
|
74
|
+
at: Date.now(),
|
|
75
|
+
...(error ? { error: String(error) } : {}),
|
|
76
|
+
});
|
|
77
|
+
if (rosterLogged) return;
|
|
78
|
+
const all = state.groups.flatMap((g) => g.roots.map((r) => String(r.id)));
|
|
79
|
+
if (!all.length || !all.every((id) => rosterPerRoot.has(id))) return;
|
|
80
|
+
rosterLogged = true;
|
|
81
|
+
// 延后一拍再取数:根 settle 的那一刻,调用方(ensureGroupLoaded)的
|
|
82
|
+
// 「跨根归并角色」还没跑(它在 await 之后才执行),此刻取 chars 会偏小,
|
|
83
|
+
// 导致日志里的角色数与 rosterStats() 对不上。放到宏任务里取,归并已完成。
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
const s = rosterStats();
|
|
86
|
+
// 走既有 say/log 通道(与 catalog/bundle 那些 diag 同一出口),文案里不带路径与根名
|
|
87
|
+
stageLog('info', `名册就绪:${s.roots} 根 / ${s.chars} 角色 / ${s.ms} ms`, {
|
|
88
|
+
tag: 'roster', roots: s.roots, entries: s.entries, chars: s.chars, ms: s.ms,
|
|
89
|
+
slowest: s.perRoot.slice().sort((a, b) => b.ms - a.ms).slice(0, 3),
|
|
90
|
+
});
|
|
91
|
+
}, 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 名册加载统计快照(`window.__WEB__.rosterStats()` 的实现在这里)。
|
|
96
|
+
*
|
|
97
|
+
* 主要用途是**看哪一根慢/失败**:perRoot 按实际加载顺序给出每根的耗时与来源,
|
|
98
|
+
* `source` 的 cache/built 来自服务端 `/api/catalog` 的自报(前端拿不到这个证据),
|
|
99
|
+
* 失败根为 `error` 并带 `error` 文本 —— 失败绝不从表里消失。
|
|
100
|
+
* @returns {{at: number|null, ms: number, roots: number, entries: number, chars: number,
|
|
101
|
+
* perRoot: Array<{rootId: string, ms: number, count: number, source: string, at: number, error?: string}>}}
|
|
102
|
+
*/
|
|
103
|
+
function rosterStats() {
|
|
104
|
+
const perRoot = [...rosterPerRoot.values()].map((r) => ({ ...r }));
|
|
105
|
+
let chars = 0;
|
|
106
|
+
for (const v of state.groupChars.values()) chars += Array.isArray(v && v.chars) ? v.chars.length : 0;
|
|
107
|
+
// 汇总只算**名册自己的根**:perRoot 允许出现额外条目(别的调用方加载过某个根),
|
|
108
|
+
// 但那不属于"名册加载",算进来会让 roots/entries 与左栏数字对不上。
|
|
109
|
+
const named = new Set(state.groups.flatMap((g) => g.roots.map((r) => String(r.id))));
|
|
110
|
+
const mine = perRoot.filter((r) => named.has(r.rootId));
|
|
111
|
+
return {
|
|
112
|
+
at: mine.length ? Math.max(...mine.map((r) => r.at)) : null,
|
|
113
|
+
// 全部根还没 settle 时给"到目前为止"的耗时:仍是有意义的读数,不是假值
|
|
114
|
+
ms: rosterT0 === null ? 0 : Math.round(performance.now() - rosterT0),
|
|
115
|
+
roots: named.size,
|
|
116
|
+
entries: mine.reduce((s, r) => s + r.count, 0),
|
|
117
|
+
chars,
|
|
118
|
+
perRoot,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ------------------------------------------------------------ 数据加载
|
|
123
|
+
|
|
124
|
+
/** 上一次写进调试日志的"剩余待探"数(只用来去重:数值没变就不再记一行) */
|
|
125
|
+
let lastLoggedProbeRemaining = null;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 记录后台形态探测的进度:写进 state.probe(**给将来 UI 读**),并在数值变化时记一行日志。
|
|
129
|
+
*
|
|
130
|
+
* 为什么只在变化时记:一次启动会按根拉九次 catalog,每根都记一行会把调试面板刷满同类信息;
|
|
131
|
+
* 真正有用的是"剩余数发生变化"这个事件。
|
|
132
|
+
* 为什么不做 UI:本轮只要求可见性 + 数据面预留,展示位留待后续。
|
|
133
|
+
* @param {string} rootId 本次刷新它的根(值是全局队列深度,与根无关,只作溯源)
|
|
134
|
+
* @param {number|null} remaining 服务端 probeRemaining 原值;null = 老服务端没该字段
|
|
135
|
+
*/
|
|
136
|
+
function noteProbeProgress(rootId, remaining) {
|
|
137
|
+
if (!Number.isFinite(remaining)) return; // 没这个字段 ⇒ 不更新、不记录(不把未知当 0)
|
|
138
|
+
store.setProbe({ remaining: Number(remaining), at: Date.now(), rootId });
|
|
139
|
+
if (remaining === lastLoggedProbeRemaining) return;
|
|
140
|
+
lastLoggedProbeRemaining = remaining;
|
|
141
|
+
stageLog('info', `后台形态探测:还剩 ${remaining} 个容器待探(UI 未实现,数据已挂 state.probe)`, {
|
|
142
|
+
root: rootId, remaining,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 显式加载一个游戏(「加载失败 · 重试」或搜索/翻页按需触发时调用)。
|
|
148
|
+
*
|
|
149
|
+
* 只动这个游戏自己的根:不碰其它游戏,也不做任何预取 —— 这是"页面加载不扇出"的关键落点。
|
|
150
|
+
* @param {string} groupKey
|
|
151
|
+
* @param {{reset?: boolean}} [opts] reset=true 时先清掉该游戏各根的加载状态与扫描去重标记(重试用)
|
|
152
|
+
*/
|
|
153
|
+
async function loadGroup(groupKey, opts = {}) {
|
|
154
|
+
const group = state.groups.find((g) => g.key === groupKey);
|
|
155
|
+
if (!group) return;
|
|
156
|
+
if (opts.reset) {
|
|
157
|
+
for (const root of group.roots) {
|
|
158
|
+
const cur = store.getScan(root.id);
|
|
159
|
+
if (cur && cur.controller) { try { cur.controller.abort(); } catch { /* 已结束 */ } }
|
|
160
|
+
store.resetRoots([root.id]);
|
|
161
|
+
store.clearScan(root.id);
|
|
162
|
+
store.unmarkRootSubscribed(root.id);
|
|
163
|
+
}
|
|
164
|
+
store.clearScanBaseline(group.key);
|
|
165
|
+
}
|
|
166
|
+
store.expandGroup(group.key);
|
|
167
|
+
render();
|
|
168
|
+
try { await ensureGroupLoaded(group); } catch { /* 失败由 error 状态与行上的「重试」承载 */ }
|
|
169
|
+
render();
|
|
170
|
+
saveState(state);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 扫描完成(done / cached 帧)之后补取一次清单。
|
|
175
|
+
*
|
|
176
|
+
* 为什么由完成帧驱动而不是定时器:扫描完成后清单已落盘,此时取一次是毫秒级;
|
|
177
|
+
* 用固定等待猜时机既慢又不准。只补一次,仍失败就落到 error,由行上的「加载失败 · 重试」按钮重来。
|
|
178
|
+
* @param {object} root
|
|
179
|
+
*/
|
|
180
|
+
async function refetchCatalog(root) {
|
|
181
|
+
const entry = store.getRoot(root.id);
|
|
182
|
+
if (!entry) return;
|
|
183
|
+
// 三字段一起写:清掉待补标记、回到 loading、清掉上一次的错误文案
|
|
184
|
+
store.setRootStatus(root.id, { pendingCatalogRetry: false, status: 'loading', error: null });
|
|
185
|
+
scheduleRender();
|
|
186
|
+
try {
|
|
187
|
+
const { models, source, serverSource, probeRemaining } = await loadRootModels(root);
|
|
188
|
+
for (const m of models) modelById.set(m.id, { model: m, rootId: root.id });
|
|
189
|
+
// models + source + status='ready' 一次写成(loadRootModels 的 source 恒为 'catalog');
|
|
190
|
+
// 角色树归并由 setRootModels 内部自动完成(唯一写入入口,见 store.js 的注释)
|
|
191
|
+
store.setRootModels(root.id, models, { source });
|
|
192
|
+
noteProbeProgress(root.id, probeRemaining);
|
|
193
|
+
recordRootLoad(root.id, performance.now(), models.length, serverSource || 'built', null);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
store.setRootStatus(root.id, { status: 'error', error: formatError(err) });
|
|
196
|
+
}
|
|
197
|
+
scheduleRender();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 首次展开时用 `stream=1` 订阅扫描进度(NDJSON 帧)。
|
|
202
|
+
*
|
|
203
|
+
* 为什么先流后 JSON:首次扫描(缓存未命中)要几十秒,这段时间原来只有"加载中…";
|
|
204
|
+
* 先订阅流拿到 progress,扫描结束后再走原来的 `/api/catalog`(那时已落盘、命中缓存很快)。
|
|
205
|
+
* 失败一律**不阻断**名册:进度是增强,读不到就照旧等 JSON(只是看不到数字)。
|
|
206
|
+
*
|
|
207
|
+
* @param {object} root
|
|
208
|
+
* @returns {Promise<void>} 流结束(done/cached/error/网络失败)后 resolve —— 永不 reject
|
|
209
|
+
*/
|
|
210
|
+
function trackScanProgress(root) {
|
|
211
|
+
const url = scanStreamUrl(root);
|
|
212
|
+
if (!url || store.hasRootSubscribed(root.id)) return Promise.resolve();
|
|
213
|
+
store.markRootSubscribed(root.id);
|
|
214
|
+
const group = store.groupOfRoot(root.id);
|
|
215
|
+
const monitor = createScanMonitor({
|
|
216
|
+
// 阈值来源:服务端 /api/web-config 下发(可选),缺省用 shared 里的常量——不在这里写死数字
|
|
217
|
+
stallMs: (window.__WEB_CONFIG__ && window.__WEB_CONFIG__.scanStallMs) || undefined,
|
|
218
|
+
// 每次变化都用"该游戏的聚合状态"刷新状态行(多根并行时不能互相覆盖)
|
|
219
|
+
onChange: () => applyScanStatusToRow(group, aggregateScan(group)),
|
|
220
|
+
});
|
|
221
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
222
|
+
store.updateScan(root.id, { monitor, controller, group });
|
|
223
|
+
monitor.begin();
|
|
224
|
+
const done = (async () => {
|
|
225
|
+
try {
|
|
226
|
+
const res = await fetch(url, {
|
|
227
|
+
headers: { accept: 'application/x-ndjson' },
|
|
228
|
+
signal: controller ? controller.signal : undefined,
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
231
|
+
await readNdjsonStream(res, (frame) => monitor.frame(frame));
|
|
232
|
+
monitor.end(); // 流结束但没有完成帧 ⇒ 判失败(不假装完成)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (controller && controller.signal.aborted) return; // 主动关流(重试/换游戏)不算失败
|
|
235
|
+
monitor.fail((error && error.message) || String(error));
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
return done.finally(() => {
|
|
239
|
+
const finalState = monitor.state;
|
|
240
|
+
const cur = store.getScan(root.id);
|
|
241
|
+
if (cur && cur.monitor === monitor) store.clearScan(root.id);
|
|
242
|
+
// 这一根结束了:把它的计数并入"已完成基线",否则下一个根从 0 开始会让总数倒退
|
|
243
|
+
if (group && (finalState.phase === 'loaded' || finalState.phase === 'failed')) {
|
|
244
|
+
store.setScanBaseline(group.key, mergeScanBaseline(store.getScanBaseline(group.key), finalState));
|
|
245
|
+
}
|
|
246
|
+
applyScanStatusToRow(group, aggregateScan(group) || { phase: 'loaded' }); // 其它根还在扫就继续显示它们
|
|
247
|
+
// 扫描完成 ⇒ 若这根的清单请求当初没回来,现在补取一次(只补一次)
|
|
248
|
+
if (finalState.phase === 'loaded') {
|
|
249
|
+
const entry = store.getRoot(root.id);
|
|
250
|
+
if (entry && entry.pendingCatalogRetry) refetchCatalog(root);
|
|
251
|
+
}
|
|
252
|
+
scheduleRender(); // 紧接着渲染一次,把正常计数补回去
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 重试:关掉当前流 → 重新以 stream=1 订阅(服务端单飞,不会重复扫描) */
|
|
257
|
+
function retryScan(groupKey) {
|
|
258
|
+
const group = state.groups.find((g) => g.key === groupKey);
|
|
259
|
+
if (!group) return;
|
|
260
|
+
store.clearScanBaseline(group.key); // 新一轮:基线清零(否则重试后数字会叠加上一轮)
|
|
261
|
+
for (const root of group.roots) {
|
|
262
|
+
const cur = store.getScan(root.id);
|
|
263
|
+
if (cur && cur.controller) { try { cur.controller.abort(); } catch { /* 已结束 */ } }
|
|
264
|
+
store.clearScan(root.id);
|
|
265
|
+
store.unmarkRootSubscribed(root.id); // 允许重新订阅
|
|
266
|
+
}
|
|
267
|
+
const targets = group.roots.filter((r) => scanStreamUrl(r));
|
|
268
|
+
if (!targets.length) return;
|
|
269
|
+
Promise.all(targets.map((root) => trackScanProgress(root)))
|
|
270
|
+
// 进度流跑完(成功或失败)后,若该根还没数据就顺手补一次正常加载
|
|
271
|
+
.then(() => {
|
|
272
|
+
const needLoad = group.roots.filter((r) => {
|
|
273
|
+
const e = store.getRoot(r.id);
|
|
274
|
+
return !e || e.status === 'idle' || e.status === 'error';
|
|
275
|
+
});
|
|
276
|
+
return Promise.all(needLoad.map((r) => ensureRootLoaded(r)));
|
|
277
|
+
})
|
|
278
|
+
.then(() => { rebuildGroupChars(group); scheduleRender(); })
|
|
279
|
+
.catch(() => { /* 重试失败不影响名册本身 */ });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 确保某个资源根的模型已加载。
|
|
284
|
+
* 并发要点:正在加载中的根必须**可等待**(entry.promise),否则后到的调用方会拿到空数据
|
|
285
|
+
* ——启动恢复与"恢复展开态"的加载会并发进来,这一点决定恢复能否命中。
|
|
286
|
+
* @param {object} root
|
|
287
|
+
* @returns {Promise<object>} 该根的加载状态条目(ready / error)
|
|
288
|
+
*/
|
|
289
|
+
function ensureRootLoaded(root) {
|
|
290
|
+
counters.ensureRootLoaded += 1;
|
|
291
|
+
// 名册计时:从**第一次**请求某根开始,到"所有已知根都 settle"结束。
|
|
292
|
+
// 为什么用 performance.now():单调时钟,系统时间校准不会把耗时拉成负数。
|
|
293
|
+
if (rosterT0 === null) rosterT0 = performance.now();
|
|
294
|
+
const cur = store.getRoot(root.id);
|
|
295
|
+
if (cur && cur.promise) return cur.promise.then(() => store.getRoot(root.id));
|
|
296
|
+
if (cur && cur.status === 'ready') return Promise.resolve(cur);
|
|
297
|
+
// 起始态一次写全:status/models/source/error/promise 都经 store 落地
|
|
298
|
+
store.setRootStatus(root.id, {
|
|
299
|
+
status: 'loading', models: [], source: '', error: null, promise: null,
|
|
300
|
+
});
|
|
301
|
+
const rootT0 = performance.now();
|
|
302
|
+
const promise = (async () => {
|
|
303
|
+
try {
|
|
304
|
+
// 名表按根取一次并合并进全局索引(同名表只并一次,避免重复 assign)
|
|
305
|
+
if (!namesLoaded.has(root.id)) {
|
|
306
|
+
namesLoaded.add(root.id);
|
|
307
|
+
const table = await loadRootNames(root.id);
|
|
308
|
+
if (table) mergeNameTable(nameIndex, table);
|
|
309
|
+
}
|
|
310
|
+
// 进度流**并行**订阅(不阻塞取清单):
|
|
311
|
+
// 服务端扫描有单飞,catalog 自己也会触发/加入同一次扫描;先 await 流会把首屏拖慢一整轮扫描,
|
|
312
|
+
// 而进度只是"看得见",不该成为加载的前置条件。失败也不阻断(trackScanProgress 自身不 reject)。
|
|
313
|
+
trackScanProgress(root);
|
|
314
|
+
const { models, source, serverSource, probeRemaining } = await loadRootModels(root);
|
|
315
|
+
for (const m of models) {
|
|
316
|
+
// 名册持有的就是这个对象本身(buildChars 的 variants[].model 也是它),
|
|
317
|
+
// 所以"写回三字段"必须落在这里 —— 只改 DOM 的话,下一次 render 读的还是旧对象,
|
|
318
|
+
// 表现为"当场变了色、一切换/搜索就掉回中性色"。
|
|
319
|
+
modelById.set(m.id, { model: m, rootId: root.id });
|
|
320
|
+
}
|
|
321
|
+
// 就绪:models + source + status='ready' 一次写成
|
|
322
|
+
store.setRootModels(root.id, models, { source });
|
|
323
|
+
// 探测进度:顺带记进 state.probe 与调试日志(只在数值变化时记一行,避免刷屏)
|
|
324
|
+
noteProbeProgress(root.id, probeRemaining);
|
|
325
|
+
recordRootLoad(root.id, rootT0, models.length, serverSource || 'built', null);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
// 扫描流仍在推进 ⇒ 暂缓判死:保持 loading,等完成帧后自动补取一次清单
|
|
328
|
+
// (服务端扫描有单飞,done 之后清单已落盘,取一次是毫秒级)
|
|
329
|
+
const liveScan = store.getScan(root.id);
|
|
330
|
+
const livePhase = liveScan && liveScan.monitor.state.phase;
|
|
331
|
+
let entryError = null;
|
|
332
|
+
if (shouldDeferCatalogFailure(livePhase)) {
|
|
333
|
+
store.setRootStatus(root.id, { status: 'loading', error: null, pendingCatalogRetry: true });
|
|
334
|
+
stageLog('info', `清单请求未回来(扫描仍在进行,完成后自动补取):${root.id}`, { source: 'roster' });
|
|
335
|
+
} else {
|
|
336
|
+
entryError = formatError(err);
|
|
337
|
+
store.setRootStatus(root.id, { status: 'error', error: entryError });
|
|
338
|
+
}
|
|
339
|
+
// 失败也要进 perRoot(source:'error'):否则"某个根挂了"在计时里凭空消失,
|
|
340
|
+
// 总耗时看起来还挺快 —— 那正是这个功能最该暴露的情形。
|
|
341
|
+
recordRootLoad(root.id, rootT0, 0, 'error', entryError);
|
|
342
|
+
}
|
|
343
|
+
store.setRootStatus(root.id, { promise: null });
|
|
344
|
+
scheduleRender(); // 加载完成:合并到下一帧(不再每个根各渲一次)
|
|
345
|
+
})();
|
|
346
|
+
store.setRootStatus(root.id, { promise });
|
|
347
|
+
scheduleRender(); // 开始加载:同上(视觉上只是把游戏行标成 loading)
|
|
348
|
+
return promise.then(() => store.getRoot(root.id));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 确保某游戏的模型已加载:先等在飞的根加载完,再补齐未开始的根,最后跨根归并。
|
|
353
|
+
* @param {object} group
|
|
354
|
+
*/
|
|
355
|
+
async function ensureGroupLoaded(group) {
|
|
356
|
+
const entries = group.roots.map((r) => store.getRoot(r.id));
|
|
357
|
+
const inflight = entries.filter((e) => e && e.promise).map((e) => e.promise);
|
|
358
|
+
if (inflight.length) await Promise.all(inflight);
|
|
359
|
+
const pending = group.roots.filter((r) => {
|
|
360
|
+
const e = store.getRoot(r.id);
|
|
361
|
+
return !e || e.status === 'idle' || e.status === 'error';
|
|
362
|
+
});
|
|
363
|
+
if (pending.length) await Promise.all(pending.map((r) => ensureRootLoaded(r)));
|
|
364
|
+
rebuildGroupChars(group);
|
|
365
|
+
scheduleRender();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 搜索需要全局数据:首次输入关键词时把所有游戏补加载(并行,服务端有缓存) */
|
|
369
|
+
async function ensureAllLoaded() {
|
|
370
|
+
await Promise.all(state.groups.map((g) => ensureGroupLoaded(g)));
|
|
371
|
+
// 硬约束①:所有加载完成后**一定**有一次渲染,不依赖 rAF 是否被节流
|
|
372
|
+
flushRender();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------- 串行加载队列
|
|
376
|
+
//
|
|
377
|
+
// 为什么要有队列:名册一打开就要把多个分组都加载完,但服务端的扫描是重活,
|
|
378
|
+
// 并发跑会互相抢 IO(实测 3 个根同时扫,单根从 24s 恶化到 180s+,连静态资源都超时)。
|
|
379
|
+
// 所以:**同一时刻只加载一个分组**,按名册显示顺序推进,其余在队列里等。
|
|
380
|
+
// 队列只影响"谁先谁后",不影响"最终都会加载完"。
|
|
381
|
+
/** @type {string[]} 待加载的分组 key(显示顺序) */
|
|
382
|
+
const pendingQueue = [];
|
|
383
|
+
let pumping = false;
|
|
384
|
+
|
|
385
|
+
/** 该分组在队列里的位次(1 起);0 = 不在队列(已就绪/正在加载/不存在) */
|
|
386
|
+
function queuePositionOf(group) {
|
|
387
|
+
if (!group) return 0;
|
|
388
|
+
const idx = pendingQueue.indexOf(group.key);
|
|
389
|
+
return idx >= 0 ? idx + 1 : 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 把还没就绪的分组按显示顺序放进队列(已在队列/正在加载的跳过) */
|
|
393
|
+
function enqueueRemaining() {
|
|
394
|
+
for (const group of state.groups) {
|
|
395
|
+
if (pendingQueue.includes(group.key)) continue;
|
|
396
|
+
const entry = state.groupChars.get(group.key);
|
|
397
|
+
if (entry && Array.isArray(entry.chars) && entry.chars.length) continue; // 已加载
|
|
398
|
+
const anyLoading = group.roots.some((r) => {
|
|
399
|
+
const e = store.getRoot(r.id);
|
|
400
|
+
return e && (e.promise || e.status === 'loading');
|
|
401
|
+
});
|
|
402
|
+
if (anyLoading) continue; // 正在加载
|
|
403
|
+
pendingQueue.push(group.key);
|
|
404
|
+
}
|
|
405
|
+
scheduleRender();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 开始推进队列(可重复调用;已在推进时直接返回) */
|
|
409
|
+
async function startQueue() {
|
|
410
|
+
if (pumping) return;
|
|
411
|
+
pumping = true;
|
|
412
|
+
try {
|
|
413
|
+
for (;;) {
|
|
414
|
+
if (!pendingQueue.length) break;
|
|
415
|
+
const key = pendingQueue[0];
|
|
416
|
+
const group = state.groups.find((g) => g.key === key);
|
|
417
|
+
if (!group) { pendingQueue.shift(); continue; }
|
|
418
|
+
// 轮到它了:先重画一次(行上从"排队中(第 1 位)"变成"加载中")
|
|
419
|
+
scheduleRender();
|
|
420
|
+
try {
|
|
421
|
+
await ensureGroupLoaded(group);
|
|
422
|
+
} catch {
|
|
423
|
+
// 单个分组失败不阻塞队列:后面还有别的分组要加载
|
|
424
|
+
}
|
|
425
|
+
if (pendingQueue[0] === key) pendingQueue.shift();
|
|
426
|
+
scheduleRender();
|
|
427
|
+
}
|
|
428
|
+
} finally {
|
|
429
|
+
pumping = false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
loadGroup, refetchCatalog, trackScanProgress, retryScan,
|
|
435
|
+
ensureRootLoaded, ensureGroupLoaded, ensureAllLoaded,
|
|
436
|
+
queuePositionOf, enqueueRemaining, startQueue,
|
|
437
|
+
/** 名册加载计时快照(`window.__WEB__.rosterStats()` 用它;口径见本文件"名册加载计时"注释) */
|
|
438
|
+
rosterStats,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
@@ -1,90 +1,99 @@
|
|
|
1
1
|
// roster/persist.js —— 名册展开态与"上次选中"的持久化
|
|
2
2
|
//
|
|
3
3
|
// 键名与结构(modelstage.tree-state.v1):
|
|
4
|
-
// {
|
|
4
|
+
// { groups: [], chars: [], activeGroup: '游戏名', lastByGroup: { '游戏名': '变体id' }, lastModelId? }
|
|
5
5
|
// 单独成模块的原因:读写逻辑与渲染无关,且 view.js 需要守住单文件行数上限。
|
|
6
6
|
|
|
7
7
|
import * as uiState from '../app/ui-state.js';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* 类型筛选的存储键。
|
|
11
|
-
*
|
|
12
|
-
* 为什么不并进 TREE_STATE_KEY:两者的生命周期不同——展开态是"临时视图状态",
|
|
13
|
-
* 类型筛选是跨会话保留的选择;分开存也让"读不懂的值直接忽略"只影响筛选,不牵动整棵树。
|
|
14
|
-
*/
|
|
15
|
-
export const TYPE_FILTER_KEY = 'roster.typeFilter';
|
|
16
9
|
|
|
17
10
|
/**
|
|
18
11
|
* 名册状态存储键(**当前唯一键**,不是历史遗留)。
|
|
19
12
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
13
|
+
* 键名沿用项目早期命名(改名会让已有用户的"上次选中的模型/展开态"丢失),故键名保持不变;
|
|
14
|
+
* 读写统一经 app/ui-state 工厂代理,**字段**用 group 口径 `{groups, chars, activeGroup, lastByGroup}`
|
|
15
|
+
* —— 旧的 game 口径字段(games/activeGame/lastByGame)读到就当没有:不报错、不迁移,下次保存自然写成新格式。
|
|
22
16
|
*/
|
|
23
17
|
export const LS_KEY = uiState.TREE_STATE_KEY;
|
|
24
18
|
|
|
25
|
-
/** 读展开态与上次选中(结构的 {
|
|
19
|
+
/** 读展开态与上次选中(结构的 {groups, chars, activeGroup, lastByGroup}) */
|
|
26
20
|
export function readSaved() {
|
|
27
21
|
try {
|
|
28
22
|
const s = uiState.getTreeState();
|
|
29
23
|
return {
|
|
30
|
-
|
|
24
|
+
groups: new Set(Array.isArray(s.groups) ? s.groups : []),
|
|
31
25
|
chars: new Set(Array.isArray(s.chars) ? s.chars : []),
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
activeGroup: typeof s.activeGroup === 'string' ? s.activeGroup : null,
|
|
27
|
+
lastByGroup: s.lastByGroup && typeof s.lastByGroup === 'object' ? s.lastByGroup : {},
|
|
34
28
|
// 旧格式:只有单个 lastModelId(更早版本写入的),读出来用于兼容恢复
|
|
35
29
|
lastModelId: typeof s.lastModelId === 'string' ? s.lastModelId : null,
|
|
36
30
|
};
|
|
37
31
|
} catch {
|
|
38
|
-
return {
|
|
32
|
+
return { groups: new Set(), chars: new Set(), activeGroup: null, lastByGroup: {}, lastModelId: null };
|
|
39
33
|
}
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
/**
|
|
43
|
-
*
|
|
44
|
-
* @param {object} state 名册状态(读
|
|
37
|
+
* 写展开态与每个游戏上次选中的变体(键名不变;字段用 group 口径:activeGroup 存分组名)。
|
|
38
|
+
* @param {object} state 名册状态(读 expandedGroups/expandedChars/activeGroup/selected)
|
|
45
39
|
*/
|
|
46
40
|
export function saveState(state) {
|
|
47
41
|
try {
|
|
48
42
|
const s = readSaved();
|
|
49
|
-
const
|
|
50
|
-
|
|
43
|
+
const lastByGroup = { ...s.lastByGroup };
|
|
44
|
+
// 内存与存储用同一个字段名(activeGroup):不再为"旧键名"留兼容分支,旧数据读到即视为没有
|
|
45
|
+
const activeGroup = state.activeGroup || null;
|
|
46
|
+
if (activeGroup && state.selected) lastByGroup[activeGroup] = state.selected.modelId;
|
|
51
47
|
uiState.setTreeState({
|
|
52
|
-
|
|
48
|
+
groups: [...state.expandedGroups],
|
|
53
49
|
chars: [...state.expandedChars],
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
activeGroup: activeGroup || s.activeGroup,
|
|
51
|
+
lastByGroup,
|
|
56
52
|
});
|
|
57
53
|
} catch {
|
|
58
54
|
// 隐私模式等写不了就忽略:持久化是增强,不影响当前会话
|
|
59
55
|
}
|
|
60
56
|
}
|
|
61
57
|
|
|
58
|
+
|
|
62
59
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
60
|
+
* 「上次所用模型」指针的存储键。
|
|
61
|
+
*
|
|
62
|
+
* 为什么单独存一份(不复用 lastByGroup):冷启动要能**只加载一个根**,就必须知道模型属于哪个根;
|
|
63
|
+
* 而 lastByGroup 只记"游戏名 → 模型 id",同一个游戏常有 2~3 个根,光靠它无法定位到根。
|
|
64
|
+
* 结构:{ rootId: string, kind: string|null, modelId: string, at: number }
|
|
67
65
|
*/
|
|
68
|
-
export
|
|
66
|
+
export const LAST_MODEL_KEY = 'roster.lastModel';
|
|
67
|
+
|
|
68
|
+
/** 读上次所用模型指针(形状不对当没有:宁可回到"什么都不加载",也不要拿半截数据去扫) */
|
|
69
|
+
export function readLastModel() {
|
|
69
70
|
try {
|
|
70
|
-
const raw = localStorage.getItem(
|
|
71
|
-
if (!raw) return
|
|
71
|
+
const raw = localStorage.getItem(LAST_MODEL_KEY);
|
|
72
|
+
if (!raw) return null;
|
|
72
73
|
const parsed = JSON.parse(raw);
|
|
73
|
-
|
|
74
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
75
|
+
const rootId = typeof parsed.rootId === 'string' ? parsed.rootId : '';
|
|
76
|
+
const modelId = typeof parsed.modelId === 'string' ? parsed.modelId : '';
|
|
77
|
+
if (!rootId || !modelId) return null;
|
|
78
|
+
return { rootId, modelId, kind: typeof parsed.kind === 'string' ? parsed.kind : null, at: Number(parsed.at) || 0 };
|
|
74
79
|
} catch {
|
|
75
|
-
return
|
|
80
|
+
return null;
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* @
|
|
85
|
+
* 记下这次所用的模型(供下次冷启动"只加载它所在的那一个根")。
|
|
86
|
+
* 写失败静默:这只是加速启动的增强,不能影响选中本身。
|
|
87
|
+
* @param {{rootId?: string, kind?: string|null, modelId?: string}|null} model 变体里的 model 对象
|
|
83
88
|
*/
|
|
84
|
-
export function
|
|
89
|
+
export function saveLastModel(model) {
|
|
85
90
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
91
|
+
const rootId = model && model.rootId ? String(model.rootId) : '';
|
|
92
|
+
const modelId = model && (model.id || model.name) ? String(model.id || model.name) : '';
|
|
93
|
+
if (!rootId || !modelId) return false;
|
|
94
|
+
localStorage.setItem(LAST_MODEL_KEY, JSON.stringify({
|
|
95
|
+
rootId, kind: model.kind ? String(model.kind) : null, modelId, at: Date.now(),
|
|
96
|
+
}));
|
|
88
97
|
return true;
|
|
89
98
|
} catch {
|
|
90
99
|
return false;
|