modelstage 0.0.0-stage → 0.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
// debug-panel.js —— 调试日志浮层面板(纯 DOM,只依赖 debug-log 实例)
|
|
2
|
+
//
|
|
3
|
+
// 结构按 docs/设计/调试日志面板.md 的真实 DOM 复现:
|
|
4
|
+
// 容器(288px 宽 / 最高 320px / 圆角 12px / 卡片底 / 阴影 / 纵向 flex)
|
|
5
|
+
// ├ 头部:info 图标 + 「调试日志」 + 「N 条」 | 右侧「清空」「收起」两个图标按钮
|
|
6
|
+
// └ 列表(最高 256px,可滚动,等宽 12px):每条 = 级别图标 + [时间 + [tag]] + 正文
|
|
7
|
+
// 空态「暂无日志」居中淡色;新增条目自动滚到底;折叠只留头部。
|
|
8
|
+
//
|
|
9
|
+
// 与业务解耦:mountDebugPanel({ log, container }) 挂在传入容器上,返回 { destroy() }。
|
|
10
|
+
// 样式来自同目录 debug-panel.css(用 import.meta.url 解析,不写死路径)。
|
|
11
|
+
//
|
|
12
|
+
// 结构:图标 / 时间格式 / 样式注入 / 骨架 / 渲染器 / 编排,各自独立成函数(每函数 ≤50 行)。
|
|
13
|
+
|
|
14
|
+
import { DEBUG_LEVELS } from './log.js';
|
|
15
|
+
import { createPanelDrag } from './drag.js';
|
|
16
|
+
import { createUploader } from './upload.js';
|
|
17
|
+
import * as uiState from '../app/ui-state.js';
|
|
18
|
+
|
|
19
|
+
const CSS_FILE = './panel.css';
|
|
20
|
+
const CSS_LINK_ATTR = 'data-debug-panel-css';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 级别多选状态统一走 app/ui-state 工厂(偏好键的 debug.logLevels 段)。
|
|
24
|
+
* 本文件不直接读写存储 —— 不再有独立的键常量。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 读取上次勾选的级别;没存过或存的值非法 ⇒ 用默认(debug 关、其余开)。
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
function loadLevels() {
|
|
32
|
+
try {
|
|
33
|
+
const arr = uiState.getLogLevels(); // S3:唯一真相源 = 工厂
|
|
34
|
+
if (!Array.isArray(arr)) return [...DEFAULT_LEVELS];
|
|
35
|
+
const valid = arr.filter((x) => LEVEL_UI.some(([lv]) => lv === x));
|
|
36
|
+
return valid.length ? valid : [...DEFAULT_LEVELS];
|
|
37
|
+
} catch {
|
|
38
|
+
return [...DEFAULT_LEVELS];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 保存勾选的级别(写失败不影响使用:持久化只是增强) */
|
|
43
|
+
function saveLevels(levels) {
|
|
44
|
+
try { uiState.setLogLevels([...levels]); } catch { /* 忽略 */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// el 的唯一实现在 infra/dom.js(本文件 31 处调用全靠这一个 import,
|
|
48
|
+
// 删掉旧的私有实现后若漏了它,运行时就是 undefined)。
|
|
49
|
+
import { el } from '../infra/dom.js';
|
|
50
|
+
// 渲染调度(同帧合批)统一走公共内核,不再在本文件里维护第二套 rAF/timer
|
|
51
|
+
import { createScheduler } from '../infra/schedule.js';
|
|
52
|
+
|
|
53
|
+
/** 内联 SVG 图标(不依赖图标字体/外部库) */
|
|
54
|
+
function icon(name) {
|
|
55
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
56
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
57
|
+
for (const [k, v] of Object.entries({
|
|
58
|
+
viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
|
|
59
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
|
|
60
|
+
})) svg.setAttribute(k, v);
|
|
61
|
+
const paths = {
|
|
62
|
+
info: ['M12 16v-4', 'M12 8h.01', 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z'],
|
|
63
|
+
x: ['M18 6 6 18', 'M6 6l12 12'],
|
|
64
|
+
chevron: ['M6 9l6 6 6-6'],
|
|
65
|
+
// 四档/操作用(lucide 同名图标):bug / upload / trash-2
|
|
66
|
+
bug: ['M8 2l1.88 1.88', 'M14.12 3.88 16 2', 'M9 7.13V6a3 3 0 1 1 6 0v1.13',
|
|
67
|
+
'M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6z',
|
|
68
|
+
'M6 13H2', 'M22 13h-4', 'M6.53 9 3.6 7.3', 'M20.4 7.3 17.47 9',
|
|
69
|
+
'M6.53 17 3.6 18.7', 'M20.4 18.7 17.47 17'],
|
|
70
|
+
upload: ['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', 'M17 8l-5-5-5 5', 'M12 3v12'],
|
|
71
|
+
trash: ['M3 6h18', 'M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2',
|
|
72
|
+
'M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6'],
|
|
73
|
+
warn: ['M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z', 'M12 9v4', 'M12 17h.01'],
|
|
74
|
+
error: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M15 9l-6 6', 'M9 9l6 6'],
|
|
75
|
+
};
|
|
76
|
+
for (const d of paths[name] || []) {
|
|
77
|
+
const p = document.createElementNS(NS, 'path');
|
|
78
|
+
p.setAttribute('d', d);
|
|
79
|
+
svg.append(p);
|
|
80
|
+
}
|
|
81
|
+
svg.classList.add('dbg-icon', `dbg-icon-${name}`);
|
|
82
|
+
return svg;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 毫秒时间戳 → HH:MM:SS.mmm */
|
|
86
|
+
function formatTime(ts) {
|
|
87
|
+
const d = new Date(ts);
|
|
88
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
89
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 注入样式表(幂等);返回本次新建的 link(已存在则返回 null,便于销毁时不误删) */
|
|
93
|
+
function ensureStylesheet(doc) {
|
|
94
|
+
if (doc.querySelector(`link[${CSS_LINK_ATTR}]`)) return null;
|
|
95
|
+
const link = doc.createElement('link');
|
|
96
|
+
link.rel = 'stylesheet';
|
|
97
|
+
link.setAttribute(CSS_LINK_ATTR, '1');
|
|
98
|
+
link.href = new URL(CSS_FILE, import.meta.url).href; // 由模块位置推导,不写死
|
|
99
|
+
doc.head.append(link);
|
|
100
|
+
return link;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 第 1 行:info 图标 + 「调试日志」 + 「N 条」 + 收起按钮。
|
|
105
|
+
*
|
|
106
|
+
* 布局约束(防挤爆):左边 `flex:1; min-width:0` 可压缩,收起按钮 `flex:none` 不压缩
|
|
107
|
+
* ⇒ 窗口再窄也只会压缩标题文字,**收起按钮永远不会被挤出右边界**(曾经溢出 6px 点不到)。
|
|
108
|
+
* @param {Document} doc
|
|
109
|
+
*/
|
|
110
|
+
function createHeadRow(doc) {
|
|
111
|
+
const head = el('div', 'dbg-head');
|
|
112
|
+
const left = el('div', 'dbg-head-left');
|
|
113
|
+
const countEl = el('span', 'dbg-count', '0 条');
|
|
114
|
+
left.append(icon('info'), el('span', 'dbg-title', '调试日志'), countEl);
|
|
115
|
+
// 上传结果就写在标题旁:成功给文件名+条数+体积,失败给原因(可见、可重试,不静默)。
|
|
116
|
+
// 平时隐藏;出现时靠 CSS 省略号收窄,不参与把标题行挤爆。
|
|
117
|
+
const uploadStatus = el('span', 'dbg-upload-status', '');
|
|
118
|
+
uploadStatus.hidden = true;
|
|
119
|
+
left.append(uploadStatus);
|
|
120
|
+
|
|
121
|
+
// 操作按钮与标题同一行(图标 + 文字):上传 / 清空 / 收起。
|
|
122
|
+
// 放在第 1 行是为了让第 2 行专心放"级别筛选"——操作与筛选是两件事,不混在一起。
|
|
123
|
+
const acts = el('div', 'dbg-acts');
|
|
124
|
+
const btnUpload = el('button', 'dbg-act');
|
|
125
|
+
btnUpload.type = 'button';
|
|
126
|
+
btnUpload.title = '把当前日志上传到服务端(data/logs/)';
|
|
127
|
+
btnUpload.setAttribute('aria-label', '上传日志');
|
|
128
|
+
const uploadLabel = el('span', 'dbg-act-text', '上传');
|
|
129
|
+
uploadLabel.dataset.uploadLabel = '1';
|
|
130
|
+
btnUpload.append(icon('upload'), uploadLabel);
|
|
131
|
+
|
|
132
|
+
const btnClear = el('button', 'dbg-act');
|
|
133
|
+
btnClear.type = 'button';
|
|
134
|
+
btnClear.title = '清空';
|
|
135
|
+
btnClear.setAttribute('aria-label', '清空日志');
|
|
136
|
+
btnClear.append(icon('trash'), el('span', 'dbg-act-text', '清空'));
|
|
137
|
+
|
|
138
|
+
acts.append(btnUpload, btnClear);
|
|
139
|
+
|
|
140
|
+
const btnCollapse = el('button', 'dbg-collapse');
|
|
141
|
+
btnCollapse.type = 'button';
|
|
142
|
+
btnCollapse.title = '收起';
|
|
143
|
+
btnCollapse.setAttribute('aria-label', '收起日志面板');
|
|
144
|
+
const headChevron = icon('chevron');
|
|
145
|
+
headChevron.classList.add('dbg-chevron', 'dbg-chevron-head');
|
|
146
|
+
btnCollapse.append(headChevron);
|
|
147
|
+
|
|
148
|
+
head.append(left, acts, btnCollapse);
|
|
149
|
+
return { head, countEl, btnCollapse, uploadStatus, btnUpload, btnClear, acts };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 四档定义:图标名取自 lucide 同名图标;顺序固定(调试→信息→警告→错误) */
|
|
153
|
+
const LEVEL_UI = [
|
|
154
|
+
['debug', '调试', 'bug'],
|
|
155
|
+
['info', '信息', 'info'],
|
|
156
|
+
['warn', '警告', 'warn'],
|
|
157
|
+
['error', '错误', 'error'],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/** 默认开启的档位:debug 关(机器日志量大、人不好读),其余三档开 */
|
|
161
|
+
export const DEFAULT_LEVELS = ['info', 'warn', 'error'];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 第 2 行:四档多选按钮(图标 + 文字)。上传/清空在第 1 行,与标题同行。
|
|
165
|
+
*
|
|
166
|
+
* 为什么用按钮而不是下拉:下拉只能表达"某一档及以上",而人真实的诉求是任意组合
|
|
167
|
+
* (比如"只看警告+错误"、"全都看但不要信息级噪音")。多选是能表达这些的唯一形态。
|
|
168
|
+
* @param {Document} doc
|
|
169
|
+
*/
|
|
170
|
+
function createBarRow(doc) {
|
|
171
|
+
const bar = el('div', 'dbg-bar');
|
|
172
|
+
const levels = el('div', 'dbg-levels');
|
|
173
|
+
levels.setAttribute('role', 'group');
|
|
174
|
+
levels.setAttribute('aria-label', '日志级别(可多选)');
|
|
175
|
+
const levelBtns = {};
|
|
176
|
+
for (const [level, text, iconName] of LEVEL_UI) {
|
|
177
|
+
const btn = el('button', 'dbg-lv');
|
|
178
|
+
btn.type = 'button';
|
|
179
|
+
btn.dataset.level = level;
|
|
180
|
+
btn.title = `${text}级:点击显示/隐藏`;
|
|
181
|
+
btn.append(icon(iconName), el('span', 'dbg-lv-text', text));
|
|
182
|
+
levels.append(btn);
|
|
183
|
+
levelBtns[level] = btn;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
bar.append(levels);
|
|
187
|
+
return { bar, levelBtns };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 收起态的胶囊按钮(info 图标 + 「日志」 + 计数徽标 + chevron)。
|
|
192
|
+
*
|
|
193
|
+
* 为什么单独做一颗按钮而不是"把面板缩到只剩标题栏":收起后是一个小胶囊浮在右下角,
|
|
194
|
+
* 用户点它就是"再打开"。这颗胶囊承载"已收起了多少条日志"的信息,比只剩一个图标更清楚。
|
|
195
|
+
* @param {Document} doc
|
|
196
|
+
* @returns {{pill: HTMLButtonElement, badge: HTMLElement}}
|
|
197
|
+
*/
|
|
198
|
+
function createPill(doc) {
|
|
199
|
+
const pill = el('button', 'dbg-pill');
|
|
200
|
+
pill.type = 'button';
|
|
201
|
+
pill.title = '展开调试日志';
|
|
202
|
+
pill.setAttribute('aria-label', '展开调试日志');
|
|
203
|
+
const text = el('span', 'dbg-pill-text', '日志');
|
|
204
|
+
const badge = el('span', 'dbg-pill-count', '0');
|
|
205
|
+
const chev = icon('chevron');
|
|
206
|
+
chev.classList.add('dbg-chevron', 'dbg-chevron-pill');
|
|
207
|
+
pill.append(icon('info'), text, badge, chev);
|
|
208
|
+
// 收起态唯一可见的抓手就是这颗胶囊:标记为"自己是拖拽手柄",
|
|
209
|
+
// 否则 drag 的"按钮不拖"规则会把它排除掉(收起后就再也拖不动了)。
|
|
210
|
+
pill.dataset.dragHandle = 'self';
|
|
211
|
+
pill.title = '展开调试日志(可拖动)';
|
|
212
|
+
return { pill, badge };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 建完整骨架。
|
|
217
|
+
* 注意 container:挂载点**必须**用调用方给的容器(否则面板永远贴在 body 上,
|
|
218
|
+
* 外面再怎么调定位都不生效——踩过:改宿主 left/bottom 完全没效果)。
|
|
219
|
+
*/
|
|
220
|
+
function createShell(doc, container) {
|
|
221
|
+
const root = el('div', 'dbg-panel');
|
|
222
|
+
root.setAttribute('role', 'region');
|
|
223
|
+
root.setAttribute('aria-label', '调试日志');
|
|
224
|
+
const head = createHeadRow(doc);
|
|
225
|
+
const bar = createBarRow(doc);
|
|
226
|
+
const list = el('div', 'dbg-list');
|
|
227
|
+
const { pill, badge } = createPill(doc);
|
|
228
|
+
// 折叠动画用 grid 的 0fr→1fr 技巧(height:auto 不能 transition):
|
|
229
|
+
// .dbg-shell 是**动画外壳**(唯一子项 .dbg-shell-inner,min-height:0 + overflow:hidden),
|
|
230
|
+
// .dbg-shell-inner 内部才是三行网格:标题行 / 工具行 / 列表(行高由 --dbg-h-* 常量给)。
|
|
231
|
+
// 收起时外壳轨道压到 0fr ⇒ 高度平滑归零,不需要猜任何固定高度。
|
|
232
|
+
const shellInner = el('div', 'dbg-shell-inner');
|
|
233
|
+
shellInner.append(head.head, bar.bar, list);
|
|
234
|
+
const shellBox = el('div', 'dbg-shell');
|
|
235
|
+
shellBox.append(shellInner);
|
|
236
|
+
// 初始收起态由挂载方写入(S4:来自工厂;下面这行只是兜底默认,随后会被真实值覆盖)
|
|
237
|
+
root.dataset.collapsed = 'false';
|
|
238
|
+
// 默认展开方向:与 CSS 默认定位(right/bottom)一致 ⇒ 向左上长,胶囊落在右下角
|
|
239
|
+
root.dataset.openDir = 'up-left';
|
|
240
|
+
root.append(pill, shellBox);
|
|
241
|
+
(container || doc.body).append(root);
|
|
242
|
+
return {
|
|
243
|
+
root, list, pill, badge, shellBox,
|
|
244
|
+
...head, ...bar,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 生成一条日志的 DOM */
|
|
249
|
+
function renderEntry(entry, { isCollapsed }) {
|
|
250
|
+
const level = DEBUG_LEVELS.includes(entry.level) ? entry.level : 'info';
|
|
251
|
+
const row = el('div', `dbg-row dbg-row-${level}`);
|
|
252
|
+
const levelIcon = icon(level === 'info' ? 'info' : level);
|
|
253
|
+
levelIcon.classList.add('dbg-level-icon');
|
|
254
|
+
row.append(levelIcon);
|
|
255
|
+
|
|
256
|
+
const body = el('div', 'dbg-body');
|
|
257
|
+
const meta = el('div', 'dbg-meta');
|
|
258
|
+
meta.append(el('span', 'dbg-time', formatTime(entry.ts)));
|
|
259
|
+
// tag 在 DOM 里保持小写,显示靠 CSS text-transform: uppercase
|
|
260
|
+
// 来源(app/error/net/…):一眼看出这条是"应用自己说的"还是"探针抓到的"
|
|
261
|
+
meta.append(el('span', `dbg-src dbg-src-${String(entry.src || 'app').toLowerCase()}`, String(entry.src || 'app')));
|
|
262
|
+
meta.append(el('span', 'dbg-tag', `[${String(entry.tag || '').toLowerCase()}]`));
|
|
263
|
+
// 结构化字段是自有属性:正文只写"发生了什么",细节挂在行上(悬停可见)+ 一个 { } 标记
|
|
264
|
+
const detail = entry.data === undefined || entry.data === null ? '' : dataToText(entry.data);
|
|
265
|
+
if (detail) {
|
|
266
|
+
// 用「⋯」而不是「{}」:花括号会被读成"这是一个空对象 value",而它其实只是
|
|
267
|
+
// "这条还有更多内容(悬停可看)"的记号。⋯ 是"还有更多"的通用记号,不会被误读。
|
|
268
|
+
meta.append(el('span', 'dbg-data-mark', '⋯'));
|
|
269
|
+
const shown = detail.length > 400 ? `${detail.slice(0, 400)}…` : detail;
|
|
270
|
+
row.title = `有结构化数据(悬停查看)\n${shown}`;
|
|
271
|
+
}
|
|
272
|
+
body.append(meta, el('div', `dbg-text dbg-text-${level}`, entry.message));
|
|
273
|
+
row.append(body);
|
|
274
|
+
if (isCollapsed) row.hidden = true;
|
|
275
|
+
return row;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 渲染器:全量重绘 / 增量追加 / 条数 / 自动滚动。
|
|
280
|
+
* @param {{log: object, list: HTMLElement, countEl: HTMLElement, state: {collapsed: boolean}}} ctx
|
|
281
|
+
*/
|
|
282
|
+
function createRenderer({ log, list, countEl, badgeEl, state }) {
|
|
283
|
+
/** 新增后自动滚到底(列表未溢出时无副作用) */
|
|
284
|
+
function scrollToBottom() {
|
|
285
|
+
if (state.collapsed) return;
|
|
286
|
+
list.scrollTop = list.scrollHeight;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 容差(px):离底部这么近就算"用户在看最新" */
|
|
290
|
+
const BOTTOM_TOLERANCE = 4;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 用户当前是否停在底部。
|
|
294
|
+
* **必须在改 DOM 之前测**:改完 scrollHeight 就变了,再测永远为 false/true 失真。
|
|
295
|
+
*
|
|
296
|
+
* 三种情况都要算「在底部」,否则首次渲染就判 false、stick 从此固定为 false,
|
|
297
|
+
* 表现为「刷新后日志永远停在顶部」:
|
|
298
|
+
* ① 还没布局(隐藏/刚创建,clientHeight=0):无法判断 ⇒ 视为应跟随;
|
|
299
|
+
* ② 内容不足以滚动(maxScroll≈0,容器 padding 让 scrollHeight 略大):顶就是底;
|
|
300
|
+
* ③ 正常情形:距底 ≤ 容差。
|
|
301
|
+
* @returns {boolean}
|
|
302
|
+
*/
|
|
303
|
+
function isAtBottom() {
|
|
304
|
+
if (list.clientHeight === 0) return true;
|
|
305
|
+
const maxScroll = list.scrollHeight - list.clientHeight;
|
|
306
|
+
if (maxScroll <= BOTTOM_TOLERANCE) return true;
|
|
307
|
+
return maxScroll - list.scrollTop <= BOTTOM_TOLERANCE;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 「是否跟随最新」的显式状态。
|
|
312
|
+
* 为什么不每次现算 isAtBottom():面板隐藏时 clientHeight=0、内容不足以滚动时顶=底,
|
|
313
|
+
* 这两种情况下现算都只能得到"在底部",但用户**明确向上翻看过**这件事会丢;
|
|
314
|
+
* 用滚动事件把用户意图记下来,隐藏→显示时才能恢复正确的跟随行为。
|
|
315
|
+
*/
|
|
316
|
+
let followLatest = true;
|
|
317
|
+
list.addEventListener('scroll', () => { followLatest = isAtBottom(); }, { passive: true });
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 面板从隐藏变可见(clientHeight 0 → 非 0)时补一次跟随。
|
|
321
|
+
* 为什么需要:隐藏期间追加的日志不会触发滚动(scrollTop 赋值在不可见元素上无效),
|
|
322
|
+
* 展开后就会停在顶部——这也是"刷新/展开后看不到最新"的一个来源。
|
|
323
|
+
*/
|
|
324
|
+
let lastClientHeight = 0;
|
|
325
|
+
function watchListResize() {
|
|
326
|
+
if (typeof ResizeObserver !== 'function') return;
|
|
327
|
+
const ro = new ResizeObserver(() => {
|
|
328
|
+
const h = list.clientHeight;
|
|
329
|
+
if (lastClientHeight === 0 && h > 0 && followLatest) scrollToBottom();
|
|
330
|
+
lastClientHeight = h;
|
|
331
|
+
});
|
|
332
|
+
ro.observe(list);
|
|
333
|
+
}
|
|
334
|
+
watchListResize();
|
|
335
|
+
|
|
336
|
+
function updateCount(n) {
|
|
337
|
+
countEl.textContent = `${n} 条`;
|
|
338
|
+
// 收起态那颗胶囊上的徽标:收起后用户看不到标题栏,条数只能靠它
|
|
339
|
+
if (badgeEl) badgeEl.textContent = String(n);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 多选级别过滤:**只显示被勾选的那几档**(任意组合)。
|
|
344
|
+
* 为什么不是阈值:阈值表达不了"只看警告+错误"(不要信息)这种真实诉求。
|
|
345
|
+
* 条目本身仍在环形缓冲与 /api/diag 里,勾回来即恢复,不丢数据。
|
|
346
|
+
* @param {{level?: string}} record
|
|
347
|
+
*/
|
|
348
|
+
function levelPass(record) {
|
|
349
|
+
const levels = state.levels;
|
|
350
|
+
if (!(levels instanceof Set)) return true;
|
|
351
|
+
const lv = DEBUG_LEVELS.includes(record.level) ? record.level : 'info';
|
|
352
|
+
return levels.has(lv);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 全量重绘(初始化 / 清空后 / 切换过滤 / 样式就绪后)。
|
|
357
|
+
*
|
|
358
|
+
* 滚动语义(标准日志面板):只有"用户本来就在底部"时才跟着滚到最新一条;
|
|
359
|
+
* 正在向上翻阅时**一律不打断**——否则切过滤/点空白/来一条新日志都会把视线拽回底部。
|
|
360
|
+
* @param {{forceBottom?: boolean}} [opts] forceBottom:清空后无内容可看,回底属合理
|
|
361
|
+
*/
|
|
362
|
+
function renderAll(opts = {}) {
|
|
363
|
+
const forceBottom = opts.forceBottom === true;
|
|
364
|
+
const stick = forceBottom || followLatest; // ← 必须在 replaceChildren 之前测
|
|
365
|
+
const records = log.entries().filter(levelPass);
|
|
366
|
+
list.replaceChildren();
|
|
367
|
+
if (!records.length) {
|
|
368
|
+
const allOff = state.levels instanceof Set && state.levels.size === 0;
|
|
369
|
+
list.append(el('div', 'dbg-empty', allOff ? '已关闭全部级别(点上方档位恢复显示)' : '暂无日志'));
|
|
370
|
+
}
|
|
371
|
+
else for (const record of records) list.append(renderEntry(record, state));
|
|
372
|
+
updateCount(records.length);
|
|
373
|
+
if (stick) scrollToBottom();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 批量刷新:高频错误下每帧最多重绘一次。
|
|
378
|
+
* 为什么不在 subscribe 里直接 append:一次网络风暴可能几十条同时到,
|
|
379
|
+
* 逐条 append + 滚动会把主线程钉住(面板本来就不该拖慢页面)。
|
|
380
|
+
*
|
|
381
|
+
* 调度本身交给 infra/schedule.js(与名册共用同一套"同帧合并"内核);
|
|
382
|
+
* 这里只保留面板专属的两件事:① entry === null 的清屏直刷语义 ② 取批后逐条 append 的内容逻辑。
|
|
383
|
+
* fallbackMs: 0 ⇒ 只靠 rAF,**与改动前的内联实现严格一致**(旧版本来就没有兜底定时器)。
|
|
384
|
+
* 给面板加兜底属于行为改进(会改变 flush 触发源),要单独评估,不混进这次等价重构。
|
|
385
|
+
*/
|
|
386
|
+
let pending = [];
|
|
387
|
+
const scheduler = createScheduler(() => {
|
|
388
|
+
const batch = pending;
|
|
389
|
+
pending = [];
|
|
390
|
+
if (!batch.length) return;
|
|
391
|
+
const stick = followLatest; // 先测:append 之后 scrollHeight 变大,就判断不出用户原本在底部
|
|
392
|
+
const hasEmpty = !!list.querySelector('.dbg-empty');
|
|
393
|
+
if (hasEmpty) list.replaceChildren();
|
|
394
|
+
for (const record of batch) if (levelPass(record)) list.append(renderEntry(record, state));
|
|
395
|
+
updateCount(list.childElementCount);
|
|
396
|
+
if (stick) scrollToBottom();
|
|
397
|
+
}, { fallbackMs: 0 });
|
|
398
|
+
|
|
399
|
+
function scheduleRender(entry) {
|
|
400
|
+
// null = 清屏后立即整表重绘:清空待刷批次,**不走调度**(不合并、不等下一帧)
|
|
401
|
+
// 这里刻意不调 scheduler.cancel():旧实现是让已排队的 rAF 空跑一次、因 batch 为空提前返回;
|
|
402
|
+
// 加上 cancel 虽然少一次空跑,但 flush/rAF 回调次数会变 ⇒ 属可观测差异,本轮不做。
|
|
403
|
+
if (entry === null) { pending = []; renderAll({ forceBottom: true }); return; }
|
|
404
|
+
pending.push(entry);
|
|
405
|
+
scheduler.request();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { renderAll, appendEntry: scheduleRender, scrollToBottom, isAtBottom, list };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 让面板显隐跟随日志开关(关闭只是隐藏,实例保留)。
|
|
413
|
+
* @param {{root: HTMLElement, view: object, log: object}} ctx
|
|
414
|
+
* @returns {() => void} 取消订阅
|
|
415
|
+
*/
|
|
416
|
+
function syncVisibility({ root, view, log }) {
|
|
417
|
+
const apply = (enabled) => {
|
|
418
|
+
try {
|
|
419
|
+
root.hidden = !enabled;
|
|
420
|
+
if (enabled) {
|
|
421
|
+
view.renderAll(); // 打开时把期间积累的条目补上,并滚到底
|
|
422
|
+
}
|
|
423
|
+
} catch { /* 显隐失败不影响业务 */ }
|
|
424
|
+
};
|
|
425
|
+
apply(log.getEnabled() !== false);
|
|
426
|
+
if (typeof log.onEnabledChange !== 'function') return () => {};
|
|
427
|
+
return log.onEnabledChange(apply);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 接线:清空、折叠、日志订阅。返回解绑所需的三件套。
|
|
432
|
+
* @param {{shell: object, view: object, log: object, state: {collapsed: boolean}}} ctx
|
|
433
|
+
*/
|
|
434
|
+
function wireControls({ shell, view, log, state, dragRef }) {
|
|
435
|
+
/** 清空:先清数据再重绘,异常也保证界面回到空态 */
|
|
436
|
+
function onClear() {
|
|
437
|
+
try { log.clear(); } finally { view.renderAll(); }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 折叠/展开。
|
|
442
|
+
*
|
|
443
|
+
* 实现口径:只切 `data-collapsed`,动画全部交给 CSS(高度 0fr↔1fr + 透明度 + 位移)。
|
|
444
|
+
* **不能再写 `list.hidden`** —— 那会让列表瞬间从布局里消失,高度过渡就没得可动,
|
|
445
|
+
* 动画会退化成"啪一下不见了"。列表留在文档里、由外层 .dbg-shell 把轨道压到 0,
|
|
446
|
+
* 内容被 overflow:hidden 裁掉,视觉上同样干净。
|
|
447
|
+
* @param {boolean} next 目标状态
|
|
448
|
+
*/
|
|
449
|
+
function setCollapsed(next, opts) {
|
|
450
|
+
state.collapsed = !!next;
|
|
451
|
+
shell.root.dataset.collapsed = String(state.collapsed);
|
|
452
|
+
// S4:把用户这次选择记下来(opts.persist === false 用于启动回放,不写盘)
|
|
453
|
+
if (!opts || opts.persist !== false) {
|
|
454
|
+
try { uiState.setDebugCollapsed(state.collapsed); } catch { /* 存储不可用就只在当前页面存活期内生效 */ }
|
|
455
|
+
}
|
|
456
|
+
// 形态一换盒子尺寸就变(胶囊 120×30 ↔ 面板 322×422):
|
|
457
|
+
// 交给 drag 重新定位 —— 展开时会**重新判断方向**(靠右向左长、靠下向上长),
|
|
458
|
+
// 所以从任何位置展开都放得下,胶囊态的拖动范围也不必预留展开空间。
|
|
459
|
+
try { if (dragRef.api) dragRef.api.applyForm(state.collapsed ? 'collapsed' : 'expanded'); } catch { /* 定位失败不影响展开 */ }
|
|
460
|
+
shell.btnCollapse.title = state.collapsed ? '展开' : '收起';
|
|
461
|
+
shell.btnCollapse.setAttribute('aria-label', state.collapsed ? '展开日志面板' : '收起日志面板');
|
|
462
|
+
shell.pill.setAttribute('aria-expanded', String(!state.collapsed));
|
|
463
|
+
if (!state.collapsed) {
|
|
464
|
+
// 收起期间新增的条目是按"收起态"渲染的(不建 DOM);展开时整表重绘一次补齐
|
|
465
|
+
try { view.renderAll(); } catch { /* 渲染失败不影响展开 */ }
|
|
466
|
+
view.scrollToBottom();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function onCollapse() { setCollapsed(!state.collapsed); }
|
|
471
|
+
/** 胶囊点击 = 展开(收起态下唯一可见的入口) */
|
|
472
|
+
function onPillOpen() { if (state.collapsed) setCollapsed(false); }
|
|
473
|
+
|
|
474
|
+
shell.btnClear.addEventListener('click', onClear);
|
|
475
|
+
shell.btnCollapse.addEventListener('click', onCollapse);
|
|
476
|
+
shell.pill.addEventListener('click', onPillOpen);
|
|
477
|
+
|
|
478
|
+
// 订阅:新条目增量追加;null 表示「已清空」→ 整表重绘
|
|
479
|
+
const unsubscribe = log.subscribe((entry) => {
|
|
480
|
+
try {
|
|
481
|
+
if (entry === null) view.renderAll();
|
|
482
|
+
else view.appendEntry(entry);
|
|
483
|
+
} catch {
|
|
484
|
+
// 面板渲染失败不能影响业务
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return { onClear, onCollapse, onPillOpen, setCollapsed, unsubscribe };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** diag 条目的正文:event + 数据摘要(截断与 log.js 的 300 字规则一致) */
|
|
492
|
+
/** 超长正文截断(面板一行能放下的量级) */
|
|
493
|
+
function clip(text) {
|
|
494
|
+
const s = String(text || '');
|
|
495
|
+
return s.length > 300 ? `${s.slice(0, 290)}…` : s;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** data 字段序列化成一行;不可序列化时给明确标记而不是抛错 */
|
|
499
|
+
function dataToText(data) {
|
|
500
|
+
if (data === null || data === undefined) return '';
|
|
501
|
+
if (typeof data === 'string') return data;
|
|
502
|
+
try {
|
|
503
|
+
const text = JSON.stringify(data);
|
|
504
|
+
// 空对象/空数组与"没有 detail"等价:单独输出一行 `{}` 是纯噪音
|
|
505
|
+
// (服务端 core/log.mjs 的 detailText 用的是同一判据,两端必须一致)。
|
|
506
|
+
// 用序列化结果比较而不是 Object.keys().length:一次覆盖数组/数字/布尔,
|
|
507
|
+
// 也顺手避开 `Object.keys(new Date()).length === 0` 这种"看起来空其实有值"的陷阱。
|
|
508
|
+
return (text === '{}' || text === '[]') ? '' : text;
|
|
509
|
+
} catch { return '[unserializable]'; }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function diagMessage(entry) {
|
|
513
|
+
// 正文优先:应用侧(stageStatus / camera / debugLog.log)都带了 message,直接用它
|
|
514
|
+
if (entry && typeof entry.message === 'string' && entry.message) return clip(entry.message.trim());
|
|
515
|
+
const head = String((entry && (entry.event || entry.src)) || 'event');
|
|
516
|
+
const tail = dataToText(entry && entry.data);
|
|
517
|
+
return clip(tail ? `${head} · ${tail}` : head);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 注册 diag sink:把探针事件转写进调试日志缓冲。
|
|
522
|
+
*
|
|
523
|
+
* 为什么放在面板这一侧:log 实例与面板是一起装配的(panel/shell-controls.js),
|
|
524
|
+
* 面板知道"该往哪个 log 写";diag 只需要提供 addSink 通道,不必知道 UI。
|
|
525
|
+
* 防自激:addExternal 只写缓冲 + 广播,不再回头调 __DIAG__.event,所以不会递归。
|
|
526
|
+
*
|
|
527
|
+
* @param {object} log createDebugLog() 实例
|
|
528
|
+
* @returns {() => void} 取消订阅
|
|
529
|
+
*/
|
|
530
|
+
function registerDiagSink(log) {
|
|
531
|
+
const diagApi = typeof window !== 'undefined' ? window.__DIAG__ : null;
|
|
532
|
+
if (!diagApi || typeof diagApi.addSink !== 'function') return () => {};
|
|
533
|
+
if (typeof log.addExternal !== 'function') return () => {};
|
|
534
|
+
// 同一条目可能既来自"补历史"又来自"订阅推送"(dump 与 sink 是两条路),按 diag 条目 id 去重
|
|
535
|
+
const seen = new Set();
|
|
536
|
+
const forward = (entry) => {
|
|
537
|
+
if (!entry) return;
|
|
538
|
+
const id = entry.id;
|
|
539
|
+
if (id !== undefined) { if (seen.has(id)) return; seen.add(id); }
|
|
540
|
+
// 交互流水(pointerdown / wheel)不进面板:它们由 diag 继续采集(/api/diag 可查),
|
|
541
|
+
// 相机动作另有 __WEB__.cameraLog;面板是"看问题"的地方,不该被每点一下刷一行。
|
|
542
|
+
const tag = String(entry.tag || entry.event || '');
|
|
543
|
+
const lcTag = tag.toLowerCase();
|
|
544
|
+
// 状态行已经通过 STAGE 通道进来过一次;console 监听会把同一条再抓一遍(%c[STAGE:…]),
|
|
545
|
+
// 重复行对排障没用,这里把它滤掉,避免每条状态都显示两遍。
|
|
546
|
+
// 正文要按 diag 条目的真实结构取(sink 层没有 message 字段,是 diagMessage 现算的)
|
|
547
|
+
let msgText = '';
|
|
548
|
+
try { msgText = String(diagMessage(entry) || ''); } catch { msgText = ''; }
|
|
549
|
+
const rawText = (entry.data && entry.data.text) ? String(entry.data.text) : '';
|
|
550
|
+
// 控制台镜像去重:应用自己 console 出去的 [STAGE:] / [CAMERA] 已经通过各自通道进过一次
|
|
551
|
+
const isMirror = (t) => t.indexOf('[STAGE:') !== -1 || t.indexOf('[CAMERA]') !== -1;
|
|
552
|
+
if (lcTag === 'console' && (isMirror(msgText) || isMirror(rawText))) return;
|
|
553
|
+
try {
|
|
554
|
+
log.addExternal({
|
|
555
|
+
ts: Date.parse(entry.t) || Date.now(),
|
|
556
|
+
level: entry.level,
|
|
557
|
+
tag: entry.tag || entry.event || entry.src,
|
|
558
|
+
message: diagMessage(entry),
|
|
559
|
+
src: entry.src,
|
|
560
|
+
event: entry.event,
|
|
561
|
+
data: entry.data,
|
|
562
|
+
});
|
|
563
|
+
} catch { /* 转写失败不影响探针 */ }
|
|
564
|
+
};
|
|
565
|
+
// 先补历史再订阅:面板是在页面启动后才装配的,之前的事件只躺在 diag 缓冲里。
|
|
566
|
+
// 不补这一段,面板显示的是"半截现场",与 GET /api/diag 的条数永远对不上(排查时极易误判为漏采)。
|
|
567
|
+
try { for (const past of (diagApi.dump() || [])) forward(past); } catch { /* 补历史失败不影响后续订阅 */ }
|
|
568
|
+
return diagApi.addSink(forward);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 挂载调试日志面板。
|
|
573
|
+
* @param {{log: {entries: Function, subscribe: Function, clear: Function}, container?: HTMLElement}} params
|
|
574
|
+
* @returns {{destroy: Function, element: HTMLElement}}
|
|
575
|
+
*/
|
|
576
|
+
export function mountDebugPanel({ log, container } = {}) {
|
|
577
|
+
if (!log || typeof log.entries !== 'function' || typeof log.subscribe !== 'function') {
|
|
578
|
+
throw new Error('mountDebugPanel: 需要 createDebugLog() 的实例');
|
|
579
|
+
}
|
|
580
|
+
const doc = (container || document.body).ownerDocument || document;
|
|
581
|
+
const linkEl = ensureStylesheet(doc);
|
|
582
|
+
// S4:收起状态也持久化(位置/四档 S3 已收编,同处 debug 段)。
|
|
583
|
+
// 默认 false(展开)—— 首次访问不能一进来就是收起的,那看起来像面板坏了。
|
|
584
|
+
const state = { collapsed: uiState.getDebugCollapsed(), levels: new Set(loadLevels()) };
|
|
585
|
+
const shell = createShell(doc, container);
|
|
586
|
+
// 防闪烁:面板样式是异步 <link>,样式到位前这块 DOM 会以"无样式裸块"绘制,
|
|
587
|
+
// 刷新时会闪一大片(看起来像样式加载失败)。先 visibility:hidden,样式就绪再显形;
|
|
588
|
+
// link 出错或超时也放开,保证面板不会永久不可见。
|
|
589
|
+
const revealWhenStyled = () => { try { shell.root.style.visibility = ''; } catch { /* 忽略 */ } };
|
|
590
|
+
try { shell.root.style.visibility = 'hidden'; } catch { /* 忽略 */ }
|
|
591
|
+
// 拖拽句柄在 wireControls 之后才创建,而展开逻辑要用它做"重新夹取":
|
|
592
|
+
// 用一个可变容器传递(直接闭包引用会在创建前触发 TDZ)。
|
|
593
|
+
const dragRef = { api: null };
|
|
594
|
+
const view = createRenderer({ log, list: shell.list, countEl: shell.countEl, badgeEl: shell.badge, state });
|
|
595
|
+
const wired = wireControls({ shell, view, log, state, dragRef });
|
|
596
|
+
const unwatchVisibility = syncVisibility({ root: shell.root, view, log });
|
|
597
|
+
|
|
598
|
+
// 样式表异步加载:加载完成前列表没有高度约束,「滚到底」无效,故就绪后重绘一次;
|
|
599
|
+
// 同时也是"防闪烁"的解锁点(样式一到就显形)。
|
|
600
|
+
if (linkEl) {
|
|
601
|
+
linkEl.addEventListener('load', () => {
|
|
602
|
+
revealWhenStyled();
|
|
603
|
+
try { view.renderAll(); } catch { /* 重绘失败不影响业务 */ }
|
|
604
|
+
}, { once: true });
|
|
605
|
+
linkEl.addEventListener('error', revealWhenStyled, { once: true });
|
|
606
|
+
setTimeout(revealWhenStyled, 800); // 兜底:慢网络/缓存异常也不至于一直看不见
|
|
607
|
+
} else {
|
|
608
|
+
revealWhenStyled(); // 样式表本来就在(重复挂载/已加载过)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------- diag → 面板(唯一数据源)
|
|
612
|
+
// 三类事件(应用 / 运行时异常 / 网络)都从 __DIAG__ 的 sink 进来:
|
|
613
|
+
// 注释掉这一段,面板会一条都不再产生 —— 这正是"接通了"的反向证明。
|
|
614
|
+
const sinkOff = registerDiagSink(log);
|
|
615
|
+
|
|
616
|
+
// 级别多选:点一下切一档,任意组合;切换后整表重绘(数据在缓冲里,重绘成本低)
|
|
617
|
+
/** 把 Set 刷到按钮的激活态与 aria-pressed(对屏幕阅读器也是"按下/未按下") */
|
|
618
|
+
function paintLevels() {
|
|
619
|
+
for (const [level] of LEVEL_UI) {
|
|
620
|
+
const btn = shell.levelBtns[level];
|
|
621
|
+
if (!btn) continue;
|
|
622
|
+
const on = state.levels.has(level);
|
|
623
|
+
btn.classList.toggle('is-on', on);
|
|
624
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function onLevelClick(ev) {
|
|
628
|
+
const level = ev.currentTarget && ev.currentTarget.dataset ? ev.currentTarget.dataset.level : '';
|
|
629
|
+
if (!level) return;
|
|
630
|
+
if (state.levels.has(level)) state.levels.delete(level);
|
|
631
|
+
else state.levels.add(level);
|
|
632
|
+
paintLevels();
|
|
633
|
+
saveLevels(state.levels);
|
|
634
|
+
view.renderAll();
|
|
635
|
+
}
|
|
636
|
+
for (const [level] of LEVEL_UI) {
|
|
637
|
+
const btn = shell.levelBtns[level];
|
|
638
|
+
if (btn) btn.addEventListener('click', onLevelClick);
|
|
639
|
+
}
|
|
640
|
+
paintLevels();
|
|
641
|
+
|
|
642
|
+
// 浮层可拖动 + 记住位置(只移动,不做缩放)
|
|
643
|
+
// 两个手柄:展开态拖第 1 行;收起态拖胶囊(此时标题栏高度为 0,抓不到)
|
|
644
|
+
const drag = createPanelDrag({ root: shell.root, head: [shell.head, shell.pill] });
|
|
645
|
+
dragRef.api = drag;
|
|
646
|
+
// S4:先按工厂恢复"收起/展开",再恢复位置 —— 顺序不能反:
|
|
647
|
+
// 收起态与展开态是两套盒子尺寸,先定位再切形态会让像素位置按错误的盒子算(会歪)。
|
|
648
|
+
// persist:false 保证这是"回放",不会把刚读回的值又写一遍。
|
|
649
|
+
try { wired.setCollapsed(state.collapsed, { persist: false }); } catch { /* 回放失败就用 CSS 默认(展开) */ }
|
|
650
|
+
drag.restore();
|
|
651
|
+
// 样式表是**异步注入**的:样式到位前面板还是"无样式裸块",尺寸与 --dbg-w/--dbg-h 都不可用,
|
|
652
|
+
// 此时按尺寸夹取会把存下的位置夹歪(实测:存 top=658,样式前恢复到 532)。
|
|
653
|
+
// 样式就绪后再恢复一次(幂等),并在下一帧补一次兜底。
|
|
654
|
+
if (linkEl) linkEl.addEventListener('load', () => { try { drag.restore(); } catch { /* 忽略 */ } }, { once: true });
|
|
655
|
+
requestAnimationFrame(() => { try { drag.restore(); } catch { /* 忽略 */ } });
|
|
656
|
+
|
|
657
|
+
// 上传日志:数据源统一从 infra/log.js 的 recent() 取(stageStatus 与 diag 事件都在里面)
|
|
658
|
+
const uploader = createUploader({
|
|
659
|
+
button: shell.btnUpload,
|
|
660
|
+
statusEl: shell.uploadStatus,
|
|
661
|
+
readState: () => ({
|
|
662
|
+
version: (typeof window !== 'undefined' && window.__WEB__ && window.__WEB__.version) || '',
|
|
663
|
+
selectedModel: state.selectedModel || null,
|
|
664
|
+
renderer: state.currentRenderer || null,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
const onUpload = () => { uploader.run(); };
|
|
668
|
+
shell.btnUpload.addEventListener('click', onUpload);
|
|
669
|
+
|
|
670
|
+
view.renderAll();
|
|
671
|
+
|
|
672
|
+
// 把上传句柄挂到 __WEB__(供自动化断言三态;不存在就下次挂载再挂)
|
|
673
|
+
try {
|
|
674
|
+
if (typeof window !== 'undefined' && window.__WEB__) window.__WEB__.logUpload = uploader;
|
|
675
|
+
} catch { /* 忽略 */ }
|
|
676
|
+
|
|
677
|
+
// 把拖动句柄挂到 __WEB__(供自动化读节流计数;不存在就下次挂载再挂)
|
|
678
|
+
try {
|
|
679
|
+
if (typeof window !== 'undefined' && window.__WEB__) window.__WEB__.debugPanelDrag = drag;
|
|
680
|
+
} catch { /* 忽略 */ }
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
drag,
|
|
684
|
+
element: shell.root,
|
|
685
|
+
/**
|
|
686
|
+
* 按工厂当前值回放"收起/展开"(幂等,persist:false 不写盘)。
|
|
687
|
+
*
|
|
688
|
+
* 为什么需要它:调试浮层可能在**读回之前**就被挂载(渲染器就绪即建面板),
|
|
689
|
+
* 那一刻工厂里还没有持久值 ⇒ 只能按默认(展开)挂上。等启动读回完成后,
|
|
690
|
+
* 由 applyDebugLog 再调一次本方法把真实状态补上(否则刷新后"收起"记不住)。
|
|
691
|
+
*/
|
|
692
|
+
restoreCollapsed() {
|
|
693
|
+
try { wired.setCollapsed(uiState.getDebugCollapsed(), { persist: false }); return true; } catch { return false; }
|
|
694
|
+
},
|
|
695
|
+
destroy() {
|
|
696
|
+
try { wired.unsubscribe(); } catch { /* 忽略 */ }
|
|
697
|
+
try { unwatchVisibility(); } catch { /* 忽略 */ }
|
|
698
|
+
try { drag.dispose(); } catch { /* 忽略 */ }
|
|
699
|
+
shell.pill.removeEventListener('click', wired.onPillOpen);
|
|
700
|
+
shell.btnUpload.removeEventListener('click', onUpload);
|
|
701
|
+
shell.btnClear.removeEventListener('click', wired.onClear);
|
|
702
|
+
shell.btnCollapse.removeEventListener('click', wired.onCollapse);
|
|
703
|
+
try { sinkOff(); } catch { /* 忽略 */ }
|
|
704
|
+
for (const [level] of LEVEL_UI) {
|
|
705
|
+
const btn = shell.levelBtns[level];
|
|
706
|
+
if (btn) btn.removeEventListener('click', onLevelClick);
|
|
707
|
+
}
|
|
708
|
+
shell.root.remove();
|
|
709
|
+
if (linkEl && linkEl.parentNode) linkEl.remove();
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|