modelstage 0.5.24 → 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.
- package/README.md +7 -0
- package/bin/modelstage.mjs +48 -12
- package/config/models/live2d.azurlane.json +1 -1
- package/config/models/live2d.azurlane_spine.json +1 -1
- package/config/models/live2d.npc.json +1 -1
- package/config/models/spine.azurlane.json +1 -1
- package/config/models/spine.azurlane_spine.json +1 -1
- package/config/models/spine.npc.json +1 -1
- package/package.json +7 -2
- package/src/server/http/write.mjs +8 -1
- package/src/server/serve.mjs +62 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/components.css +59 -0
- package/src/web/index.html +2 -0
- package/src/web/roster/persist.js +40 -0
- package/src/web/roster/snapshot.js +2 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +93 -2
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"rootId":"npc","rootName":"棕色尘埃2","rootPath":"/volume1/VirtualDSM/DeepSeekHarness/npc","ts":
|
|
1
|
+
{"rootId":"npc","rootName":"棕色尘埃2","rootPath":"/volume1/VirtualDSM/DeepSeekHarness/npc","ts":1793868513580,"count":0,"dirStamps":{"root":null,"dirs":{}},"generator":"642803b7","models":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelstage",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
4
4
|
"description": "Spine / Live2D 模型查看器:三级名册、两套渲染面共享相机与面板、UnityFS 资源包解包",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
"plugins/unity-spine-bundle/",
|
|
25
25
|
"tool/parse-jsonc.mjs",
|
|
26
26
|
"tool/resolve-live2d-runtimes.mjs",
|
|
27
|
-
"tool/lib/",
|
|
27
|
+
"tool/lib/bundle-probe-node.mjs",
|
|
28
|
+
"tool/lib/paths.mjs",
|
|
29
|
+
"tool/lib/runtime-guides.mjs",
|
|
30
|
+
"tool/lib/runtime-package.mjs",
|
|
31
|
+
"tool/lib/spine-meta.mjs",
|
|
32
|
+
"tool/lib/spine-motions.mjs",
|
|
28
33
|
"tool/spine-motion-rules.json",
|
|
29
34
|
"config/plugins.json",
|
|
30
35
|
"config/models/",
|
|
@@ -508,7 +508,14 @@ export function createWriteApi({ paths, config, models, names }) {
|
|
|
508
508
|
|
|
509
509
|
// ---------- ③ 校验并算出目标状态(纯内存,失败则磁盘不动)----------
|
|
510
510
|
const plan = planChange(action, payload, raw);
|
|
511
|
-
if (!plan.ok)
|
|
511
|
+
if (!plan.ok) {
|
|
512
|
+
// planChange 把 path / existing 这类补充字段放在**顶层**(不是 plan.extra),
|
|
513
|
+
// 这里要显式把除 status/error/ok 之外的字段都透传出去 ——
|
|
514
|
+
// 否则 409 的 existing、400 的 path 会在响应里变成 undefined,
|
|
515
|
+
// 前端想提示"已登记的是哪个根"时拿不到依据。
|
|
516
|
+
const { ok: _ok, status, error, ...extra } = plan;
|
|
517
|
+
return fail(status, error, extra);
|
|
518
|
+
}
|
|
512
519
|
|
|
513
520
|
// ---------- ④ 备份(写盘前)----------
|
|
514
521
|
let backup = null;
|
package/src/server/serve.mjs
CHANGED
|
@@ -236,7 +236,54 @@ const ctx = {
|
|
|
236
236
|
* @param {string} p
|
|
237
237
|
* @param {URL} url
|
|
238
238
|
*/
|
|
239
|
+
/**
|
|
240
|
+
* 版本号:读 package.json(它是版本号的唯一权威,与 bin 的 --version 同源)。
|
|
241
|
+
* 读不到时返回 'unknown' 而不是抛错 —— 探活端点不该因为包元信息缺失而 500。
|
|
242
|
+
*/
|
|
243
|
+
function readVersion() {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || 'unknown';
|
|
246
|
+
} catch {
|
|
247
|
+
return 'unknown';
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* GET /api/health —— 探活端点(`web.sh status` 与外部监控用)。
|
|
253
|
+
*
|
|
254
|
+
* 为什么单独一条而不复用 /api/ready:ready 是"能否开始服务请求"的业务就绪信号
|
|
255
|
+
* (要扫资源根、可能 503),而 health 只回答"进程活着吗、什么版本、数据在哪"。
|
|
256
|
+
* 两者语义不同,混一起会让监控在"资源根还没扫完"时误判进程已死。
|
|
257
|
+
*
|
|
258
|
+
* 只暴露排障必需的粗粒度信息:版本 / 运行时长 / 数据根绝对路径 / 插件数。
|
|
259
|
+
* **不返回**配置内容、资源根清单、任何凭据或环境变量。
|
|
260
|
+
*/
|
|
261
|
+
function handleHealth(req, res, p) {
|
|
262
|
+
if (p !== '/api/health') return false;
|
|
263
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
264
|
+
sendJson(res, 405, { ok: false, error: '仅支持 GET' });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
// 插件数:直接问数据层(与 /api/plugins 同源)。
|
|
268
|
+
// 早先这里写的是 ctx.providers(),但本函数没有 ctx 参数 —— 异常被 catch 吞成 0,
|
|
269
|
+
// 结果 health 永远报 plugins:0(实测 5 个插件时也报 0)。探活字段报假数比不报更糟,故改为同源直取。
|
|
270
|
+
let pluginCount = 0;
|
|
271
|
+
try {
|
|
272
|
+
pluginCount = (data.plugins.readPlugins() || []).length;
|
|
273
|
+
} catch { /* 插件系统未就绪也算 0,探活不该因此失败 */ }
|
|
274
|
+
sendJson(res, 200, {
|
|
275
|
+
ok: true,
|
|
276
|
+
uptimeSec: Math.round(process.uptime()),
|
|
277
|
+
version: readVersion(),
|
|
278
|
+
dataRoot: data.paths.dataRoot,
|
|
279
|
+
plugins: pluginCount,
|
|
280
|
+
});
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
239
284
|
function dispatch(req, res, p, url) {
|
|
285
|
+
// 探活放最前:它不该被其它路由的鉴权/副作用影响(也就不会因为那些逻辑出错而"连探活都挂")
|
|
286
|
+
if (handleHealth(req, res, p)) return true;
|
|
240
287
|
if (unpackRoutes(req, res, p, url)) return true;
|
|
241
288
|
if (handleDiag(req, res, p, url, ctx)) return true;
|
|
242
289
|
if (handleReady(req, res, p, url, ctx)) return true;
|
|
@@ -302,6 +349,21 @@ server.listen(PORT, '0.0.0.0', () => {
|
|
|
302
349
|
log.info('资源根已登记', { roots: data.config.readConfig().map((r) => `${r.id}=${r.name}`) });
|
|
303
350
|
});
|
|
304
351
|
|
|
352
|
+
// 监听失败要**说人话**:不处理的话用户看到的是 EADDRINUSE 原始堆栈(一大坨,且不说下一步做什么)。
|
|
353
|
+
// 退出码用 2 而不是 1:1 留给"参数/配置错误"这类,端口占用是环境问题,靠退出码就能区分。
|
|
354
|
+
server.on('error', (e) => {
|
|
355
|
+
const code = (e && e.code) || '';
|
|
356
|
+
if (code === 'EADDRINUSE') {
|
|
357
|
+
log.error(`端口 ${PORT} 已被占用:用 --port <其他端口> 换一个,或先停掉占用该端口的进程`);
|
|
358
|
+
log.error(`排查提示:lsof -i :${PORT}(macOS/Linux)或 netstat -ano | findstr :${PORT}(Windows)`);
|
|
359
|
+
process.exit(2);
|
|
360
|
+
}
|
|
361
|
+
// 其它监听错误(EACCES 权限不足、EADDRNOTAVAIL 地址不可用…):保留原始信息,同样给出下一步
|
|
362
|
+
log.error(`监听端口 ${PORT} 失败:${(e && e.message) || e}`, { code });
|
|
363
|
+
log.error('可尝试:换一个端口(--port),或检查是否有权限监听该端口/该地址');
|
|
364
|
+
process.exit(2);
|
|
365
|
+
});
|
|
366
|
+
|
|
305
367
|
/**
|
|
306
368
|
* 收尾钩子:先把**常驻资源**放掉再退出。
|
|
307
369
|
*
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// shared/type-labels.mjs —— 内容类型(kind)的显示名映射(**单一来源**)
|
|
2
|
+
//
|
|
3
|
+
// 谁用:前端名册的类型筛选胶囊、以及将来可能的插件/其它视图。
|
|
4
|
+
// 为什么放 shared 而不是各自写一份:显示名是"同一事实两处表达"的高危点——
|
|
5
|
+
// 前端写一套、插件再写一套,很快就会出现"同一个类型两处叫法不同"。
|
|
6
|
+
//
|
|
7
|
+
// 规则(设计口径):
|
|
8
|
+
// · 已知类型 → 可读名(下表)
|
|
9
|
+
// · 未知类型 → **原样返回 key**,不做大小写美化(避免"看起来认识其实不认识")
|
|
10
|
+
|
|
11
|
+
/** kind 为 null/空时的归并键(未解包容器条目走这一档,见设计稿 §5.1) */
|
|
12
|
+
export const UNKNOWN_TYPE_KEY = 'unknown';
|
|
13
|
+
|
|
14
|
+
/** 内置显示名:键是条目上的 kind 值(null 一律先归成 UNKNOWN_TYPE_KEY) */
|
|
15
|
+
export const TYPE_LABELS = Object.freeze({
|
|
16
|
+
spine: 'Spine',
|
|
17
|
+
live2d: 'Live2D',
|
|
18
|
+
[UNKNOWN_TYPE_KEY]: '待解包',
|
|
19
|
+
bundle: '资源包',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 取显示名:已知返回中文/品牌名,未知**原值返回**。
|
|
24
|
+
* @param {string} typeKey 类型键(如 'spine' / 'unknown' / 将来插件的自定义值)
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function typeLabel(typeKey) {
|
|
28
|
+
const key = String(typeKey == null ? '' : typeKey);
|
|
29
|
+
if (!key) return String(UNKNOWN_TYPE_KEY);
|
|
30
|
+
return Object.prototype.hasOwnProperty.call(TYPE_LABELS, key) ? TYPE_LABELS[key] : key;
|
|
31
|
+
}
|
package/src/web/components.css
CHANGED
|
@@ -271,6 +271,65 @@
|
|
|
271
271
|
border-color: var(--color-primary);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/* ------------------------------------------------------------ 类型筛选胶囊行 */
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 类型胶囊行(搜索框与名册列表之间)。
|
|
278
|
+
*
|
|
279
|
+
* 两个必须的几何约束(设计稿 §4.1 与 §10.2):
|
|
280
|
+
* ① **自动换行、不横向滚动**:类型数量由数据决定(今天 3 种,将来可能更多),
|
|
281
|
+
* 横向滚动在 304px 的窄栏里几乎不可用;
|
|
282
|
+
* ② **单行最小高度**:类型从 2 行回落到 1 行时,若容器随之变矮,下面的列表会"猛地往上一跳"。
|
|
283
|
+
* 给 min-height 兜住这一档,高度变化只发生在真的加了一整行时。
|
|
284
|
+
*/
|
|
285
|
+
.roster-type-filter {
|
|
286
|
+
flex: none;
|
|
287
|
+
display: flex;
|
|
288
|
+
flex-wrap: wrap;
|
|
289
|
+
gap: 6px;
|
|
290
|
+
min-height: 22px; /* 单行高度:防列表因胶囊行消失/收缩而跳动 */
|
|
291
|
+
padding: 0 12px 8px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.roster-type-filter[hidden] {
|
|
295
|
+
display: none;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* 单个类型胶囊:复用 .mini-btn 的视觉语言(描边 + 底色),但高度更小、可换行 */
|
|
299
|
+
.roster-type-chip,
|
|
300
|
+
.roster-type-clear {
|
|
301
|
+
height: 22px;
|
|
302
|
+
padding: 0 8px;
|
|
303
|
+
border: 1px solid var(--color-border);
|
|
304
|
+
border-radius: 999px;
|
|
305
|
+
background: var(--color-muted);
|
|
306
|
+
color: var(--color-foreground);
|
|
307
|
+
font-size: 12px;
|
|
308
|
+
line-height: 20px;
|
|
309
|
+
white-space: nowrap;
|
|
310
|
+
cursor: pointer;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.roster-type-chip:hover,
|
|
314
|
+
.roster-type-clear:hover {
|
|
315
|
+
background: var(--color-secondary);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* 选中态:主色描边 + 主色底(与工具栏 aria-pressed 同一套语义,颜色浓度略低以免抢眼) */
|
|
319
|
+
.roster-type-chip[aria-pressed='true'] {
|
|
320
|
+
border-color: var(--color-primary);
|
|
321
|
+
color: var(--color-primary);
|
|
322
|
+
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* 「清除」比胶囊更轻:它是辅助动作,不该和类型胶囊抢视觉权重 */
|
|
327
|
+
.roster-type-clear {
|
|
328
|
+
border-style: dashed;
|
|
329
|
+
color: var(--color-muted-foreground, var(--color-foreground));
|
|
330
|
+
opacity: 0.85;
|
|
331
|
+
}
|
|
332
|
+
|
|
274
333
|
.roster-list {
|
|
275
334
|
padding: 2px 0 8px;
|
|
276
335
|
}
|
package/src/web/index.html
CHANGED
|
@@ -90,6 +90,8 @@
|
|
|
90
90
|
<input id="roster-search" type="search" autocomplete="off"
|
|
91
91
|
placeholder="搜索角色 / 服装 / 变体…" aria-label="搜索角色、服装、变体" />
|
|
92
92
|
</div>
|
|
93
|
+
<!-- 类型胶囊行:内容由 roster/view.js 渲染(名册为空时整行隐藏);放搜索与列表之间 -->
|
|
94
|
+
<div id="roster-type-filter" class="roster-type-filter" role="group" aria-label="按内容类型筛选" hidden></div>
|
|
93
95
|
<!-- 三级树:资源根 → 角色 → 变体(首次展开某根才请求该根的模型清单) -->
|
|
94
96
|
<div id="roster-list" class="panel-body roster-list" role="tree" aria-label="角色库" aria-live="polite">
|
|
95
97
|
<p class="muted-hint">正在读取资源根…</p>
|
|
@@ -6,6 +6,14 @@
|
|
|
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
|
*
|
|
@@ -50,3 +58,35 @@ export function saveState(state) {
|
|
|
50
58
|
// 隐私模式等写不了就忽略:持久化是增强,不影响当前会话
|
|
51
59
|
}
|
|
52
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读类型筛选的选中集合(数组;读不到/形状不对一律当空)。
|
|
64
|
+
* 注意:这里**不校验 key 是否仍然有效**——失效项的忽略发生在渲染期(见 roster/type-filter.js
|
|
65
|
+
* 的 usableTypeSelection),这样存储里的值不会被我们悄悄删掉。
|
|
66
|
+
* @returns {string[]}
|
|
67
|
+
*/
|
|
68
|
+
export function readTypeFilter() {
|
|
69
|
+
try {
|
|
70
|
+
const raw = localStorage.getItem(TYPE_FILTER_KEY);
|
|
71
|
+
if (!raw) return [];
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
return Array.isArray(parsed) ? parsed.filter((key) => typeof key === 'string' && key) : [];
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 写类型筛选(失败静默:筛选是增强,隐私模式/配额满都不该影响当前会话)。
|
|
81
|
+
* @param {Iterable<string>} keys
|
|
82
|
+
* @returns {boolean} 是否写入成功(调用方一般不需要它,测试用)
|
|
83
|
+
*/
|
|
84
|
+
export function saveTypeFilter(keys) {
|
|
85
|
+
try {
|
|
86
|
+
const list = [...(keys || [])].map(String).filter(Boolean);
|
|
87
|
+
localStorage.setItem(TYPE_FILTER_KEY, JSON.stringify(list));
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -25,6 +25,8 @@ export function snapshotRoster({ state, listEl, gameStatus }) {
|
|
|
25
25
|
expandedGames: [...state.expandedGames],
|
|
26
26
|
expandedChars: [...state.expandedChars],
|
|
27
27
|
keyword: state.keyword,
|
|
28
|
+
/** 类型筛选的选中集合(**存储原样**,可能含当前名册里不存在的失效项) */
|
|
29
|
+
typeFilter: state.typeFilter ? [...state.typeFilter] : [],
|
|
28
30
|
selected: state.selected,
|
|
29
31
|
/** 最近一次"定位当前角色"的结果(visible / scrolled / skipped-user-scrolling / …) */
|
|
30
32
|
lastReveal: state.lastReveal || null,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// roster/type-filter.js —— 名册「内容类型」筛选(纯函数,不碰 DOM、不读存储)
|
|
2
|
+
//
|
|
3
|
+
// 与 filter.js 的分工:
|
|
4
|
+
// · filter.js 管**关键词**(角色整组判定:命中角色名或任一变体即显示整组)
|
|
5
|
+
// · 本文件 管**类型**(按条目自带的 kind 归并、计数、过滤)
|
|
6
|
+
// 两者正交:视图层先按类型筛,再按关键词筛(或反过来),结果一致(交集)。
|
|
7
|
+
//
|
|
8
|
+
// 类型从哪来:条目的 `kind`(内容类型)。**未解包的容器条目 kind 可能为 null**,
|
|
9
|
+
// 一律归入 UNKNOWN_TYPE_KEY(显示名「待解包」)—— 否则这些条目在"选中任意类型"时会被全部滤掉,
|
|
10
|
+
// 用户会以为资源包不见了(设计稿 §5.1 的关键结论)。
|
|
11
|
+
|
|
12
|
+
import { UNKNOWN_TYPE_KEY } from '../../shared/type-labels.mjs';
|
|
13
|
+
import { visibleChars } from './filter.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 单个变体的类型键:条目 kind 为空 ⇒ unknown。
|
|
17
|
+
* 为什么看 kind 而不是 container:container 是"装载方式"(要先解包),kind 才是"内容类型"。
|
|
18
|
+
* @param {{model?: {kind?: string|null}}} variant
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function typeKeyOf(variant) {
|
|
22
|
+
const model = variant && variant.model ? variant.model : variant;
|
|
23
|
+
const kind = model && model.kind != null ? String(model.kind).trim() : '';
|
|
24
|
+
return kind || UNKNOWN_TYPE_KEY;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 类型计数:**当前关键词下、该类型可见的变体数**(不含类型过滤本身的影响)。
|
|
29
|
+
*
|
|
30
|
+
* 为什么要排除类型过滤:数字要回答"如果我再选它,会有多少"。
|
|
31
|
+
* 若把类型过滤也算进去,选中 Spine 后其它胶囊会立刻变 0,用户就没法判断要不要切换。
|
|
32
|
+
* 为什么仍计入关键词:面板上的数字必须和"现在列表里能看到多少"对得上(设计稿 §5.3)。
|
|
33
|
+
*
|
|
34
|
+
* scope 参数是为将来扩维度(如按资源根)预留的:当前只消费 keyword,新增维度时只加 scope 字段,
|
|
35
|
+
* 不动本函数的排序/计数逻辑(设计稿 §5.4)。
|
|
36
|
+
*
|
|
37
|
+
* @param {{games: object[], gameChars: Map, keyword?: string,
|
|
38
|
+
* scope?: {keyword?: string}}} input
|
|
39
|
+
* @returns {Array<{key: string, count: number}>} 按数量降序,并列按 key 字典序(稳定)
|
|
40
|
+
*/
|
|
41
|
+
export function deriveTypeCounts({ games, gameChars, keyword = '', scope = null }) {
|
|
42
|
+
const effectiveKeyword = String((scope && scope.keyword) != null ? scope.keyword : keyword || '').toLowerCase();
|
|
43
|
+
const counts = new Map();
|
|
44
|
+
for (const game of games || []) {
|
|
45
|
+
// 复用关键词过滤的同一口径(角色整组判定),保证"数字"与"列表可见性"不会两套算法
|
|
46
|
+
const chars = visibleChars(game, { gameChars, keyword: effectiveKeyword });
|
|
47
|
+
for (const char of chars) {
|
|
48
|
+
for (const variant of char.variants || []) {
|
|
49
|
+
const typeKey = typeKeyOf(variant);
|
|
50
|
+
counts.set(typeKey, (counts.get(typeKey) || 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...counts.entries()]
|
|
55
|
+
.map(([key, count]) => ({ key, count }))
|
|
56
|
+
.sort((left, right) => (right.count - left.count) || (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 按选中类型过滤角色(并把每个角色的变体裁到命中集合)。
|
|
61
|
+
*
|
|
62
|
+
* 语义:selectedTypes 为空 ⇒ 不过滤(等于"全部");否则 = 并集(命中任一选中类型即保留)。
|
|
63
|
+
* 注意返回的是**新数组/新角色对象**,不改动 state.gameChars 里的原对象(渲染层只读)。
|
|
64
|
+
*
|
|
65
|
+
* @param {object[]} chars 角色数组(每个带 variants)
|
|
66
|
+
* @param {Set<string>|string[]} selectedTypes
|
|
67
|
+
* @returns {object[]}
|
|
68
|
+
*/
|
|
69
|
+
export function filterCharsByType(chars, selectedTypes) {
|
|
70
|
+
const selected = selectedTypes instanceof Set ? selectedTypes : new Set(selectedTypes || []);
|
|
71
|
+
if (!selected.size) return chars;
|
|
72
|
+
const kept = [];
|
|
73
|
+
for (const char of chars) {
|
|
74
|
+
const variants = (char.variants || []).filter((variant) => selected.has(typeKeyOf(variant)));
|
|
75
|
+
if (variants.length) kept.push({ ...char, variants });
|
|
76
|
+
}
|
|
77
|
+
return kept;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 把存储里读到的选中集合收敛成"当前可用"的集合。
|
|
82
|
+
*
|
|
83
|
+
* 失效项(当前类型集合里没有的 key)在**当前页面生命周期内被忽略**,但**不删存储**——
|
|
84
|
+
* 这样某个类型暂时消失(例如最后一个资源包被解包)后再出现时,用户的选中会自动恢复。
|
|
85
|
+
* @param {string[]} storedKeys 存储里读到的 key 列表
|
|
86
|
+
* @param {Iterable<string>} availableKeys 当前名册实际出现的类型
|
|
87
|
+
* @returns {Set<string>} 可用的选中集合
|
|
88
|
+
*/
|
|
89
|
+
export function usableTypeSelection(storedKeys, availableKeys) {
|
|
90
|
+
const available = new Set([...availableKeys].map((key) => String(key)));
|
|
91
|
+
const out = new Set();
|
|
92
|
+
for (const raw of Array.isArray(storedKeys) ? storedKeys : []) {
|
|
93
|
+
const key = String(raw == null ? '' : raw);
|
|
94
|
+
if (key && available.has(key)) out.add(key);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
package/src/web/roster/view.js
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { createReveal } from './reveal.js';
|
|
14
14
|
import { visibleChars } from './filter.js';
|
|
15
|
+
import { deriveTypeCounts, filterCharsByType, typeKeyOf, usableTypeSelection } from './type-filter.js';
|
|
16
|
+
import { typeLabel } from '../../shared/type-labels.mjs';
|
|
15
17
|
import { snapshotRoster } from './snapshot.js';
|
|
16
|
-
import { readSaved, saveState } from './persist.js';
|
|
18
|
+
import { readSaved, saveState, readTypeFilter, saveTypeFilter } from './persist.js';
|
|
17
19
|
import {
|
|
18
20
|
createNameIndex, mergeNameTable, loadRootModels, loadRootNames, buildChars,
|
|
19
21
|
} from './list.js';
|
|
@@ -43,6 +45,9 @@ function renderLimitPerGame() {
|
|
|
43
45
|
|
|
44
46
|
/** 创建名册视图并挂到容器上;返回对外 API(供 main.js 与自动化验证使用) */
|
|
45
47
|
export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSelect }) {
|
|
48
|
+
// 类型胶囊行:容器由 index.html 提供(#roster-type-filter),这里只取引用;
|
|
49
|
+
// 取不到(旧页面/单测)就整体降级为"没有类型筛选",不影响名册其余功能。
|
|
50
|
+
const typeFilterEl = typeof document !== 'undefined' ? document.getElementById('roster-type-filter') : null;
|
|
46
51
|
const state = {
|
|
47
52
|
games: [],
|
|
48
53
|
loaded: new Map(),
|
|
@@ -50,6 +55,12 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
|
|
|
50
55
|
expandedGames: new Set(),
|
|
51
56
|
expandedChars: new Set(),
|
|
52
57
|
keyword: '',
|
|
58
|
+
/**
|
|
59
|
+
* 类型筛选的选中集合(**存储原样**,可能含当前名册里已不存在的 key)。
|
|
60
|
+
* 为什么不在读入时就剪掉失效项:设计上要求"暂时消失的类型再出现时自动恢复选中",
|
|
61
|
+
* 剪掉就丢了这条信息;渲染期用 usableTypeSelection 现算生效集合即可。
|
|
62
|
+
*/
|
|
63
|
+
typeFilter: new Set(),
|
|
53
64
|
selected: null,
|
|
54
65
|
activeGame: null,
|
|
55
66
|
/**
|
|
@@ -288,6 +299,43 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
|
|
|
288
299
|
/** 单条变体的可搜索文本(资源名 / 目录名 / 角色名 / 服装名) */
|
|
289
300
|
// ------------------------------------------------------------ 渲染
|
|
290
301
|
|
|
302
|
+
/**
|
|
303
|
+
* 渲染类型胶囊行(搜索框下方那一行)。
|
|
304
|
+
*
|
|
305
|
+
* 为什么不复用工具栏按钮:胶囊要自动换行、要带计数、要表达 aria-pressed 的选中态,
|
|
306
|
+
* 与 .mini-btn 的"单行操作按钮"定位不同;只复用它的视觉语言(描边+底色),类名单列。
|
|
307
|
+
* @param {Array<{key: string, count: number}>} counts 类型与计数(已按数量降序)
|
|
308
|
+
* @param {Set<string>} activeTypes 当前**生效**的选中集合(已剔除当前不存在的失效项)
|
|
309
|
+
*/
|
|
310
|
+
function renderTypeFilter(counts, activeTypes) {
|
|
311
|
+
if (!typeFilterEl) return;
|
|
312
|
+
// 名册为空(一个类型都没有)⇒ 整行隐藏,不留一条空白(设计稿 §4.4)
|
|
313
|
+
if (!counts.length) {
|
|
314
|
+
typeFilterEl.hidden = true;
|
|
315
|
+
typeFilterEl.replaceChildren();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
typeFilterEl.hidden = false;
|
|
319
|
+
const nodes = counts.map(({ key, count }) => {
|
|
320
|
+
const chip = el('button', 'roster-type-chip', `${typeLabel(key)} ${count}`);
|
|
321
|
+
chip.type = 'button';
|
|
322
|
+
chip.dataset.typeKey = key;
|
|
323
|
+
chip.setAttribute('aria-pressed', activeTypes.has(key) ? 'true' : 'false');
|
|
324
|
+
chip.title = `只看 ${typeLabel(key)}(当前搜索下 ${count} 个变体)`;
|
|
325
|
+
return chip;
|
|
326
|
+
});
|
|
327
|
+
// 「清除」只在至少选 1 个时出现;不提供「全部」按钮(全部 = 全部取消选中)
|
|
328
|
+
if (activeTypes.size) {
|
|
329
|
+
const clear = el('button', 'roster-type-clear', '清除');
|
|
330
|
+
clear.type = 'button';
|
|
331
|
+
clear.dataset.role = 'clear-type-filter';
|
|
332
|
+
clear.setAttribute('aria-label', '清除类型筛选');
|
|
333
|
+
clear.title = '清除类型筛选(等价于不筛选)';
|
|
334
|
+
nodes.push(clear);
|
|
335
|
+
}
|
|
336
|
+
typeFilterEl.replaceChildren(...nodes);
|
|
337
|
+
}
|
|
338
|
+
|
|
291
339
|
/** 渲染整棵树(只渲染可见部分;展开的角色才生成变体行) */
|
|
292
340
|
function render() {
|
|
293
341
|
counters.render += 1;
|
|
@@ -303,11 +351,26 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
|
|
|
303
351
|
let totalVariants = 0;
|
|
304
352
|
let rendered = 0;
|
|
305
353
|
let loadedGames = 0;
|
|
354
|
+
// 类型胶囊的计数口径 = **当前关键词下**的可见变体数(不含类型过滤本身的影响,见设计稿 §5.3)。
|
|
355
|
+
// 所以先算 counts(只吃 keyword),再算 activeTypes,最后才把类型过滤作用到列表上。
|
|
356
|
+
const typeCounts = deriveTypeCounts({ games: state.games, gameChars: state.gameChars, keyword: state.keyword });
|
|
357
|
+
const activeTypes = usableTypeSelection([...state.typeFilter], typeCounts.map((entry) => entry.key));
|
|
358
|
+
renderTypeFilter(typeCounts, activeTypes);
|
|
359
|
+
// 被类型过滤挡掉的变体数 / 留下来的变体数:只用于"当前筛选下没有条目"的提示文案
|
|
360
|
+
let hiddenByType = 0;
|
|
361
|
+
let keptVariants = 0;
|
|
306
362
|
|
|
307
363
|
for (const game of state.games) {
|
|
308
364
|
const status = gameStatus(game);
|
|
309
365
|
if (status === 'ready') loadedGames += 1;
|
|
310
|
-
const
|
|
366
|
+
const charsByKeyword = visibleChars(game, state);
|
|
367
|
+
const chars = filterCharsByType(charsByKeyword, activeTypes);
|
|
368
|
+
if (activeTypes.size) {
|
|
369
|
+
const visibleCount = charsByKeyword.reduce((n, c) => n + c.variants.length, 0);
|
|
370
|
+
const keptCount = chars.reduce((n, c) => n + c.variants.length, 0);
|
|
371
|
+
hiddenByType += visibleCount - keptCount;
|
|
372
|
+
keptVariants += keptCount;
|
|
373
|
+
}
|
|
311
374
|
const open = state.expandedGames.has(game.key) || (searching && chars.length > 0);
|
|
312
375
|
|
|
313
376
|
listEl.append(renderGameRow(game, {
|
|
@@ -348,6 +411,13 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
|
|
|
348
411
|
}
|
|
349
412
|
}
|
|
350
413
|
|
|
414
|
+
// 类型筛选把所有条目都挡掉时给一句可读提示,不留空白(设计稿 §10.2 第 1 条)。
|
|
415
|
+
// 不自动改用户的选择:解包完成后条目会从 unknown 跳到 spine/live2d,
|
|
416
|
+
// 这里只如实说明"被挡掉了多少个",让用户自己决定是否取消筛选。
|
|
417
|
+
if (activeTypes.size && keptVariants === 0 && hiddenByType > 0) {
|
|
418
|
+
listEl.prepend(el('p', 'muted-hint tree-hint', `当前筛选下没有条目(${hiddenByType} 个已移到其它类型)`));
|
|
419
|
+
}
|
|
420
|
+
|
|
351
421
|
// 计数文案:全量已加载时用统一口径(N 游戏 / M 角色 / K 变体),否则标注已加载多少
|
|
352
422
|
const allLoaded = loadedGames === state.games.length;
|
|
353
423
|
const prefix = allLoaded ? `${state.games.length} 游戏` : `${state.games.length} 游戏 / 已加载 ${loadedGames}`;
|
|
@@ -434,6 +504,25 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
|
|
|
434
504
|
activate(row);
|
|
435
505
|
});
|
|
436
506
|
|
|
507
|
+
// 类型胶囊:事件委托(胶囊每次重渲染都会换新节点,逐个绑定会漏)
|
|
508
|
+
if (typeFilterEl) {
|
|
509
|
+
typeFilterEl.addEventListener('click', (event) => {
|
|
510
|
+
const button = event.target && event.target.closest ? event.target.closest('button') : null;
|
|
511
|
+
if (!button || !typeFilterEl.contains(button)) return;
|
|
512
|
+
if (button.dataset.role === 'clear-type-filter') {
|
|
513
|
+
state.typeFilter.clear();
|
|
514
|
+
} else {
|
|
515
|
+
const typeKey = button.dataset.typeKey;
|
|
516
|
+
if (!typeKey) return;
|
|
517
|
+
if (state.typeFilter.has(typeKey)) state.typeFilter.delete(typeKey);
|
|
518
|
+
else state.typeFilter.add(typeKey);
|
|
519
|
+
}
|
|
520
|
+
// 写失败静默(隐私模式/配额满):筛选是增强,不能因此让页面报错
|
|
521
|
+
saveTypeFilter(state.typeFilter);
|
|
522
|
+
render();
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
437
526
|
searchEl.addEventListener('input', () => {
|
|
438
527
|
state.keyword = searchEl.value.trim().toLowerCase();
|
|
439
528
|
// 搜索要全局数据:首次输入时把所有游戏补上(之后都走缓存,不再请求)
|
|
@@ -460,6 +549,8 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
|
|
|
460
549
|
*/
|
|
461
550
|
function mount(roots, opts) {
|
|
462
551
|
const restoreExpanded = !opts || opts.restoreExpanded !== false;
|
|
552
|
+
// 类型筛选:读存储原样恢复(失效项保留在集合里,渲染期再按当前类型集合筛掉)
|
|
553
|
+
state.typeFilter = new Set(readTypeFilter());
|
|
463
554
|
// 同名根合并成一个游戏节点(照 char-tree.js:name 是归一 key)
|
|
464
555
|
const byName = new Map();
|
|
465
556
|
for (const root of roots) {
|
package/tool/lib/check-perms.mjs
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
// tool/lib/check-perms.mjs —— 源码文件/目录权限预检(门禁)
|
|
2
|
-
//
|
|
3
|
-
// 为什么要这条门禁:本项目由多人/多 AI 会话并行改文件,各自 umask 不同。
|
|
4
|
-
// 一旦某个 .js/.mjs/.css/.json 被写成 600,**Node 服务端(跑在另一个用户下)读不到它**
|
|
5
|
-
// ⇒ 该模块 import 失败 ⇒ 整个 app 在初始化早期崩溃(window.__WEB__ 为空、页面只构建少数元素)
|
|
6
|
-
// ⇒ 所有 e2e 套件拿不到结果、超时被杀 ⇒ 报一堆"未捕获异常"的红。
|
|
7
|
-
// 这种红**全是假红**,比不跑更贵:会让人去查业务代码。已经反复发生三次,所以做成硬门禁。
|
|
8
|
-
//
|
|
9
|
-
// 判据:
|
|
10
|
-
// · 代码文件(js/mjs/css/json/html)必须 644(可读)
|
|
11
|
-
// · 目录必须 755(可进入、可列)
|
|
12
|
-
// · 不检查 node_modules/.git(SCAN_DIRS 已限定在源码目录内)
|
|
13
|
-
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
import { fileURLToPath } from 'node:url';
|
|
17
|
-
|
|
18
|
-
/** 需要检查权限的文件类型(只看我们自己的源码/配置) */
|
|
19
|
-
const CODE_EXT = /\.(js|mjs|css|json|html)$/;
|
|
20
|
-
/** 相对于项目根的扫描目录 */
|
|
21
|
-
const SCAN_DIRS = ['src', 'test', 'tool', 'config', 'public'];
|
|
22
|
-
/**
|
|
23
|
-
* 判据不是"必须是 644",而是「**其他用户可读**」——服务端跑在另一个用户下,只要 go+r 就够。
|
|
24
|
-
* · 文件:需要 0o004(other read)。600/640 不行;644/664/755 都行。
|
|
25
|
-
* · 目录:需要 0o005(other read+execute)。700/750 不行;755/775 都行。
|
|
26
|
-
* 为什么不用精确相等:真实环境 umask 常见 002 ⇒ 664/775 是正常态,卡死它们只会制造假红。
|
|
27
|
-
*/
|
|
28
|
-
const FILE_NEED = 0o004;
|
|
29
|
-
const DIR_NEED = 0o005;
|
|
30
|
-
/** 跳过的大目录(不属于源码,权限由各自包管理器管) */
|
|
31
|
-
const SKIP_NAMES = new Set(['node_modules', '.git', 'dist', 'runtime']);
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* 找出权限不对的文件与目录。
|
|
35
|
-
* @param {string} root 项目根绝对路径
|
|
36
|
-
* @returns {{files: Array<{path: string, mode: string}>, dirs: Array<{path: string, mode: string}>}}
|
|
37
|
-
*/
|
|
38
|
-
export function findBadPerms(root) {
|
|
39
|
-
const files = [];
|
|
40
|
-
const dirs = [];
|
|
41
|
-
const walk = (dir) => {
|
|
42
|
-
let entries;
|
|
43
|
-
try {
|
|
44
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
45
|
-
} catch {
|
|
46
|
-
return; // 目录读不到(本身就可能是权限问题):由上层目录检查覆盖
|
|
47
|
-
}
|
|
48
|
-
for (const entry of entries) {
|
|
49
|
-
if (SKIP_NAMES.has(entry.name)) continue;
|
|
50
|
-
const full = path.join(dir, entry.name);
|
|
51
|
-
if (entry.isDirectory()) {
|
|
52
|
-
const mode = modeOf(full);
|
|
53
|
-
if (mode !== null && (mode & DIR_NEED) !== DIR_NEED) dirs.push({ path: path.relative(root, full), mode: toOctal(mode) });
|
|
54
|
-
walk(full);
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
if (!CODE_EXT.test(entry.name)) continue;
|
|
58
|
-
const mode = modeOf(full);
|
|
59
|
-
if (mode !== null && (mode & FILE_NEED) !== FILE_NEED) files.push({ path: path.relative(root, full), mode: toOctal(mode) });
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
for (const name of SCAN_DIRS) {
|
|
63
|
-
const dir = path.join(root, name);
|
|
64
|
-
if (!fs.existsSync(dir)) continue;
|
|
65
|
-
const mode = modeOf(dir);
|
|
66
|
-
if (mode !== null && (mode & DIR_NEED) !== DIR_NEED) dirs.push({ path: name, mode: toOctal(mode) });
|
|
67
|
-
walk(dir);
|
|
68
|
-
}
|
|
69
|
-
return { files, dirs };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** 取权限位(只有低 9 位有意义);拿不到返回 null */
|
|
73
|
-
function modeOf(target) {
|
|
74
|
-
try {
|
|
75
|
-
return fs.statSync(target).mode & 0o777;
|
|
76
|
-
} catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** 转成可读八进制(644 / 755) */
|
|
82
|
-
function toOctal(mode) {
|
|
83
|
-
return mode.toString(8);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* 一行话总结(供 test-all / web.sh 直接打印)。
|
|
88
|
-
* @returns {string} 没问题返回空串
|
|
89
|
-
*/
|
|
90
|
-
export function summarizeBadPerms({ files, dirs }) {
|
|
91
|
-
if (!files.length && !dirs.length) return '';
|
|
92
|
-
const lines = [
|
|
93
|
-
`源码权限不对:文件 ${files.length} 个、目录 ${dirs.length} 个`,
|
|
94
|
-
'(Node 服务端跑在另一个用户下,读不到这些文件 ⇒ 模块 import 失败 ⇒ 整个 app 崩、e2e 全部超时假红)',
|
|
95
|
-
];
|
|
96
|
-
for (const item of files.slice(0, 12)) lines.push(` ${item.mode} ${item.path}`);
|
|
97
|
-
if (files.length > 12) lines.push(` …另有 ${files.length - 12} 个文件`);
|
|
98
|
-
for (const item of dirs.slice(0, 6)) lines.push(` ${item.mode} ${item.path}/(目录须 755)`);
|
|
99
|
-
lines.push('修复:chmod 644 <文件…>;chmod 755 <目录…>(或 chmod -R u+rwX,go+rX src test tool config public)');
|
|
100
|
-
return lines.join('\n');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** CLI 入口:node tool/lib/check-perms.mjs [项目根] —— 有问题的退出码 1 */
|
|
104
|
-
if (process.argv[1] && process.argv[1].endsWith('check-perms.mjs')) {
|
|
105
|
-
// 必须用 fileURLToPath:项目路径含中文,URL.pathname 是百分号编码,直接当路径会扫空
|
|
106
|
-
const root = path.resolve(process.argv[2] || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..'));
|
|
107
|
-
const bad = findBadPerms(root);
|
|
108
|
-
const msg = summarizeBadPerms(bad);
|
|
109
|
-
if (msg) {
|
|
110
|
-
console.error(msg);
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
113
|
-
console.log('源码权限检查通过(其他用户可读:文件 go+r / 目录 go+rx)');
|
|
114
|
-
}
|