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,369 @@
|
|
|
1
|
+
import * as uiState from '../app/ui-state.js';
|
|
2
|
+
|
|
3
|
+
// debug/drag.js —— 调试日志浮层的「可拖动 + 记住位置」
|
|
4
|
+
//
|
|
5
|
+
// 只做移动,不做缩放(缩放手柄不在需求内)。
|
|
6
|
+
//
|
|
7
|
+
// 六条设计要点:
|
|
8
|
+
// 1. 节流:pointermove 只累积目标坐标,真正写样式在 rAF 里做一次
|
|
9
|
+
// (结构与 render/interaction.js 的 createPanScheduler 一致:累积 → 单帧 flush → flushNow)。
|
|
10
|
+
// 2. 跟手:拖动期间写 `transform: translate3d()`(只合成、不触发布局),
|
|
11
|
+
// 松手时才把最终位置落到 left/top。若每帧都改 left/top,浏览器要重排整页
|
|
12
|
+
// (本页有 1920×1080 的舞台画布),手感会明显发滞。
|
|
13
|
+
// 3. 记忆:pointerup 时把最终 left/top 写 localStorage;下次挂载时恢复。
|
|
14
|
+
// 没有记录就保持 CSS 里写死的默认位置(right/bottom),不改默认外观。
|
|
15
|
+
// 4. 边界:按**当前形态**尺寸夹取(收起=胶囊尺寸,展开=面板尺寸),整块留在视口内。
|
|
16
|
+
// 不能拿展开尺寸去夹胶囊 —— 那会让胶囊只能在右上角一小块区域活动。
|
|
17
|
+
// 拖出屏幕找不回来是最烦的坏状态(这个浮层没有 reset 入口)。
|
|
18
|
+
// 5. 多手柄:展开态拖标题栏、收起态拖胶囊(收起后标题栏高度为 0,只剩胶囊可抓)。
|
|
19
|
+
// 6. 点击/拖拽区分:位移 ≤ DRAG_THRESHOLD 视为点击,不开始拖动、不 preventDefault,
|
|
20
|
+
// 否则点一下胶囊想展开会变成"轻微抖动但没反应"。
|
|
21
|
+
// 7. 展开方向自适应:面板永远朝视口中心展开(靠右向左长、靠下向上长)。
|
|
22
|
+
// 写死方向必然有一半位置放不下(胶囊在右下角时向下展开直接出屏)。
|
|
23
|
+
// 存盘只存**锚点**(收起态胶囊盒子的位置),展开位置由锚点+方向算出。
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 位置存储统一走 app/ui-state 工厂(偏好键的 debug.panelPos 段)。
|
|
27
|
+
* 本文件不直接读写存储 —— 不再有独立的键常量与 storageKey 参数。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** 超过这个位移(px)才算拖动;之内视为点击/手抖 */
|
|
31
|
+
const DRAG_THRESHOLD = 4;
|
|
32
|
+
|
|
33
|
+
/** 拖动时不该被当作拖拽起点的元素(标题栏里的按钮/下拉等交互控件) */
|
|
34
|
+
const NO_DRAG_SELECTOR = 'button, select, input, textarea, option, a';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 读保存的位置。
|
|
38
|
+
* @param {string} [key] 存储键
|
|
39
|
+
* @returns {{left:number, top:number}|null} 无记录或数据损坏时返回 null
|
|
40
|
+
*/
|
|
41
|
+
export function readSavedPos() {
|
|
42
|
+
// S3:唯一真相源 = 工厂(旧键由工厂在启动时迁移,这里不再直读 localStorage)
|
|
43
|
+
try { return uiState.getPanelPos(); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 把坐标夹到"面板不出视口"的范围内(标题栏因此始终可见)。
|
|
48
|
+
* @param {{left:number, top:number}} pos 期望位置
|
|
49
|
+
* @param {{w:number, h:number, vw:number, vh:number}} box 面板宽高与视口尺寸
|
|
50
|
+
* @returns {{left:number, top:number}} 夹取后的位置
|
|
51
|
+
*/
|
|
52
|
+
export function clampPos(pos, box) {
|
|
53
|
+
const maxLeft = Math.max(0, box.vw - box.w);
|
|
54
|
+
const maxTop = Math.max(0, box.vh - box.h);
|
|
55
|
+
return {
|
|
56
|
+
left: Math.min(Math.max(0, pos.left), maxLeft),
|
|
57
|
+
top: Math.min(Math.max(0, pos.top), maxTop),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 单帧只写一次样式的调度器。
|
|
63
|
+
* @param {(pos:{left:number,top:number}) => void} write 真正的写入
|
|
64
|
+
* @returns {{queue:Function, flushNow:Function, stats:Function, resetStats:Function}} 调度器
|
|
65
|
+
*/
|
|
66
|
+
function createPosScheduler(write) {
|
|
67
|
+
let pending = null;
|
|
68
|
+
let frameId = 0;
|
|
69
|
+
let writes = 0;
|
|
70
|
+
/** 冲刷本帧累积的目标位置(同一帧内多次 queue 只写一次) */
|
|
71
|
+
function flush() {
|
|
72
|
+
frameId = 0;
|
|
73
|
+
if (!pending) return;
|
|
74
|
+
const pos = pending;
|
|
75
|
+
pending = null;
|
|
76
|
+
writes += 1;
|
|
77
|
+
write(pos);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
queue(pos) { pending = pos; if (!frameId) frameId = requestAnimationFrame(flush); },
|
|
81
|
+
flushNow() { if (frameId) { cancelAnimationFrame(frameId); flush(); } },
|
|
82
|
+
stats: () => ({ writes }),
|
|
83
|
+
resetStats: () => { writes = 0; },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 给面板装上拖动能力(支持多个手柄:标题栏 + 收起态胶囊)。
|
|
89
|
+
* @param {{root: HTMLElement, head: HTMLElement|HTMLElement[]}} opts
|
|
90
|
+
* head:单个元素或元素数组;数组里为空的值会被过滤掉(调用方不必自己判空)
|
|
91
|
+
* @returns {{restore:Function, dispose:Function, clampIntoView:Function, stats:Function, resetStats:Function}} 句柄
|
|
92
|
+
*/
|
|
93
|
+
export function createPanelDrag({ root, head }) {
|
|
94
|
+
const handles = (Array.isArray(head) ? head : [head]).filter(Boolean);
|
|
95
|
+
let moves = 0;
|
|
96
|
+
let dragFrom = null;
|
|
97
|
+
/** 拖动开始时缓存的尺寸/视口:拖动过程中面板尺寸不变,逐帧读 offsetWidth 会强制重排 */
|
|
98
|
+
let dragBox = null;
|
|
99
|
+
/** 拖动起点的 left/top(transform 是相对它算的) */
|
|
100
|
+
let basePos = null;
|
|
101
|
+
|
|
102
|
+
/** 读一个尺寸常量(px);取不到就用兜底值 */
|
|
103
|
+
function cssPx(name, fallback) {
|
|
104
|
+
try {
|
|
105
|
+
const v = parseFloat(getComputedStyle(root).getPropertyValue(name));
|
|
106
|
+
if (Number.isFinite(v)) return v;
|
|
107
|
+
} catch { /* 忽略 */ }
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 边框厚度(常量是内容尺寸,盒子还要加边框;不加会差 2px 冒到视口外) */
|
|
112
|
+
function borderBox() {
|
|
113
|
+
try {
|
|
114
|
+
const cs = getComputedStyle(root);
|
|
115
|
+
return {
|
|
116
|
+
x: (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0),
|
|
117
|
+
y: (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0),
|
|
118
|
+
};
|
|
119
|
+
} catch { return { x: 0, y: 0 }; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 当前形态(以 root 的 data-collapsed 为准) */
|
|
123
|
+
function currentForm() {
|
|
124
|
+
return root.dataset.collapsed === 'true' ? 'collapsed' : 'expanded';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 某个形态的盒子尺寸与视口。
|
|
129
|
+
*
|
|
130
|
+
* **按形态取尺寸**(不是永远按展开尺寸):展开方向自适应后,从任何位置都能找到
|
|
131
|
+
* 放得下的朝向 ⇒ 不需要"提前预留展开空间",拿展开尺寸去夹胶囊只会把可拖范围
|
|
132
|
+
* 压成右上角一小块。
|
|
133
|
+
*
|
|
134
|
+
* **不读 offsetWidth/offsetHeight**:宽度有 220ms 过渡,形态切换瞬间读到的是中间值。
|
|
135
|
+
* 统一从 CSS 常量算,随时可读且与目标尺寸一致。
|
|
136
|
+
* @param {'collapsed'|'expanded'} form
|
|
137
|
+
*/
|
|
138
|
+
function boxFor(form) {
|
|
139
|
+
const b = borderBox();
|
|
140
|
+
if (form === 'collapsed') {
|
|
141
|
+
return {
|
|
142
|
+
w: cssPx('--dbg-pill-w', root.offsetWidth) + b.x,
|
|
143
|
+
h: cssPx('--dbg-pill-hbox', root.offsetHeight) + b.y,
|
|
144
|
+
vw: window.innerWidth, vh: window.innerHeight,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
w: cssPx('--dbg-w', root.offsetWidth) + b.x,
|
|
149
|
+
h: cssPx('--dbg-h', root.offsetHeight) + b.y,
|
|
150
|
+
vw: window.innerWidth, vh: window.innerHeight,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 面板当前的 left/top(未定位过时按实际渲染位置换算) */
|
|
155
|
+
function currentPos() {
|
|
156
|
+
const r = root.getBoundingClientRect();
|
|
157
|
+
return { left: r.left, top: r.top };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 记一次写入次数(节流证据:moves vs writes) */
|
|
161
|
+
function countWrite() {
|
|
162
|
+
root.dataset.dragWrites = String(Number(root.dataset.dragWrites || 0) + 1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 把位置落到 left/top(松手/恢复/窗口变化时用;会触发布局,所以不在拖动中调用) */
|
|
166
|
+
function writeStyle(pos, box) {
|
|
167
|
+
const next = clampPos(pos, box || boxFor(currentForm()));
|
|
168
|
+
root.style.transform = '';
|
|
169
|
+
root.style.left = `${Math.round(next.left)}px`;
|
|
170
|
+
root.style.top = `${Math.round(next.top)}px`;
|
|
171
|
+
// 用 left/top 定位后必须解除 right/bottom,否则两套定位同时生效会打架
|
|
172
|
+
root.style.right = 'auto';
|
|
173
|
+
root.style.bottom = 'auto';
|
|
174
|
+
root.dataset.dragLeft = String(Math.round(next.left));
|
|
175
|
+
root.dataset.dragTop = String(Math.round(next.top));
|
|
176
|
+
countWrite();
|
|
177
|
+
return next;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 拖动中只写 transform(合成层,不重排)——这是"跟手"的关键 */
|
|
181
|
+
function writeTransform(pos, box) {
|
|
182
|
+
const next = clampPos(pos, box || dragBox || boxFor(currentForm()));
|
|
183
|
+
const dx = next.left - (basePos ? basePos.left : next.left);
|
|
184
|
+
const dy = next.top - (basePos ? basePos.top : next.top);
|
|
185
|
+
root.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0)`;
|
|
186
|
+
root.dataset.dragLeft = String(Math.round(next.left));
|
|
187
|
+
root.dataset.dragTop = String(Math.round(next.top));
|
|
188
|
+
countWrite();
|
|
189
|
+
return next;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 当前写入策略:拖动中用 transform,其余时候写 left/top */
|
|
193
|
+
let writer = writeStyle;
|
|
194
|
+
const scheduler = createPosScheduler((pos) => writer(pos));
|
|
195
|
+
|
|
196
|
+
/** 当前展开方向(由最近一次展开决定) */
|
|
197
|
+
function openDir() {
|
|
198
|
+
const dir = String(root.dataset.openDir || 'up-left');
|
|
199
|
+
return { up: dir.includes('up'), left: dir.includes('left') };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 把"当前形态的盒子位置"换算成**锚点**(收起态胶囊盒子的位置)。
|
|
204
|
+
* 展开态要按方向反算:向左长 ⇒ 胶囊贴在面板右边缘;向上长 ⇒ 贴在面板下边缘。
|
|
205
|
+
* @param {{left:number, top:number}} pos
|
|
206
|
+
* @param {'collapsed'|'expanded'} form
|
|
207
|
+
*/
|
|
208
|
+
function toAnchor(pos, form) {
|
|
209
|
+
if (form === 'collapsed') return { left: pos.left, top: pos.top };
|
|
210
|
+
const pBox = boxFor('expanded');
|
|
211
|
+
const cBox = boxFor('collapsed');
|
|
212
|
+
const dir = openDir();
|
|
213
|
+
return {
|
|
214
|
+
left: dir.left ? pos.left + pBox.w - cBox.w : pos.left,
|
|
215
|
+
top: dir.up ? pos.top + pBox.h - cBox.h : pos.top,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 落盘:只存锚点(走工厂的同一条写路径) */
|
|
220
|
+
function persistPos(anchor) {
|
|
221
|
+
try { uiState.setPanelPos({ left: anchor.left, top: anchor.top }); } catch { /* 隐私模式忽略 */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 应用一次位置(当前形态)并可选落盘 */
|
|
225
|
+
function applyPos(pos, { persist = false } = {}) {
|
|
226
|
+
const next = writeStyle(pos);
|
|
227
|
+
if (persist) persistPos(toAnchor(next, currentForm()));
|
|
228
|
+
return next;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 切到某个形态并重新定位(**每次展开都重新判断方向**:用户可能拖过位置再展开)。
|
|
233
|
+
*
|
|
234
|
+
* 方向自适应让面板永远朝视口中心长 ⇒ 从任何位置展开都放得下。
|
|
235
|
+
* @param {'collapsed'|'expanded'} form 目标形态
|
|
236
|
+
* @returns {{left:number, top:number}} 落到的位置
|
|
237
|
+
*/
|
|
238
|
+
function placeForForm(form) {
|
|
239
|
+
const saved = readSavedPos();
|
|
240
|
+
// 锚点来源:有记录就用记录;否则用当前盒子按当前方向反算(首次运行)
|
|
241
|
+
const anchor = saved || toAnchor(currentPos(), currentForm());
|
|
242
|
+
const cBox = boxFor('collapsed');
|
|
243
|
+
const pBox = boxFor('expanded');
|
|
244
|
+
|
|
245
|
+
if (form === 'collapsed') {
|
|
246
|
+
// 收起:胶囊盒子的 left/top 直接就是锚点
|
|
247
|
+
return writeStyle({ left: anchor.left, top: anchor.top }, cBox);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 展开:按锚点中心相对视口中心的象限定方向
|
|
251
|
+
const cx = anchor.left + cBox.w / 2;
|
|
252
|
+
const cy = anchor.top + cBox.h / 2;
|
|
253
|
+
const openLeft = cx >= window.innerWidth / 2;
|
|
254
|
+
const openUp = cy >= window.innerHeight / 2;
|
|
255
|
+
root.dataset.openDir = `${openUp ? 'up' : 'down'}-${openLeft ? 'left' : 'right'}`;
|
|
256
|
+
|
|
257
|
+
const anchorX = openLeft ? anchor.left + cBox.w : anchor.left;
|
|
258
|
+
const anchorY = openUp ? anchor.top + cBox.h : anchor.top;
|
|
259
|
+
const wantLeft = openLeft ? anchorX - pBox.w : anchorX;
|
|
260
|
+
const wantTop = openUp ? anchorY - pBox.h : anchorY;
|
|
261
|
+
// 兜底:视口比面板还小的极端情况贴左上,允许溢出右/下
|
|
262
|
+
const safeLeft = Math.max(0, Math.min(wantLeft, Math.max(0, window.innerWidth - pBox.w)));
|
|
263
|
+
const safeTop = Math.max(0, Math.min(wantTop, Math.max(0, window.innerHeight - pBox.h)));
|
|
264
|
+
return writeStyle({ left: safeLeft, top: safeTop }, pBox);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 视口变化时重新夹一次,避免窗口变小后面板被挤出屏幕 */
|
|
268
|
+
function onResize() {
|
|
269
|
+
if (root.hidden || dragFrom) return;
|
|
270
|
+
applyPos(currentPos());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 按下:只记录起点,**先不进入拖动**。
|
|
275
|
+
* 进入拖动由第一次超过阈值的 pointermove 触发 —— 这样轻点胶囊仍然是一次正常 click。
|
|
276
|
+
* @param {PointerEvent} ev
|
|
277
|
+
*/
|
|
278
|
+
function onPointerDown(ev) {
|
|
279
|
+
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
|
280
|
+
// 手柄本身可能就是按钮(收起态胶囊):它自己要能拖,所以对它跳过"按钮不拖"的过滤
|
|
281
|
+
const selfHandle = ev.currentTarget && ev.currentTarget.dataset
|
|
282
|
+
&& ev.currentTarget.dataset.dragHandle === 'self';
|
|
283
|
+
if (!selfHandle && ev.target && ev.target.closest && ev.target.closest(NO_DRAG_SELECTOR)) return;
|
|
284
|
+
basePos = currentPos();
|
|
285
|
+
dragBox = boxFor(currentForm()); // 只在按下时读一次尺寸
|
|
286
|
+
dragFrom = { px: ev.clientX, py: ev.clientY, id: ev.pointerId, active: false };
|
|
287
|
+
try { ev.currentTarget.setPointerCapture(ev.pointerId); } catch { /* 老浏览器忽略 */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function onPointerMove(ev) {
|
|
291
|
+
if (!dragFrom || ev.pointerId !== dragFrom.id) return;
|
|
292
|
+
moves += 1; // 计数不过阈值也记(节流门禁按真实 move 次数核对)
|
|
293
|
+
if (!dragFrom.active) {
|
|
294
|
+
const dist = Math.abs(ev.clientX - dragFrom.px) + Math.abs(ev.clientY - dragFrom.py);
|
|
295
|
+
if (dist <= DRAG_THRESHOLD) return; // 还在"手抖/点击"范围
|
|
296
|
+
dragFrom.active = true;
|
|
297
|
+
writer = writeTransform; // 拖动期间走 transform
|
|
298
|
+
root.classList.add('is-dragging');
|
|
299
|
+
if (ev.cancelable) ev.preventDefault();
|
|
300
|
+
}
|
|
301
|
+
// 只累积目标坐标:真正的写入交给 rAF(同一帧多次 move 只写一次)
|
|
302
|
+
scheduler.queue({
|
|
303
|
+
left: basePos.left + (ev.clientX - dragFrom.px),
|
|
304
|
+
top: basePos.top + (ev.clientY - dragFrom.py),
|
|
305
|
+
});
|
|
306
|
+
root.dataset.dragMoves = String(moves);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function endDrag() {
|
|
310
|
+
if (!dragFrom) return;
|
|
311
|
+
const wasActive = dragFrom.active;
|
|
312
|
+
dragFrom = null;
|
|
313
|
+
root.classList.remove('is-dragging');
|
|
314
|
+
// 没超过阈值 ⇒ 这是一次点击(例如点胶囊展开):不写位置、不落盘,
|
|
315
|
+
// 且因为全程没 preventDefault,button 的 click 会照常触发。
|
|
316
|
+
if (!wasActive) { writer = writeStyle; return; }
|
|
317
|
+
scheduler.flushNow(); // 最后一帧不能丢
|
|
318
|
+
writer = writeStyle; // 松手后回到 left/top 真值
|
|
319
|
+
// 用 transform 期间的目标位置在 dataset 里;按它提交,视觉上不跳动
|
|
320
|
+
const left = Number(root.dataset.dragLeft);
|
|
321
|
+
const top = Number(root.dataset.dragTop);
|
|
322
|
+
if (Number.isFinite(left) && Number.isFinite(top)) applyPos({ left, top }, { persist: true });
|
|
323
|
+
else applyPos(currentPos(), { persist: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const h of handles) {
|
|
327
|
+
h.addEventListener('pointerdown', onPointerDown);
|
|
328
|
+
h.addEventListener('pointermove', onPointerMove);
|
|
329
|
+
h.addEventListener('pointerup', endDrag);
|
|
330
|
+
h.addEventListener('pointercancel', endDrag);
|
|
331
|
+
}
|
|
332
|
+
window.addEventListener('resize', onResize);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
/** 按存储的锚点恢复位置(当前形态由 root 的 data-collapsed 决定;展开会重判方向) */
|
|
336
|
+
restore() {
|
|
337
|
+
return placeForForm(currentForm());
|
|
338
|
+
},
|
|
339
|
+
/** 形态切换后重新定位(同时重新判断展开方向) */
|
|
340
|
+
applyForm(form) {
|
|
341
|
+
return placeForForm(form);
|
|
342
|
+
},
|
|
343
|
+
/** 窗口缩放等场景下重新夹一次,避免撑出视口 */
|
|
344
|
+
clampIntoView() {
|
|
345
|
+
if (root.hidden || dragFrom) return null;
|
|
346
|
+
return applyPos(currentPos());
|
|
347
|
+
},
|
|
348
|
+
dispose() {
|
|
349
|
+
for (const h of handles) {
|
|
350
|
+
h.removeEventListener('pointerdown', onPointerDown);
|
|
351
|
+
h.removeEventListener('pointermove', onPointerMove);
|
|
352
|
+
h.removeEventListener('pointerup', endDrag);
|
|
353
|
+
h.removeEventListener('pointercancel', endDrag);
|
|
354
|
+
}
|
|
355
|
+
window.removeEventListener('resize', onResize);
|
|
356
|
+
scheduler.flushNow();
|
|
357
|
+
},
|
|
358
|
+
/** 节流证据:move 次数 vs 样式写入次数 */
|
|
359
|
+
stats: () => ({
|
|
360
|
+
moves, ...scheduler.stats(),
|
|
361
|
+
left: root.dataset.dragLeft || null,
|
|
362
|
+
top: root.dataset.dragTop || null,
|
|
363
|
+
openDir: root.dataset.openDir || null,
|
|
364
|
+
// 落盘存的是"锚点"(收起态胶囊盒子的位置);这里把它换算出来供门禁核对
|
|
365
|
+
anchor: toAnchor(currentPos(), currentForm()),
|
|
366
|
+
}),
|
|
367
|
+
resetStats: () => { moves = 0; scheduler.resetStats(); },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// debug-log.js —— 调试日志核心(无 DOM 依赖)
|
|
2
|
+
//
|
|
3
|
+
// 职责:环形缓冲、分级、订阅、开关。面板(debug-panel.js)只消费这里产出的条目。
|
|
4
|
+
//
|
|
5
|
+
// 单一真相源(docs/设计/调试日志面板.md):
|
|
6
|
+
// 条目**只从 diag 进来** —— log() 不再直接写缓冲,而是把事件交给 window.__DIAG__,
|
|
7
|
+
// 由面板注册的 sink 回调 addExternal() 写回缓冲。这样"应用事件 / 运行时异常 / 网络失败"
|
|
8
|
+
// 走的是同一条管道:把 sink 注册注释掉,三类会一起消失(可反向验证"真的接通了")。
|
|
9
|
+
// 没有 diag(独立使用本模块/单测)时退回直接写缓冲,行为与旧版一致。
|
|
10
|
+
//
|
|
11
|
+
// 契约(docs/README.md 第二十二节):
|
|
12
|
+
// const log = createDebugLog({ capacity: 500 });
|
|
13
|
+
// log.log(tag, message, { level = 'info', data } = {});
|
|
14
|
+
// log.entries(); // [{ ts, level, tag, message }] 快照,按时间正序
|
|
15
|
+
// log.subscribe(fn); // 新条目回调,返回取消订阅函数
|
|
16
|
+
// log.clear(); log.setEnabled(bool); log.getEnabled();
|
|
17
|
+
//
|
|
18
|
+
// 硬规则(都在代码里落实):
|
|
19
|
+
// 1. 默认开启;关闭时 log() 立即返回、不产生任何对象;
|
|
20
|
+
// 2. 单条 message ≤ 300 字,超出截断并注明;
|
|
21
|
+
// 3. 环形缓冲默认 500 条,超出丢最旧;
|
|
22
|
+
// 4. 日志绝不抛异常、不阻塞渲染(订阅者异常各自隔离)。
|
|
23
|
+
//
|
|
24
|
+
// 结构:本文件按职责拆成小函数(每函数 ≤50 行):容量规整 / 文本化 / 组装正文 /
|
|
25
|
+
// 环形缓冲 / 订阅总线 / 编排,四者互不重叠。
|
|
26
|
+
|
|
27
|
+
import { sanitizeUrl } from '../infra/diag.js';
|
|
28
|
+
|
|
29
|
+
/** 允许的 tag(大写)。调用方只能从这里取,不自造。 */
|
|
30
|
+
export const DEBUG_TAGS = Object.freeze([
|
|
31
|
+
'RUNTIME', 'LOADER', 'RENDERER', 'CAMERA', 'ANIMATION', 'SKIN', 'FILTER',
|
|
32
|
+
'PMA', 'ROSTER', 'PANEL', 'TRANSPORT', 'PERSIST', 'CONFIG',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** 允许的级别 */
|
|
36
|
+
// debug 是最低档:交互遥测(缩放/拖动)走这一档,默认过滤下不显示,切到低档才看
|
|
37
|
+
export const DEBUG_LEVELS = Object.freeze(['debug', 'info', 'warn', 'error']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 条目来源(面板据此加前缀,区分"应用自己说的"和"探针抓到的"):
|
|
41
|
+
* app 应用事件(加载/切换/设置变更)
|
|
42
|
+
* error 运行时异常(window.onerror / unhandledrejection / console.error)
|
|
43
|
+
* net 网络失败(HTTP ≥400 / 请求失败)
|
|
44
|
+
* resource 资源加载失败
|
|
45
|
+
* 其它 探针内部来源(diag/stage/console/motion…)原样透传
|
|
46
|
+
*/
|
|
47
|
+
export const DEBUG_SOURCES = Object.freeze(['app', 'error', 'net', 'resource', 'diag', 'stage', 'console', 'motion', 'window']);
|
|
48
|
+
|
|
49
|
+
/** 来源归一:未知来源归到 app,避免面板出现奇怪前缀 */
|
|
50
|
+
function normalizeSource(src) {
|
|
51
|
+
const v = String(src || '').toLowerCase();
|
|
52
|
+
return DEBUG_SOURCES.includes(v) ? v : (v ? v : 'app');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 敏感键:data 里这些键的值一律打码(与 infra/diag.js 的 URL 脱敏同一套口径) */
|
|
56
|
+
const SENSITIVE_KEY_RE = /^(token|access_token|refresh_token|id_token|api_?key|apikey|secret|password|passwd|pwd|auth|authorization|session|sessionid|sid|code|sign|signature|sig)$/i;
|
|
57
|
+
|
|
58
|
+
/** 递归深度上限:日志对象可能很深(含 DOM/PIXI 引用),到上限就截断成占位符 */
|
|
59
|
+
const SANITIZE_MAX_DEPTH = 6;
|
|
60
|
+
/** 单个对象/数组最多看多少个成员(避免大对象被整份遍历) */
|
|
61
|
+
const SANITIZE_MAX_KEYS = 40;
|
|
62
|
+
/** 深度截断占位符 */
|
|
63
|
+
const DEPTH_MARK = '[depth-limit]';
|
|
64
|
+
/** 循环引用占位符 */
|
|
65
|
+
const CIRCULAR_MARK = '[circular]';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 字符串脱敏:**凡带 query 的字符串一律按 URL 清洗**(不看有没有 scheme)。
|
|
69
|
+
*
|
|
70
|
+
* 为什么不能只看 ^https?://:日志里的地址大多是相对路径(/api/x?token=…),
|
|
71
|
+
* 只认绝对地址等于把这类凭据全放过。清洗实现复用 infra/diag.js 的 sanitizeUrl
|
|
72
|
+
* (网络采集那条路径已经用它处理过相对/绝对两种形态)。
|
|
73
|
+
* @param {string} value
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function sanitizeString(value) {
|
|
77
|
+
if (!value.includes('?') || !value.includes('=')) return value;
|
|
78
|
+
try {
|
|
79
|
+
return sanitizeUrl(value);
|
|
80
|
+
} catch {
|
|
81
|
+
return value.replace(/([?&][^=&]*(?:token|key|secret|password|code|sign)[^=&]*=)[^&]*/gi, '$1***');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 递归脱敏一个值(对象/数组都进)。
|
|
87
|
+
* @param {*} value
|
|
88
|
+
* @param {number} depth 当前层数(0 = data 本身)
|
|
89
|
+
* @param {WeakSet<object>} seen 已访问对象(循环引用保护)
|
|
90
|
+
* @returns {*}
|
|
91
|
+
*/
|
|
92
|
+
function sanitizeValue(value, depth, seen) {
|
|
93
|
+
if (value === null || value === undefined) return value;
|
|
94
|
+
const type = typeof value;
|
|
95
|
+
if (type === 'string') return sanitizeString(value);
|
|
96
|
+
if (type !== 'object') return value;
|
|
97
|
+
if (depth >= SANITIZE_MAX_DEPTH) return DEPTH_MARK;
|
|
98
|
+
if (seen.has(value)) return CIRCULAR_MARK;
|
|
99
|
+
seen.add(value);
|
|
100
|
+
try {
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
return value.slice(0, SANITIZE_MAX_KEYS).map((item) => sanitizeValue(item, depth + 1, seen));
|
|
103
|
+
}
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const key of Object.keys(value).slice(0, SANITIZE_MAX_KEYS)) {
|
|
106
|
+
out[key] = SENSITIVE_KEY_RE.test(key) ? '***' : sanitizeValue(value[key], depth + 1, seen);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
} catch {
|
|
110
|
+
return null; // 抛异常的取值器(getter/DOM)整体放弃,不影响其它条目
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* data 脱敏(对外唯一入口):敏感键整体打码 + 递归清洗 URL 里的凭据。
|
|
116
|
+
*
|
|
117
|
+
* 为什么要递归:日志里的凭据不只出现在顶层,常见形态是 { req: { url: '…?token=…' } };
|
|
118
|
+
* 只洗一层等于只堵住最显眼的那条路。深度与循环都有上限,脱敏本身不能成为风险。
|
|
119
|
+
* @param {*} data
|
|
120
|
+
* @returns {*}
|
|
121
|
+
*/
|
|
122
|
+
function sanitizeData(data) {
|
|
123
|
+
if (data === null || data === undefined) return null;
|
|
124
|
+
try {
|
|
125
|
+
return sanitizeValue(data, 0, new WeakSet());
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 单条正文上限(字符)与默认容量 */
|
|
132
|
+
const MESSAGE_MAX = 300;
|
|
133
|
+
const CAPACITY_DEFAULT = 500;
|
|
134
|
+
|
|
135
|
+
/** 容量规整:非正整数一律回落到默认值 */
|
|
136
|
+
function normalizeCapacity(value) {
|
|
137
|
+
const n = Number(value);
|
|
138
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : CAPACITY_DEFAULT;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 任意输入压成一行文本:字符串原样,其余尝试 JSON,失败再 String() */
|
|
142
|
+
function toText(raw) {
|
|
143
|
+
if (typeof raw === 'string') return raw;
|
|
144
|
+
if (raw === null || raw === undefined) return '';
|
|
145
|
+
try {
|
|
146
|
+
return JSON.stringify(raw);
|
|
147
|
+
} catch {
|
|
148
|
+
try { return String(raw); } catch { return '[unserializable]'; }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 组装正文:message +(可选)data 以「 · {json}」追加,整体受 300 字上限约束。
|
|
154
|
+
* 为什么把 data 并进 message:条目结构固定为 { ts, level, tag, message },
|
|
155
|
+
* 面板与外部检查代码都按这 4 个键读,多一个键会让两端解析口径不一致。
|
|
156
|
+
*/
|
|
157
|
+
function buildMessage(message) {
|
|
158
|
+
// 只保留"发生了什么"这句话本身:级别是自有属性(level 字段 + 图标),
|
|
159
|
+
// 结构化细节是自有属性(data 字段,面板悬停/查看用),都不再拼进正文。
|
|
160
|
+
const full = toText(message);
|
|
161
|
+
if (full.length <= MESSAGE_MAX) return full;
|
|
162
|
+
const note = `…(截断 ${full.length - MESSAGE_MAX} 字)`;
|
|
163
|
+
return full.slice(0, Math.max(0, MESSAGE_MAX - note.length)) + note;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 环形缓冲:push 到末尾,超出容量从头丢最旧。
|
|
168
|
+
* @param {number} capacity
|
|
169
|
+
*/
|
|
170
|
+
function createBuffer(capacity) {
|
|
171
|
+
const items = [];
|
|
172
|
+
return {
|
|
173
|
+
/** 追加一条;返回是否发生了淘汰 */
|
|
174
|
+
add(entry) {
|
|
175
|
+
items.push(entry);
|
|
176
|
+
if (items.length <= capacity) return false;
|
|
177
|
+
items.splice(0, items.length - capacity);
|
|
178
|
+
return true;
|
|
179
|
+
},
|
|
180
|
+
/** 快照(浅拷贝,外部改动不影响内部) */
|
|
181
|
+
snapshot() { return items.slice(); },
|
|
182
|
+
/** 清空 */
|
|
183
|
+
reset() { items.length = 0; },
|
|
184
|
+
capacity,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 订阅总线:两路互不干扰 —— 条目路(新条目 / null=已清空)与开关路(enabled 变化)。
|
|
190
|
+
* 为什么分两路:条目回调的入参约定是「条目|null」,把开关状态塞进去会污染这个约定。
|
|
191
|
+
*/
|
|
192
|
+
function createNotifier() {
|
|
193
|
+
const subs = new Set();
|
|
194
|
+
const stateSubs = new Set();
|
|
195
|
+
const call = (set, arg) => {
|
|
196
|
+
for (const fn of set) {
|
|
197
|
+
try { fn(arg); } catch { /* 单个订阅者出错不影响其它订阅者 */ }
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
subscribe(fn) {
|
|
202
|
+
if (typeof fn !== 'function') return () => {};
|
|
203
|
+
subs.add(fn);
|
|
204
|
+
return () => { subs.delete(fn); };
|
|
205
|
+
},
|
|
206
|
+
onStateChange(fn) {
|
|
207
|
+
if (typeof fn !== 'function') return () => {};
|
|
208
|
+
stateSubs.add(fn);
|
|
209
|
+
return () => { stateSubs.delete(fn); };
|
|
210
|
+
},
|
|
211
|
+
emit(entry) { call(subs, entry); },
|
|
212
|
+
emitState(enabled) { call(stateSubs, enabled); },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 创建日志实例。
|
|
218
|
+
* @param {{capacity?: number}} [options]
|
|
219
|
+
*/
|
|
220
|
+
export function createDebugLog(options = {}) {
|
|
221
|
+
const buffer = createBuffer(normalizeCapacity(options.capacity));
|
|
222
|
+
const bus = createNotifier();
|
|
223
|
+
// 默认开启;显式传 { enabled: false } 可关闭
|
|
224
|
+
let enabled = options.enabled === undefined ? true : !!options.enabled;
|
|
225
|
+
|
|
226
|
+
/** 组装一条内部条目(唯一构造点,保证字段齐全) */
|
|
227
|
+
function makeEntry(fields) {
|
|
228
|
+
return {
|
|
229
|
+
ts: Number(fields.ts) || Date.now(),
|
|
230
|
+
level: DEBUG_LEVELS.includes(fields.level) ? fields.level : 'info',
|
|
231
|
+
tag: String(fields.tag || '').toUpperCase(),
|
|
232
|
+
message: String(fields.message || ''),
|
|
233
|
+
src: normalizeSource(fields.src),
|
|
234
|
+
event: String(fields.event || fields.tag || ''),
|
|
235
|
+
data: sanitizeData(fields.data),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 写缓冲并广播(**唯一入口**,不转发 —— 转发是 log() 的事,避免自激) */
|
|
240
|
+
function addExternal(fields) {
|
|
241
|
+
try {
|
|
242
|
+
const entry = makeEntry(fields || {});
|
|
243
|
+
buffer.add(entry);
|
|
244
|
+
bus.emit(entry);
|
|
245
|
+
return entry;
|
|
246
|
+
} catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 记一条日志;关闭时立即返回 undefined。
|
|
253
|
+
*
|
|
254
|
+
* 有 window.__DIAG__ 时**只交给探针**(由面板 sink 回调 addExternal 写回缓冲),
|
|
255
|
+
* 这样三类事件共享同一条管道;没有探针(独立使用/单测)时直接写缓冲。
|
|
256
|
+
*/
|
|
257
|
+
function log(tag, message, opts = {}) {
|
|
258
|
+
if (!enabled) return undefined;
|
|
259
|
+
try {
|
|
260
|
+
const fields = {
|
|
261
|
+
ts: Date.now(),
|
|
262
|
+
level: opts.level,
|
|
263
|
+
tag,
|
|
264
|
+
message: buildMessage(message, opts.data),
|
|
265
|
+
src: opts.src,
|
|
266
|
+
event: opts.event || tag,
|
|
267
|
+
// 脱敏必须发生在这里(转发之前):diag 会把它写进缓冲并 POST 给服务端,
|
|
268
|
+
// 只在 addExternal 里脱敏的话,GET /api/diag 与落盘仍是明文。
|
|
269
|
+
data: opts.data === undefined ? null : sanitizeData(opts.data),
|
|
270
|
+
};
|
|
271
|
+
const diag = typeof window !== 'undefined' ? window.__DIAG__ : null;
|
|
272
|
+
if (diag && typeof diag.event === 'function') {
|
|
273
|
+
// message 必须一起送:只送 event+data 的话,正文(如"相机 fit: bbox=…")会在面板里丢掉,
|
|
274
|
+
// 只剩一个标签名,等于把日志变哑。
|
|
275
|
+
diag.event(fields.event, fields.data, {
|
|
276
|
+
level: fields.level, src: fields.src || 'app', tag: fields.tag, message: fields.message,
|
|
277
|
+
});
|
|
278
|
+
return fields; // 缓冲由 sink 写回;这里只回报"已上报"
|
|
279
|
+
}
|
|
280
|
+
return addExternal(fields);
|
|
281
|
+
} catch {
|
|
282
|
+
return undefined; // 日志绝不能影响业务
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
log,
|
|
288
|
+
/** 外部写入(sink 专用):不转发,避免"转发→写缓冲→再转发"的自激 */
|
|
289
|
+
addExternal,
|
|
290
|
+
entries: () => buffer.snapshot(),
|
|
291
|
+
/**
|
|
292
|
+
* 稳定导出结构(供 API/自动化断言):{ t, level, src, event, data }。
|
|
293
|
+
* 与面板同一份缓冲,所以"API 条数 == 面板条数"是结构保证,不靠对账。
|
|
294
|
+
*/
|
|
295
|
+
dump: () => buffer.snapshot().map((e) => ({
|
|
296
|
+
t: new Date(e.ts).toISOString(),
|
|
297
|
+
level: e.level,
|
|
298
|
+
src: e.src,
|
|
299
|
+
event: e.event,
|
|
300
|
+
data: e.data,
|
|
301
|
+
})),
|
|
302
|
+
subscribe: (fn) => bus.subscribe(fn),
|
|
303
|
+
clear() {
|
|
304
|
+
buffer.reset();
|
|
305
|
+
bus.emit(null);
|
|
306
|
+
},
|
|
307
|
+
setEnabled(value) {
|
|
308
|
+
const next = !!value;
|
|
309
|
+
if (next !== enabled) {
|
|
310
|
+
enabled = next;
|
|
311
|
+
bus.emitState(enabled);
|
|
312
|
+
}
|
|
313
|
+
return enabled;
|
|
314
|
+
},
|
|
315
|
+
/** 订阅开关变化(入参为新的 enabled 值),返回取消订阅函数 */
|
|
316
|
+
onEnabledChange: (fn) => bus.onStateChange(fn),
|
|
317
|
+
getEnabled: () => enabled,
|
|
318
|
+
capacity: buffer.capacity,
|
|
319
|
+
};
|
|
320
|
+
}
|