diff --git a/admin-web/index.html b/admin-web/index.html index 6f4834c..20c39f3 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -45,8 +45,19 @@ .live-ws-bar-actions .btn { margin-top: 0; margin-right: 0; } details.live-details > summary { cursor: pointer; color: #cfe3ff; font-weight: 600; list-style-position: outside; } details.live-details > summary::-webkit-details-marker { color: #9eb0d6; } - .live-json-pre { max-height: 320px; overflow: auto; font-size: 11px; margin: 8px 0 0; padding: 10px; background: #0f1522; border: 1px solid #2f3b5a; border-radius: 6px; white-space: pre-wrap; word-break: break-word; } .quest-world-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid #2a3551; } + .jv-root { margin-top: 8px; font-family: ui-monospace, Consolas, monospace; font-size: 12px; line-height: 1.45; } + .jv-node { margin: 2px 0 2px 0; border-left: 1px solid #2f3b5a; padding-left: 8px; } + .jv-node > .jv-sum { cursor: pointer; color: #9eb0d6; user-select: none; } + .jv-node > .jv-sum:hover { color: #cfe3ff; } + .jv-ch { margin-top: 4px; padding-left: 4px; } + .jv-row { margin: 2px 0; } + .jv-key { color: #7eb8ff; margin-right: 4px; } + .jv-idx { color: #b8a0ff; margin-right: 4px; } + .jv-str { color: #7de29f; } + .jv-lit { color: #ffb86c; } + .jv-null { color: #9eb0d6; font-style: italic; } + .jv-empty { color: #6a7a9e; } @@ -96,6 +107,8 @@ _heroLiveWsLastAt: null, _liveSnapshotOpen: false, _heroQuestWorldOpen: false, + _heroLiveSnapshot: null, + _jsonViewerOpenPaths: null, }; state._confirmAction = null; @@ -612,6 +625,8 @@ state._heroLiveWsStatus = "disconnected"; state._heroLiveWsError = ""; state._heroLiveWsLastAt = null; + state._heroLiveSnapshot = null; + state._jsonViewerOpenPaths = null; render(); } function startHeroMovementPoll(durationSec = 55) { @@ -649,6 +664,8 @@ state._heroLiveWsStatus = "connecting"; state._heroLiveWsError = ""; state._heroLiveWsLastAt = null; + state._heroLiveSnapshot = null; + state._jsonViewerOpenPaths = Object.create(null); render(); ws.onopen = () => { state._heroLiveWsStatus = "connected"; @@ -672,8 +689,18 @@ render(); return; } - if (data && data.id && state.selectedHeroId === data.id) { - state.selectedHero = data; + let hero = null; + let snap = null; + if (data && data.hero && typeof data.hero === "object" && data.hero.id != null) { + hero = data.hero; + snap = data; + } else if (data && data.id != null) { + hero = data; + snap = { hero: data, heroMove: null }; + } + if (hero && state.selectedHeroId === hero.id) { + state.selectedHero = hero; + state._heroLiveSnapshot = snap; state._heroLiveWsLastAt = Date.now(); render(); } @@ -684,6 +711,53 @@ } }; } + function jsonChildPath(parent, segment) { + if (parent === "$") return `$.${segment}`; + return `${parent}.${segment}`; + } + function jsonViewerToggle(path, ev) { + if (ev) ev.preventDefault(); + if (!state._jsonViewerOpenPaths) state._jsonViewerOpenPaths = Object.create(null); + if (state._jsonViewerOpenPaths[path]) delete state._jsonViewerOpenPaths[path]; + else state._jsonViewerOpenPaths[path] = true; + render(); + } + function jsonTreeHtml(value, path) { + const pathArg = JSON.stringify(path); + const open = state._jsonViewerOpenPaths && state._jsonViewerOpenPaths[path]; + const openAttr = open ? " open" : ""; + if (value === null) return `null`; + if (value === undefined) return `undefined`; + const t = typeof value; + if (t === "boolean" || t === "number") return `${e(String(value))}`; + if (t === "string") return `"${e(value)}"`; + if (Array.isArray(value)) { + if (value.length === 0) return `[]`; + const inner = value.map((item, i) => { + const cp = `${path}[${i}]`; + return `
${i}: ${jsonTreeHtml(item, cp)}
`; + }).join(""); + return `
[${value.length} эл.]
${inner}
`; + } + if (t === "object") { + const keys = Object.keys(value); + if (keys.length === 0) return `{}`; + keys.sort(); + const inner = keys.map(k => { + const cp = jsonChildPath(path, k); + return `
${e(k)}: ${jsonTreeHtml(value[k], cp)}
`; + }).join(""); + return `
{${keys.length} полей}
${inner}
`; + } + return `${e(String(value))}`; + } + function heroSnapshotTreeHtml() { + const snap = state._heroLiveSnapshot; + if (!snap || typeof snap !== "object") { + return `

Нет данных — подключите live или дождитесь сообщения.

`; + } + return `
${jsonTreeHtml(snap, "$")}
`; + } function formatRemainingMs(ms) { if (ms == null || !Number.isFinite(ms)) return "—"; if (ms <= 0) return "истекло"; @@ -740,10 +814,6 @@ const last = state._heroLiveWsLastAt ? new Date(state._heroLiveWsLastAt).toLocaleTimeString() : "—"; const heroId = state._heroLiveWsHeroId; const err = state._heroLiveWsError; - const h = state.selectedHero; - const jsonSnap = h && h.id - ? `
${e(JSON.stringify(h, null, 2))}
` - : `

Нет JSON — подключите live или дождитесь первого сообщения.

`; const openAttr = state._liveSnapshotOpen ? " open" : ""; return `
@@ -756,8 +826,8 @@
- Снимок героя (JSON) - ${jsonSnap} + Снимок: hero + heroMove (дерево) + ${heroSnapshotTreeHtml()}

/admin-ws/hero/{heroId}, авторизация как у API.

@@ -1620,6 +1690,7 @@ window.stopHeroLiveWS = stopHeroLiveWS; window.toggleLiveSnapshotOpen = toggleLiveSnapshotOpen; window.toggleHeroQuestWorldOpen = toggleHeroQuestWorldOpen; + window.jsonViewerToggle = jsonViewerToggle; render(); diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 346b672..05a026b 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -89,6 +89,12 @@ type adminHeroDetailResponse struct { AdminLiveMovement *adminLiveMovementJSON `json:"adminLiveMovement,omitempty"` } +// adminWSSnapshot is the admin live WebSocket payload: hero detail + last hero_move (client WS) sample. +type adminWSSnapshot struct { + Hero adminHeroDetailResponse `json:"hero"` + HeroMove *model.HeroMovePayload `json:"heroMove"` +} + func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON { if hm == nil { return nil @@ -158,6 +164,27 @@ func (h *AdminHandler) buildAdminHeroDetail(hero *model.Hero) (adminHeroDetailRe return out, nil } +func (h *AdminHandler) buildAdminWSSnapshot(ctx context.Context, heroID int64) (adminWSSnapshot, error) { + hero, err := h.store.GetByID(ctx, heroID) + if err != nil { + return adminWSSnapshot{}, err + } + if hero == nil { + return adminWSSnapshot{}, fmt.Errorf("hero not found") + } + detail, err := h.buildAdminHeroDetail(hero) + if err != nil { + return adminWSSnapshot{}, err + } + now := time.Now() + var move *model.HeroMovePayload + if hm := h.engine.GetMovements(heroID); hm != nil { + p := hm.MovePayload(now) + move = &p + } + return adminWSSnapshot{Hero: detail, HeroMove: move}, nil +} + // ListHeroes returns a paginated list of all heroes. // GET /admin/heroes?limit=20&offset=0 func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) { @@ -1899,14 +1926,7 @@ func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Reques }() sendSnapshot := func() error { - hero, err := h.store.GetByID(r.Context(), heroID) - if err != nil { - return err - } - if hero == nil { - return fmt.Errorf("hero not found") - } - snap, err := h.buildAdminHeroDetail(hero) + snap, err := h.buildAdminWSSnapshot(r.Context(), heroID) if err != nil { return err }