admin update

master
Denis Ranneft 1 month ago
parent 81b22db006
commit 8d2cb97614

@ -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; }
</style>
</head>
<body>
@ -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 `<span class="jv-null">null</span>`;
if (value === undefined) return `<span class="jv-null">undefined</span>`;
const t = typeof value;
if (t === "boolean" || t === "number") return `<span class="jv-lit">${e(String(value))}</span>`;
if (t === "string") return `<span class="jv-str">"${e(value)}"</span>`;
if (Array.isArray(value)) {
if (value.length === 0) return `<span class="jv-empty">[]</span>`;
const inner = value.map((item, i) => {
const cp = `${path}[${i}]`;
return `<div class="jv-row"><span class="jv-idx">${i}:</span> ${jsonTreeHtml(item, cp)}</div>`;
}).join("");
return `<details class="jv-node"${openAttr}><summary class="jv-sum" onclick="jsonViewerToggle(${pathArg}, event)">[${value.length} эл.]</summary><div class="jv-ch">${inner}</div></details>`;
}
if (t === "object") {
const keys = Object.keys(value);
if (keys.length === 0) return `<span class="jv-empty">{}</span>`;
keys.sort();
const inner = keys.map(k => {
const cp = jsonChildPath(path, k);
return `<div class="jv-row"><span class="jv-key">${e(k)}:</span> ${jsonTreeHtml(value[k], cp)}</div>`;
}).join("");
return `<details class="jv-node"${openAttr}><summary class="jv-sum" onclick="jsonViewerToggle(${pathArg}, event)">{${keys.length} полей}</summary><div class="jv-ch">${inner}</div></details>`;
}
return `<span>${e(String(value))}</span>`;
}
function heroSnapshotTreeHtml() {
const snap = state._heroLiveSnapshot;
if (!snap || typeof snap !== "object") {
return `<p class="muted" style="margin-top:8px">Нет данных — подключите live или дождитесь сообщения.</p>`;
}
return `<div class="jv-root">${jsonTreeHtml(snap, "$")}</div>`;
}
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
? `<pre class="live-json-pre">${e(JSON.stringify(h, null, 2))}</pre>`
: `<p class="muted" style="margin-top:8px">Нет JSON — подключите live или дождитесь первого сообщения.</p>`;
const openAttr = state._liveSnapshotOpen ? " open" : "";
return `
<div class="card" style="margin-top:10px">
@ -756,8 +826,8 @@
<button type="button" class="btn" onclick="connectHeroLiveWS()">Подключить</button>
<button type="button" class="btn" onclick="stopHeroLiveWS()">Отключить</button>
<details class="live-details"${openAttr}>
<summary onclick="toggleLiveSnapshotOpen(event)">Снимок героя (JSON)</summary>
${jsonSnap}
<summary onclick="toggleLiveSnapshotOpen(event)">Снимок: hero + heroMove (дерево)</summary>
${heroSnapshotTreeHtml()}
</details>
<p class="muted" style="margin-top:8px"><kbd>/admin-ws/hero/{heroId}</kbd>, авторизация как у API.</p>
</div>
@ -1620,6 +1690,7 @@
window.stopHeroLiveWS = stopHeroLiveWS;
window.toggleLiveSnapshotOpen = toggleLiveSnapshotOpen;
window.toggleHeroQuestWorldOpen = toggleHeroQuestWorldOpen;
window.jsonViewerToggle = jsonViewerToggle;
render();
</script>
</body>

@ -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
}

Loading…
Cancel
Save