Files
cmr-mini/backend/internal/httpapi/handlers/dev_handler.go

4180 lines
202 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type DevHandler struct {
devService *service.DevService
}
func NewDevHandler(devService *service.DevService) *DevHandler {
return &DevHandler{devService: devService}
}
func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) {
result, err := h.devService.BootstrapDemo(r.Context())
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(devWorkbenchHTML))
}
const devWorkbenchHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CMR Backend Workbench</title>
<style>
:root {
--bg: #0d1418;
--panel: #132129;
--panel-alt: #182b34;
--text: #e9f1f5;
--muted: #8ea3ad;
--line: #29424d;
--accent: #4fd1a5;
--accent-2: #ffd166;
--danger: #ff6b6b;
--mono: "Consolas", "SFMono-Regular", monospace;
--sans: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(circle at top right, rgba(79, 209, 165, 0.12), transparent 24%),
radial-gradient(circle at bottom left, rgba(255, 209, 102, 0.10), transparent 28%),
var(--bg);
color: var(--text);
font-family: var(--sans);
}
.shell {
max-width: 1400px;
margin: 0 auto;
padding: 28px 24px 40px;
}
.hero {
display: grid;
gap: 8px;
margin-bottom: 22px;
}
.eyebrow {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 34px;
line-height: 1.1;
}
.hero p {
margin: 0;
max-width: 920px;
color: var(--muted);
line-height: 1.6;
}
.layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.sidebar {
position: sticky;
top: 18px;
display: grid;
gap: 16px;
}
.workspace {
display: grid;
gap: 0;
min-width: 0;
}
.side-card {
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
display: grid;
gap: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
}
.side-card h2 {
margin: 0;
font-size: 16px;
}
.side-card p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.mode-list,
.side-links {
display: grid;
gap: 8px;
}
.mode-btn,
.side-link {
display: inline-flex;
align-items: center;
justify-content: flex-start;
min-height: 40px;
padding: 0 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.04);
color: var(--text);
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.mode-btn.active {
background: rgba(79, 209, 165, 0.16);
border-color: rgba(79, 209, 165, 0.55);
color: var(--accent);
}
.guide-list {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.category-head {
display: grid;
gap: 6px;
margin: 28px 0 14px;
}
.category-kicker {
color: var(--accent);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.category-head h2 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
.category-head p {
margin: 0;
color: var(--muted);
line-height: 1.6;
font-size: 13px;
max-width: 960px;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.stack {
display: grid;
gap: 16px;
}
.panel {
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
display: grid;
gap: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
}
.panel h2 {
margin: 0;
font-size: 18px;
}
.panel p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.row {
display: grid;
gap: 8px;
}
.row.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-alt);
color: var(--text);
padding: 10px 12px;
font: inherit;
}
textarea {
min-height: 90px;
resize: vertical;
font-family: var(--mono);
font-size: 12px;
}
button {
border: 0;
border-radius: 12px;
padding: 10px 14px;
background: var(--accent);
color: #062419;
font-weight: 700;
cursor: pointer;
}
button.secondary {
background: var(--accent-2);
color: #312200;
}
button.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.kv {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.kv code {
display: block;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255,255,255,0.04);
color: var(--text);
font-family: var(--mono);
word-break: break-all;
}
.log {
min-height: 220px;
max-height: 520px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
background: #0a1013;
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
}
.subpanel {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.history {
display: grid;
gap: 8px;
max-height: 280px;
overflow: auto;
}
.history-item {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.05);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
}
.history-item strong {
color: var(--accent);
}
.muted-note {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.api-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.api-toolbar input {
max-width: 360px;
}
.api-catalog {
display: grid;
gap: 12px;
}
.api-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 4px 0 2px;
}
.api-chip {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.04);
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.api-item {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
}
.api-item.hidden {
display: none;
}
.mode-hidden {
display: none !important;
}
.api-head {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.api-method {
padding: 4px 8px;
border-radius: 999px;
background: rgba(79, 209, 165, 0.14);
color: var(--accent);
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
}
.api-path {
font-family: var(--mono);
font-size: 13px;
color: var(--text);
word-break: break-all;
}
.api-desc {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.api-meta {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.api-meta strong {
color: var(--text);
font-weight: 600;
}
.status {
color: var(--accent);
font-weight: 700;
}
.status.error {
color: var(--danger);
}
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.sidebar { position: static; }
.row.two { grid-template-columns: 1fr; }
.shell { padding: 20px 16px 32px; }
}
</style>
</head>
<body>
<div class="shell">
<div class="hero">
<div class="eyebrow">Developer Workbench</div>
<h1>CMR Backend API Flow Panel</h1>
<p>把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。</p>
</div>
<div class="layout">
<aside class="sidebar">
<section class="side-card">
<h2>工作模式</h2>
<p>先选你现在要做的事,主区只显示这一类内容。</p>
<div class="mode-list">
<button class="mode-btn" data-mode-btn="frontend" type="button">前台联调</button>
<button class="mode-btn" data-mode-btn="config" type="button">配置发布</button>
<button class="mode-btn" data-mode-btn="admin" type="button">后台运营</button>
<button class="mode-btn" data-mode-btn="reference" type="button">接口参考</button>
<button class="mode-btn" data-mode-btn="all" type="button">全部显示</button>
</div>
</section>
<section class="side-card">
<h2>区域跳转</h2>
<div class="side-links">
<a class="side-link" href="#nav-main" data-nav-target="nav-main" data-nav-mode="frontend">联调主区</a>
<a class="side-link" href="#nav-fast" data-nav-target="nav-fast" data-nav-mode="frontend">快捷操作</a>
<a class="side-link" href="#nav-admin" data-nav-target="nav-admin" data-nav-mode="admin">后台运营</a>
<a class="side-link" href="#nav-tools" data-nav-target="nav-tools">辅助工具</a>
<a class="side-link" href="#nav-api" data-nav-target="nav-api" data-nav-mode="reference">API 目录 <span id="nav-api-count">(0)</span></a>
</div>
</section>
<section class="side-card">
<h2>建议顺序</h2>
<ol class="guide-list">
<li>前台联调:先跑 demo、登录、入口、launch、session、result。</li>
<li>配置发布:只看 source、build、publish、rollback。</li>
<li>后台运营只在要管理地图、KML、资源包时进入。</li>
</ol>
</section>
</aside>
<main class="workspace">
<div class="category-head" id="nav-main" data-modes="frontend config">
<div class="category-kicker">Main Flow</div>
<h2>联调主区</h2>
<p>前台联调和配置发布最常用的入口都在这里。先跑通用户主链,再处理配置与发布。</p>
</div>
<div class="grid">
<section class="panel" data-modes="frontend config admin">
<h2>准备 Demo 数据</h2>
<p>初始化 demo tenant / channel / event / card。</p>
<div class="actions">
<button id="btn-bootstrap">Bootstrap Demo</button>
<button class="secondary" id="btn-use-variant-manual-demo">Use Manual Variant Demo</button>
</div>
<div class="kv">
<div>默认入口 <code id="bootstrap-entry">tenant_demo / mini-demo / evt_demo_001</code></div>
<div>多赛道入口 <code id="bootstrap-variant-entry">tenant_demo / mini-demo / evt_demo_variant_manual_001</code></div>
</div>
</section>
<section class="panel" data-modes="frontend config">
<h2>本地配置导入与发布</h2>
<p>从本地 event 目录导入 source config生成 preview build并可在发布时直接挂接 runtime binding。</p>
<div class="row two">
<label>Local Config File
<input id="local-config-file" value="classic-sequential.json">
</label>
<label>Event ID
<input id="config-event-id" value="evt_demo_001">
</label>
</div>
<div class="row two">
<label>Source ID
<input id="config-source-id" placeholder="import 后自动填充">
</label>
<label>Build ID
<input id="config-build-id" placeholder="preview 后自动填充">
</label>
</div>
<div class="row two">
<label>Runtime Binding ID
<input id="config-runtime-binding-id" placeholder="可选,发布时直接挂接">
</label>
<div class="muted-note">第四刀发布闭环publish 时可直接带 runtimeBindingId旧发布路径继续可用。</div>
</div>
<div class="row two">
<label>Presentation ID
<input id="config-presentation-id" placeholder="可选,发布时挂接 presentation">
</label>
<label>Content Bundle ID
<input id="config-content-bundle-id" placeholder="可选,发布时挂接内容包">
</label>
</div>
<div class="actions">
<button id="btn-config-files">List Local Files</button>
<button id="btn-config-import">Import Local</button>
<button class="secondary" id="btn-config-preview">Build Preview</button>
<button class="secondary" id="btn-config-publish">Publish Build</button>
<button class="ghost" id="btn-config-source">Get Source</button>
<button class="ghost" id="btn-config-build">Get Build</button>
</div>
</section>
<section class="panel" data-modes="common">
<h2>当前上下文</h2>
<p>当前调试上下文,所有按钮共享这一组状态。</p>
<div class="kv">
<div>Access Token <code id="state-access">-</code></div>
<div>Refresh Token <code id="state-refresh">-</code></div>
<div>Source ID <code id="state-source">-</code></div>
<div>Build ID <code id="state-build">-</code></div>
<div>Release ID <code id="state-release">-</code></div>
<div>Session ID <code id="state-session">-</code></div>
<div>Session Token <code id="state-session-token">-</code></div>
</div>
<div class="actions">
<button class="ghost" id="btn-clear-state">Clear State</button>
</div>
</section>
<section class="panel" data-modes="frontend">
<h2>短信登录 / 绑定</h2>
<div class="row two">
<label>Client Type
<select id="sms-client-type">
<option value="app">app</option>
<option value="wechat">wechat</option>
</select>
</label>
<label>Scene
<select id="sms-scene">
<option value="login">login</option>
<option value="bind_mobile">bind_mobile</option>
</select>
</label>
</div>
<div class="row two">
<label>Mobile
<input id="sms-mobile" value="13800138000">
</label>
<label>Device Key
<input id="sms-device" value="workbench-device-001">
</label>
</div>
<div class="row two">
<label>Country Code
<input id="sms-country" value="86">
</label>
<label>Code
<input id="sms-code" placeholder="send 后自动填充 devCode">
</label>
</div>
<div class="actions">
<button id="btn-send-sms">Send SMS</button>
<button class="secondary" id="btn-login-sms">Login SMS</button>
<button class="ghost" id="btn-bind-mobile">Bind Mobile</button>
</div>
</section>
<section class="panel" data-modes="frontend">
<h2>微信小程序登录</h2>
<p>开发环境可直接使用 dev-xxx code。</p>
<div class="row two">
<label>Code
<input id="wechat-code" value="dev-workbench-user">
</label>
<label>Device Key
<input id="wechat-device" value="wechat-device-001">
</label>
</div>
<div class="actions">
<button id="btn-login-wechat">Login WeChat Mini</button>
</div>
</section>
<section class="panel" data-modes="frontend">
<h2>入口与首页</h2>
<div class="row two">
<label>Channel Code
<input id="entry-channel-code" value="mini-demo">
</label>
<label>Channel Type
<input id="entry-channel-type" value="wechat_mini">
</label>
</div>
<div class="actions">
<button id="btn-resolve-entry">Resolve Entry</button>
<button id="btn-home">Home</button>
<button class="secondary" id="btn-entry-home">My Entry Home</button>
</div>
</section>
<section class="panel" data-modes="frontend">
<h2>活动与启动</h2>
<div class="row two">
<label>Event ID
<input id="event-id" value="evt_demo_001">
</label>
<label>Release ID
<input id="event-release-id" value="rel_demo_001">
</label>
</div>
<div class="row two">
<label>Launch Device
<input id="event-device" value="workbench-device-001">
</label>
<label>Variant ID
<input id="event-variant-id" placeholder="可选manual 时可传">
</label>
</div>
<div class="actions">
<button id="btn-event-detail">Event Detail</button>
<button id="btn-event-play">Event Play</button>
<button class="secondary" id="btn-launch">Launch</button>
</div>
</section>
<section class="panel" data-modes="frontend">
<h2>局内状态</h2>
<div class="row two">
<label>Session ID
<input id="session-id" placeholder="launch 后自动填充">
</label>
<label>Session Token
<input id="session-token" placeholder="launch 后自动填充">
</label>
</div>
<div class="row two">
<label>Finish Status
<select id="finish-status">
<option value="finished">finished</option>
<option value="failed">failed</option>
<option value="cancelled">cancelled</option>
</select>
</label>
<div></div>
</div>
<div class="row two">
<label>Duration Sec
<input id="finish-duration" type="number" value="960">
</label>
<label>Score
<input id="finish-score" type="number" value="88">
</label>
</div>
<div class="row two">
<label>Completed Controls
<input id="finish-controls-done" type="number" value="7">
</label>
<label>Total Controls
<input id="finish-controls-total" type="number" value="8">
</label>
</div>
<div class="row two">
<label>Distance Meters
<input id="finish-distance" type="number" step="0.01" value="5230">
</label>
<label>Average Speed KM/H
<input id="finish-speed" type="number" step="0.001" value="6.45">
</label>
</div>
<div class="row two">
<label>Max Heart Rate BPM
<input id="finish-heart-rate" type="number" value="168">
</label>
<div></div>
</div>
<div class="actions">
<button id="btn-session-detail">Session Detail</button>
<button id="btn-session-start">Start Session</button>
<button class="secondary" id="btn-session-finish">Finish Session</button>
<button class="ghost" id="btn-my-sessions">My Sessions</button>
</div>
</section>
<section class="panel" data-modes="frontend">
<h2>结果查询</h2>
<div class="actions">
<button id="btn-session-result">Session Result</button>
<button id="btn-my-results">My Results</button>
</div>
</section>
<section class="panel" data-modes="frontend">
<h2>当前用户</h2>
<div class="actions">
<button id="btn-me">/me</button>
<button id="btn-profile">/me/profile</button>
</div>
</section>
</div>
<div class="category-head" id="nav-fast" data-modes="frontend config admin">
<div class="category-kicker">Fast Path</div>
<h2>快捷操作</h2>
<p>当你只是想验证“能不能跑通”,优先使用这一组。</p>
</div>
<div class="grid" style="margin-top:16px;" data-modes="frontend config admin">
<section class="panel" data-modes="frontend config admin">
<h2>一键流程</h2>
<p>把常用接口串成一键工作流,减少重复点击。</p>
<div class="actions">
<button id="btn-flow-home">Bootstrap + WeChat + Entry Home</button>
<button class="secondary" id="btn-flow-launch">Login + Launch + Start</button>
<button class="ghost" id="btn-flow-finish">Finish Current Session</button>
<button class="ghost" id="btn-flow-result">Finish + Result</button>
<button class="secondary" id="btn-flow-admin-default-publish">一键默认绑定发布</button>
<button class="secondary" id="btn-flow-admin-runtime-publish">一键补齐 Runtime 并发布</button>
</div>
<div class="muted-note">这些流程会复用当前表单里的手机号、设备、event、channel 等输入。“一键默认绑定发布” 会自动执行Get Event -> Import Presentation -> Import Bundle -> Save Event Defaults -> Build Source -> Publish Build -> Get Release。“一键补齐 Runtime 并发布” 会在缺少默认 runtime 时自动创建 Runtime Binding再继续发布链。</div>
<div class="subpanel">
<div class="muted-note">预期结果</div>
<div class="kv">
<div>Release ID <code id="flow-admin-release-result">-</code></div>
<div>Presentation <code id="flow-admin-presentation-result">-</code></div>
<div>Content Bundle <code id="flow-admin-content-bundle-result">-</code></div>
<div>Runtime Binding <code id="flow-admin-runtime-result">-</code></div>
<div>判定 <code id="flow-admin-verdict">待执行</code></div>
</div>
</div>
</section>
<section class="panel" data-modes="common">
<h2>请求导出</h2>
<p>最后一次请求会生成一条可复制的 curl后面做问题复现会方便很多。</p>
<div class="actions">
<button id="btn-copy-curl">Copy Last Curl</button>
<button class="ghost" id="btn-clear-history">Clear History</button>
</div>
<div class="subpanel">
<div class="muted-note">Last Curl</div>
<div id="curl" class="log" style="min-height:120px; max-height:200px;"></div>
</div>
</section>
</div>
<div class="category-head" id="nav-admin" data-modes="config admin">
<div class="category-kicker">Advanced</div>
<h2>后台运营与发布</h2>
<p>这一组给资源对象、Event 组装、Build / Publish / Rollback 使用。默认隐藏,只有需要管理配置和资源时再打开。</p>
</div>
<div class="grid" style="margin-top:16px;" data-modes="admin">
<section class="panel" data-modes="admin">
<h2>第一阶段生产骨架联调台</h2>
<p>这里只做总控确认的最小范围:地点、地图资产、瓦片版本、赛道输入源、赛道集合、赛道方案、运行绑定。</p>
<div class="subpanel">
<div class="muted-note">A. 地点与地图</div>
<div class="row two">
<label>Place Code
<input id="prod-place-code" value="place-demo-001">
</label>
<label>Place Name
<input id="prod-place-name" value="Demo Park">
</label>
</div>
<div class="row two">
<label>Place ID
<input id="prod-place-id" placeholder="list/create/detail 后填入">
</label>
<label>Place Status
<select id="prod-place-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="disabled">disabled</option>
</select>
</label>
</div>
<div class="row two">
<label>Place Region
<input id="prod-place-region" value="Shanghai">
</label>
<label>Place Cover URL
<input id="prod-place-cover-url" value="">
</label>
</div>
<div class="actions">
<button id="btn-prod-places-list">List Places</button>
<button id="btn-prod-place-create">Create Place</button>
<button class="ghost" id="btn-prod-place-detail">Get Place</button>
</div>
<div class="row two">
<label>Map Asset Code
<input id="prod-map-asset-code" value="mapasset-demo-001">
</label>
<label>Map Asset Name
<input id="prod-map-asset-name" value="Demo Asset Map">
</label>
</div>
<div class="row two">
<label>Map Asset ID
<input id="prod-map-asset-id" placeholder="create/detail 后填入">
</label>
<label>Legacy Map ID
<input id="prod-map-asset-legacy-map-id" placeholder="可选,复用 /admin/maps 的 id">
</label>
</div>
<div class="row two">
<label>Map Asset Type
<input id="prod-map-asset-type" value="standard">
</label>
<label>Map Asset Status
<select id="prod-map-asset-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="disabled">disabled</option>
</select>
</label>
</div>
<div class="actions">
<button id="btn-prod-map-asset-create">Create Map Asset</button>
<button class="ghost" id="btn-prod-map-asset-detail">Get Map Asset</button>
</div>
<div class="row two">
<label>Tile Release ID
<input id="prod-tile-release-id" placeholder="create/detail 后填入">
</label>
<label>Legacy Tile Version ID
<input id="prod-tile-legacy-version-id" placeholder="可选,复用 /admin/maps 版本 id">
</label>
</div>
<div class="row two">
<label>Tile Version Code
<input id="prod-tile-version-code" value="v2026-04-03">
</label>
<label>Tile Status
<select id="prod-tile-status">
<option value="published">published</option>
<option value="active">active</option>
<option value="draft">draft</option>
</select>
</label>
</div>
<div class="row two">
<label>Tile Base URL
<input id="prod-tile-base-url" value="https://example.com/tiles/demo/">
</label>
<label>Tile Meta URL
<input id="prod-tile-meta-url" value="https://example.com/tiles/demo/meta.json">
</label>
</div>
<div class="actions">
<button id="btn-prod-tile-create">Create Tile Release</button>
</div>
</div>
<div class="subpanel">
<div class="muted-note">B. 赛道与 KML</div>
<div class="row two">
<label>Course Source ID
<input id="prod-course-source-id" placeholder="list/create 后填入">
</label>
<label>Legacy Playfield ID
<input id="prod-course-source-legacy-playfield-id" placeholder="可选,复用 /admin/playfields 的 id">
</label>
</div>
<div class="row two">
<label>Legacy Playfield Version ID
<input id="prod-course-source-legacy-version-id" placeholder="可选,复用 /admin/playfields 版本 id">
</label>
<label>Source Type
<input id="prod-course-source-type" value="kml">
</label>
</div>
<div class="row two">
<label>Source File URL
<input id="prod-course-source-file-url" value="https://example.com/course/demo.kml">
</label>
<label>Import Status
<select id="prod-course-source-status">
<option value="imported">imported</option>
<option value="parsed">parsed</option>
<option value="draft">draft</option>
</select>
</label>
</div>
<div class="actions">
<button id="btn-prod-course-sources-list">List Sources</button>
<button id="btn-prod-course-source-create">Create Source</button>
<button class="ghost" id="btn-prod-course-source-detail">Get Source</button>
</div>
<div class="row two">
<label>Course Set Code
<input id="prod-course-set-code" value="cset-demo-001">
</label>
<label>Course Set Name
<input id="prod-course-set-name" value="Demo Course Set">
</label>
</div>
<div class="row two">
<label>Course Set ID
<input id="prod-course-set-id" placeholder="create/detail 后填入">
</label>
<label>Course Mode
<input id="prod-course-mode" value="classic-sequential">
</label>
</div>
<div class="row two">
<label>Course Set Status
<select id="prod-course-set-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="disabled">disabled</option>
</select>
</label>
<label>Course Variant ID
<input id="prod-course-variant-id" placeholder="create/detail 后填入">
</label>
</div>
<div class="actions">
<button id="btn-prod-course-set-create">Create Course Set</button>
<button class="ghost" id="btn-prod-course-set-detail">Get Course Set</button>
</div>
<div class="row two">
<label>Variant Name
<input id="prod-course-variant-name" value="Demo Variant A">
</label>
<label>Variant Route Code
<input id="prod-course-variant-route-code" value="route-demo-a">
</label>
</div>
<div class="row two">
<label>Variant Status
<select id="prod-course-variant-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="disabled">disabled</option>
</select>
</label>
<label>Variant Control Count
<input id="prod-course-variant-control-count" type="number" value="8">
</label>
</div>
<div class="actions">
<button id="btn-prod-course-variant-create">Create Variant</button>
</div>
</div>
<div class="subpanel">
<div class="muted-note">C. 运行绑定</div>
<div class="row two">
<label>Runtime Binding ID
<input id="prod-runtime-binding-id" placeholder="list/create 后填入">
</label>
<label>Runtime Event ID
<input id="prod-runtime-event-id" value="evt_demo_001">
</label>
</div>
<div class="row two">
<label>Runtime Binding Status
<select id="prod-runtime-binding-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="disabled">disabled</option>
</select>
</label>
<label>Runtime Notes
<input id="prod-runtime-notes" value="workbench runtime binding">
</label>
</div>
<div class="actions">
<button id="btn-prod-runtime-bindings-list">List Runtime Bindings</button>
<button id="btn-prod-runtime-binding-create">Create Runtime Binding</button>
<button class="ghost" id="btn-prod-runtime-binding-detail">Get Runtime Binding</button>
</div>
</div>
</section>
<section class="panel" data-modes="admin">
<h2>资源对象管理</h2>
<p>管理地图、赛场和资源包对象,先建对象,再建版本,后面 Event source 直接引用这些对象。</p>
<div class="row two">
<label>Map Code
<input id="admin-map-code" value="map-demo-001">
</label>
<label>Map Name
<input id="admin-map-name" value="Demo Park Map">
</label>
</div>
<div class="row two">
<label>Map ID
<input id="admin-map-id" placeholder="create/list 后填入">
</label>
<label>Map Version ID
<input id="admin-map-version-id" placeholder="create version 后填入">
</label>
</div>
<div class="row two">
<label>Map Version Code
<input id="admin-map-version-code" value="v2026-04-02">
</label>
<label>Map Status
<select id="admin-map-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="inactive">inactive</option>
</select>
</label>
</div>
<div class="row two">
<label>Mapmeta URL
<input id="admin-mapmeta-url" value="https://example.com/maps/demo/mapmeta.json">
</label>
<label>Tiles Root URL
<input id="admin-tiles-root-url" value="https://example.com/maps/demo/tiles/">
</label>
</div>
<div class="actions">
<button id="btn-admin-maps-list">List Maps</button>
<button id="btn-admin-map-create">Create Map</button>
<button class="secondary" id="btn-admin-map-version">Create Map Version</button>
<button class="ghost" id="btn-admin-map-detail">Get Map</button>
</div>
<div class="row two">
<label>Playfield Code
<input id="admin-playfield-code" value="pf-demo-001">
</label>
<label>Playfield Name
<input id="admin-playfield-name" value="Demo Course">
</label>
</div>
<div class="row two">
<label>Playfield ID
<input id="admin-playfield-id" placeholder="create/list 后填入">
</label>
<label>Playfield Version ID
<input id="admin-playfield-version-id" placeholder="create version 后填入">
</label>
</div>
<div class="row two">
<label>Playfield Kind
<select id="admin-playfield-kind">
<option value="course">course</option>
<option value="score">score</option>
<option value="custom">custom</option>
</select>
</label>
<label>Playfield Status
<select id="admin-playfield-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="inactive">inactive</option>
</select>
</label>
</div>
<div class="row two">
<label>Playfield Version Code
<input id="admin-playfield-version-code" value="v2026-04-02">
</label>
<label>Playfield Source Type
<input id="admin-playfield-source-type" value="kml">
</label>
</div>
<div class="row two">
<label>Playfield Source URL
<input id="admin-playfield-source-url" value="https://example.com/playfields/demo/course.kml">
</label>
<label>Control Count
<input id="admin-playfield-control-count" type="number" value="8">
</label>
</div>
<div class="actions">
<button id="btn-admin-playfields-list">List Playfields</button>
<button id="btn-admin-playfield-create">Create Playfield</button>
<button class="secondary" id="btn-admin-playfield-version">Create Playfield Version</button>
<button class="ghost" id="btn-admin-playfield-detail">Get Playfield</button>
</div>
<div class="row two">
<label>Pack Code
<input id="admin-pack-code" value="pack-demo-001">
</label>
<label>Pack Name
<input id="admin-pack-name" value="Demo Resource Pack">
</label>
</div>
<div class="row two">
<label>Pack ID
<input id="admin-pack-id" placeholder="create/list 后填入">
</label>
<label>Pack Version ID
<input id="admin-pack-version-id" placeholder="create version 后填入">
</label>
</div>
<div class="row two">
<label>Pack Version Code
<input id="admin-pack-version-code" value="v2026-04-02">
</label>
<label>Pack Status
<select id="admin-pack-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="inactive">inactive</option>
</select>
</label>
</div>
<div class="row two">
<label>Content Entry URL
<input id="admin-pack-content-url" value="https://example.com/packs/demo/content.html">
</label>
<label>Audio Root URL
<input id="admin-pack-audio-url" value="https://example.com/packs/demo/audio/">
</label>
</div>
<div class="row two">
<label>Theme Profile Code
<input id="admin-pack-theme-code" value="theme-demo">
</label>
<label>Published Asset Root
<input id="admin-published-asset-root" value="">
</label>
</div>
<div class="actions">
<button id="btn-admin-packs-list">List Packs</button>
<button id="btn-admin-pack-create">Create Pack</button>
<button class="secondary" id="btn-admin-pack-version">Create Pack Version</button>
<button class="ghost" id="btn-admin-pack-detail">Get Pack</button>
</div>
</section>
<section class="panel" data-modes="config admin">
<h2>Event Source 组装</h2>
<p>创建 Event 并把 map version、playfield version、resource pack version 组装成 source config。</p>
<div class="row two">
<label>Tenant Code
<input id="admin-tenant-code" value="tenant_demo">
</label>
<label>Event Status
<select id="admin-event-status">
<option value="active">active</option>
<option value="draft">draft</option>
<option value="inactive">inactive</option>
</select>
</label>
</div>
<div class="row two">
<label>Event ID
<input id="admin-event-ref-id" value="evt_demo_001">
</label>
<label>Event Slug
<input id="admin-event-slug" value="demo-city-run">
</label>
</div>
<div class="row">
<label>Event Display Name
<input id="admin-event-name" value="Demo City Run">
</label>
</div>
<div class="row">
<label>Event Summary
<textarea id="admin-event-summary" placeholder="后台第一版 Event 摘要">后台第一版 Event用于资源对象组装和发布链路验证。</textarea>
</label>
</div>
<div class="row two">
<label>Game Mode Code
<input id="admin-game-mode-code" value="classic-sequential">
</label>
<label>Route Code
<input id="admin-route-code" value="route-demo-001">
</label>
</div>
<div class="row">
<label>Source Notes
<input id="admin-source-notes" value="workbench assembled source">
</label>
</div>
<div class="row">
<label>Overrides JSON
<textarea id="admin-overrides-json" placeholder='例如:{"game":{"presentation":{"showCompass":true}}}'>{}</textarea>
</label>
</div>
<div class="actions">
<button id="btn-admin-events-list">List Events</button>
<button id="btn-admin-event-create">Create Event</button>
<button class="secondary" id="btn-admin-event-update">Update Event</button>
<button class="ghost" id="btn-admin-event-detail">Get Event</button>
<button class="ghost" id="btn-admin-event-source">Assemble Source</button>
</div>
<div class="subpanel">
<div class="muted-note">Event Presentation</div>
<div class="row two">
<label>Presentation ID
<input id="admin-presentation-id" placeholder="list/create/detail 后填入">
</label>
<label>Presentation Code
<input id="admin-presentation-code" value="presentation-demo-001">
</label>
</div>
<div class="row two">
<label>Presentation Name
<input id="admin-presentation-name" value="Demo Event Presentation">
</label>
<label>Presentation Type
<select id="admin-presentation-type">
<option value="card">card</option>
<option value="detail">detail</option>
<option value="h5">h5</option>
<option value="result">result</option>
<option value="generic">generic</option>
</select>
</label>
</div>
<div class="row">
<label>Presentation Schema JSON
<textarea id="admin-presentation-schema-json" placeholder='例如:{"card":{"title":"Demo City Run"}}'>{"card":{"title":"Demo City Run"},"detail":{"template":"event-detail-default"}}</textarea>
</label>
</div>
<div class="actions">
<button id="btn-admin-presentations-list">List Presentations</button>
<button id="btn-admin-presentation-create">Create Presentation</button>
<button class="secondary" id="btn-admin-presentation-import">Import Presentation</button>
<button class="ghost" id="btn-admin-presentation-detail">Get Presentation</button>
</div>
<div class="row two">
<label>Import Title
<input id="admin-presentation-import-title" value="Demo Imported Presentation">
</label>
<label>Template Key
<input id="admin-presentation-import-template-key" value="event.detail.standard">
</label>
</div>
<div class="row two">
<label>Source Type
<input id="admin-presentation-import-source-type" value="schema">
</label>
<label>Version
<input id="admin-presentation-import-version" value="v2026-04-03">
</label>
</div>
<div class="row">
<label>Schema URL
<input id="admin-presentation-import-schema-url" value="https://example.com/presentation/schema/event-detail-v1.json">
</label>
</div>
</div>
<div class="subpanel">
<div class="muted-note">Content Bundle</div>
<div class="row two">
<label>Content Bundle ID
<input id="admin-content-bundle-id" placeholder="list/create/detail 后填入">
</label>
<label>Content Bundle Code
<input id="admin-content-bundle-code" value="bundle-demo-001">
</label>
</div>
<div class="row two">
<label>Content Bundle Name
<input id="admin-content-bundle-name" value="Demo Event Bundle">
</label>
<label>Content Entry URL
<input id="admin-content-entry-url" value="https://example.com/content/demo/index.html">
</label>
</div>
<div class="row two">
<label>Content Asset Root URL
<input id="admin-content-asset-root-url" value="https://example.com/content/demo/assets/">
</label>
<label>Content Metadata JSON
<textarea id="admin-content-metadata-json" placeholder='例如:{"resultTemplate":"default"}'>{"resultTemplate":"default","locale":"zh-CN"}</textarea>
</label>
</div>
<div class="actions">
<button id="btn-admin-content-bundles-list">List Bundles</button>
<button id="btn-admin-content-bundle-create">Create Bundle</button>
<button class="secondary" id="btn-admin-content-bundle-import">Import Bundle</button>
<button class="ghost" id="btn-admin-content-bundle-detail">Get Bundle</button>
</div>
<div class="row two">
<label>Import Title
<input id="admin-content-import-title" value="Demo Imported Bundle">
</label>
<label>Bundle Type
<input id="admin-content-import-bundle-type" value="result_media">
</label>
</div>
<div class="row two">
<label>Source Type
<input id="admin-content-import-source-type" value="manifest">
</label>
<label>Version
<input id="admin-content-import-version" value="v2026-04-03">
</label>
</div>
<div class="row two">
<label>Manifest URL
<input id="admin-content-import-manifest-url" value="https://example.com/content/demo/manifest.json">
</label>
<label>Asset Manifest JSON
<textarea id="admin-content-import-asset-manifest-json" placeholder='例如:{"manifestUrl":"https://example.com/content/demo/manifest.json"}'>{"manifestUrl":"https://example.com/content/demo/manifest.json","assets":["cover.png","result.json"]}</textarea>
</label>
</div>
</div>
</section>
</div>
<div class="category-head" id="nav-tools" data-modes="common">
<div class="category-kicker">Tools</div>
<h2>辅助工具</h2>
<p>保存场景、查看日志、复制 curl、回看请求历史都放在这里。</p>
</div>
<div class="grid" style="margin-top:16px;">
<section class="panel" data-modes="config admin">
<h2>Build / Publish / Rollback</h2>
<p>围绕当前 Event 查询 source/build/release 流水线,并执行 build、publish、rollback。</p>
<div class="row two">
<label>Source ID
<input id="admin-pipeline-source-id" placeholder="source 组装或导入后自动填充">
</label>
<label>Build ID
<input id="admin-pipeline-build-id" placeholder="build 后自动填充">
</label>
</div>
<div class="row two">
<label>Release ID
<input id="admin-pipeline-release-id" value="rel_demo_001">
</label>
<label>Rollback Release ID
<input id="admin-rollback-release-id" placeholder="输入要切回的 releaseId">
</label>
</div>
<div class="row two">
<label>Runtime Binding ID
<input id="admin-release-runtime-binding-id" placeholder="输入或复用已创建的 runtimeBindingId">
</label>
<div class="muted-note">第四刀:发布时可直接带 runtime binding旧的“先发布再绑定”路径继续保留。</div>
</div>
<div class="row two">
<label>Presentation ID
<input id="admin-release-presentation-id" placeholder="可选,发布时绑定 presentation">
</label>
<label>Content Bundle ID
<input id="admin-release-content-bundle-id" placeholder="可选,发布时绑定内容包">
</label>
</div>
<div class="actions">
<button class="ghost" id="btn-admin-event-defaults">Save Event Defaults</button>
</div>
<div class="muted-note">不填发布参数时,后端会先继承当前 Event 的默认 activepresentation / content bundle / runtime。</div>
<div class="actions">
<button id="btn-admin-pipeline">Get Pipeline</button>
<button id="btn-admin-build-source">Build Source</button>
<button class="secondary" id="btn-admin-build-detail">Get Build</button>
<button class="secondary" id="btn-admin-build-publish">Publish Build</button>
<button class="secondary" id="btn-admin-release-detail">Get Release</button>
<button class="secondary" id="btn-admin-bind-runtime">Bind Runtime</button>
<button class="ghost" id="btn-admin-rollback">Rollback Release</button>
</div>
</section>
<section class="panel" data-modes="common">
<h2>场景模板</h2>
<p>保存当前表单状态为可复用场景,也支持导入导出 JSON适合后续切换不同俱乐部、入口和 event。</p>
<div class="row two">
<label>Scenario Name
<input id="scenario-name" placeholder="例如俱乐部A-小程序-Launch流">
</label>
<label>Saved / Preset
<select id="scenario-select"></select>
</label>
</div>
<div class="actions">
<button id="btn-scenario-save">Save Current</button>
<button class="secondary" id="btn-scenario-load">Load Selected</button>
<button class="ghost" id="btn-scenario-delete">Delete Selected</button>
</div>
<div class="subpanel">
<div class="muted-note">Scenario JSON</div>
<textarea id="scenario-json" placeholder="导出后可复制,导入时贴回这里"></textarea>
<div class="actions">
<button id="btn-scenario-export">Export Selected</button>
<button class="secondary" id="btn-scenario-import">Import JSON</button>
</div>
</div>
</section>
<section class="panel" data-modes="common">
<h2>响应日志</h2>
<p>最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。</p>
<div id="status" class="status">ready</div>
<div id="log" class="log"></div>
</section>
</div>
<div class="grid" style="margin-top:16px;">
<section class="panel" data-modes="common">
<h2>请求历史</h2>
<p>最近 12 次请求会保留在浏览器本地,刷新页面不会丢。</p>
<div id="history" class="history"></div>
</section>
</div>
<div class="category-head" id="nav-api" data-modes="reference">
<div class="category-kicker">Reference</div>
<h2>API 目录 <span id="api-total-count">(0)</span></h2>
<p>需要查路径、参数、鉴权方式时再展开这一块,不影响主链调试。</p>
</div>
<div class="grid" style="margin-top:16px;" data-modes="reference">
<section class="panel" data-modes="reference">
<h2>API 目录</h2>
<p>把当前已实现接口按分组放进 workbench直接看中文说明、鉴权要求和关键参数不用来回翻文档。</p>
<div class="api-toolbar">
<input id="api-filter" placeholder="搜索路径、用途、参数,例如 launch / wechat / result">
<div class="muted-note" id="api-filter-meta">共 0 个接口,支持按关键词筛选。</div>
</div>
<div id="api-summary" class="api-summary"></div>
<div id="api-catalog" class="api-catalog">
<div class="api-item" data-api="healthz 健康检查 服务状态">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/healthz</span></div>
<div class="api-desc">健康检查接口,用来确认服务是否存活。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="auth sms send 验证码 登录 绑定 手机">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/sms/send</span></div>
<div class="api-desc">发送短信验证码,支持登录和绑定手机号两种场景。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>关键参数:</strong><code>countryCode</code>、<code>mobile</code>、<code>clientType</code>、<code>deviceKey</code>、<code>scene</code></div>
</div>
</div>
<div class="api-item" data-api="auth login sms app 手机号 验证码 登录">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/sms</span></div>
<div class="api-desc">APP 主登录入口,使用手机号验证码登录并返回 access/refresh token。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth login wechat mini 微信 小程序 code openid 登录">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/wechat-mini</span></div>
<div class="api-desc">微信小程序登录入口。开发环境支持 <code>dev-</code> 前缀 code 直接模拟登录。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>关键参数:</strong><code>code</code>、<code>clientType=wechat</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth bind mobile 绑定 手机号 合并 账号">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/bind/mobile</span></div>
<div class="api-desc">已登录用户绑定手机号,必要时把微信轻账号合并到手机号主账号。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth refresh token 刷新 登录态">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/refresh</span></div>
<div class="api-desc">使用 refresh token 刷新 access token。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需 Bearer token</div>
<div><strong>关键参数:</strong><code>refreshToken</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth logout 登出 撤销 refresh token">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/logout</span></div>
<div class="api-desc">登出并撤销 refresh token。</div>
<div class="api-meta"><div><strong>鉴权:</strong>可带 Bearer token</div></div>
</div>
<div class="api-item" data-api="entry resolve tenant channel 入口 解析">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/entry/resolve</span></div>
<div class="api-desc">解析当前入口属于哪个 tenant / channel是多俱乐部、多公众号接入的入口层基础接口。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>查询参数:</strong><code>channelCode</code>、<code>channelType</code>、<code>platformAppId</code>、<code>tenantCode</code></div>
</div>
</div>
<div class="api-item" data-api="home 首页 卡片 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/home</span></div>
<div class="api-desc">返回入口首页卡片数据。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="cards 卡片 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/cards</span></div>
<div class="api-desc">只返回卡片列表,适合调试卡片数据本身。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="me entry home 首页 聚合 ongoing recent">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/entry-home</span></div>
<div class="api-desc">首页聚合接口返回用户、tenant、channel、cards、进行中 session 和最近一局。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="event detail 活动 详情 release resolvedRelease">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}</span></div>
<div class="api-desc">活动详情接口,会带当前发布的 release 和 resolvedRelease。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="event play 活动 准备页 聚合 canLaunch continue review variant assignmentMode courseVariants">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/play</span></div>
<div class="api-desc">活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果;第一阶段也会返回多赛道 assignmentMode 和 courseVariants。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="event launch 启动 一局 release manifest sessionToken variantId assignmentMode">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/events/{eventPublicID}/launch</span></div>
<div class="api-desc">基于当前 event 的已发布 release 创建一局 session并返回 config URL、releaseId、sessionToken多赛道第一阶段支持可选 variantId并返回最终绑定的 launch.variant。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>releaseId</code>、<code>variantId</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="config sources event source 配置 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/config-sources</span></div>
<div class="api-desc">查看某个 event 下已经导入过的 source config 列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="config source detail 源配置 明细">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-sources/{sourceID}</span></div>
<div class="api-desc">查看单条 source config 明细。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="config build detail 预览 build 明细 manifest assets">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-builds/{buildID}</span></div>
<div class="api-desc">查看单次 build 的 manifest 和 asset index。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="session detail 一局 详情 resolvedRelease">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}</span></div>
<div class="api-desc">查询一局详情,带 session 状态、event 和 resolvedRelease。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="session start running 开始 一局 sessionToken">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/start</span></div>
<div class="api-desc">把 session 从 <code>launched</code> 推进到 <code>running</code>。</div>
<div class="api-meta">
<div><strong>鉴权:</strong><code>sessionToken</code></div>
<div><strong>关键参数:</strong><code>sessionToken</code></div>
</div>
</div>
<div class="api-item" data-api="session finish 结束 成绩 摘要 result summary sessionToken">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/finish</span></div>
<div class="api-desc">结束一局并沉淀结果摘要,是结果页数据的来源。</div>
<div class="api-meta">
<div><strong>鉴权:</strong><code>sessionToken</code></div>
<div><strong>关键参数:</strong><code>sessionToken</code>、<code>status</code>、<code>summary.*</code></div>
</div>
</div>
<div class="api-item" data-api="me sessions 我的 最近 局 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/sessions</span></div>
<div class="api-desc">查询用户最近 session 列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="session result 单局 结果 页 成绩">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}/result</span></div>
<div class="api-desc">单局结果页接口,返回 session 和 result。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="me results 我的 成绩 结果 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/results</span></div>
<div class="api-desc">查询用户最近结果列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="me 当前用户 信息">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me</span></div>
<div class="api-desc">返回当前用户基础信息。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="me profile 我的页 聚合 绑定 最近记录">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/profile</span></div>
<div class="api-desc">“我的页”聚合接口,返回绑定概览、绑定项列表和最近记录摘要。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="dev bootstrap demo 初始化 示例 数据">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/bootstrap-demo</span></div>
<div class="api-desc">开发态自举 demo 数据,会准备 tenant、channel、event、release、card、source、build。</div>
<div class="api-meta"><div><strong>鉴权:</strong>仅 non-production无需鉴权</div></div>
</div>
<div class="api-item" data-api="dev config local files 本地 配置 文件 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/dev/config/local-files</span></div>
<div class="api-desc">列出本地配置目录中的 JSON 文件,作为 source config 导入入口。</div>
<div class="api-meta"><div><strong>鉴权:</strong>仅 non-production无需鉴权</div></div>
</div>
<div class="api-item" data-api="dev import local source config 导入 本地 event json">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/events/{eventPublicID}/config-sources/import-local</span></div>
<div class="api-desc">从本地 event 目录导入 source config。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>仅 non-production无需鉴权</div>
<div><strong>关键参数:</strong><code>fileName</code>、<code>notes</code></div>
</div>
</div>
<div class="api-item" data-api="dev config preview build 预览 manifest asset index">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/preview</span></div>
<div class="api-desc">基于 source config 生成 preview build并产出 preview manifest。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>仅 non-production无需鉴权</div>
<div><strong>关键参数:</strong><code>sourceId</code></div>
</div>
</div>
<div class="api-item" data-api="dev config publish build 发布 release 当前版本">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/publish</span></div>
<div class="api-desc">把成功的 build 发布成正式 release并可选直接挂接 runtime binding。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>仅 non-production无需鉴权</div>
<div><strong>关键参数:</strong><code>buildId</code>、<code>runtimeBindingId</code></div>
</div>
</div>
<div class="api-item" data-api="admin maps 列表 地图 后台 资源">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/maps</span></div>
<div class="api-desc">后台地图对象列表接口。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin maps create 创建 地图 对象 后台 资源">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/maps</span></div>
<div class="api-desc">创建地图对象,后续再为它追加版本。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>name</code>、<code>status</code></div>
</div>
</div>
<div class="api-item" data-api="admin map detail 地图 明细 版本 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/maps/{mapPublicID}</span></div>
<div class="api-desc">查看单个地图对象和它的版本列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin map version 创建 地图 版本 mapmeta tiles">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/maps/{mapPublicID}/versions</span></div>
<div class="api-desc">为地图对象创建一个版本,挂接 mapmeta 和 tiles 根路径。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>versionCode</code>、<code>mapmetaUrl</code>、<code>tilesRootUrl</code>、<code>setAsCurrent</code></div>
</div>
</div>
<div class="api-item" data-api="admin places 列表 地点 生产骨架">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/places</span></div>
<div class="api-desc">第一阶段生产骨架的地点对象列表接口。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin places create 创建 地点 place 生产骨架">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/places</span></div>
<div class="api-desc">创建地点对象,作为地图资产的上层归属。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>name</code>、<code>region</code>、<code>status</code></div>
</div>
</div>
<div class="api-item" data-api="admin place detail 地点 明细 map assets">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/places/{placePublicID}</span></div>
<div class="api-desc">查看地点详情,并带出该地点下的地图资产列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin place map asset 创建 地点 地图资产">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/places/{placePublicID}/map-assets</span></div>
<div class="api-desc">在指定地点下创建地图资产,可选挂接已有 legacy map。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>name</code>、<code>mapType</code>、<code>legacyMapId</code></div>
</div>
</div>
<div class="api-item" data-api="admin map asset detail 地图资产 明细 tile releases course sets">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/map-assets/{mapAssetPublicID}</span></div>
<div class="api-desc">查看地图资产详情,带出瓦片版本和赛道集合摘要。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin tile release 创建 瓦片版本 生产骨架">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/map-assets/{mapAssetPublicID}/tile-releases</span></div>
<div class="api-desc">为地图资产创建瓦片版本,可选关联已有 legacy map version。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>versionCode</code>、<code>tileBaseUrl</code>、<code>metaUrl</code>、<code>setAsCurrent</code></div>
</div>
</div>
<div class="api-item" data-api="admin course source 列表 kml 输入源">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/course-sources</span></div>
<div class="api-desc">查看赛道原始输入源列表,承接 KML / GeoJSON 等输入。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin course source 创建 kml 输入源">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/course-sources</span></div>
<div class="api-desc">创建赛道输入源,为后续解析成 CourseVariant 做准备。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>sourceType</code>、<code>fileUrl</code>、<code>legacyPlayfieldId</code>、<code>legacyVersionId</code></div>
</div>
</div>
<div class="api-item" data-api="admin course source detail 输入源 明细">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/course-sources/{sourcePublicID}</span></div>
<div class="api-desc">查看单个赛道输入源详情。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin course set 创建 赛道集合 map asset">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/map-assets/{mapAssetPublicID}/course-sets</span></div>
<div class="api-desc">在指定地图资产下创建赛道集合。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>mode</code>、<code>name</code>、<code>status</code></div>
</div>
</div>
<div class="api-item" data-api="admin course set detail 赛道集合 variant 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/course-sets/{courseSetPublicID}</span></div>
<div class="api-desc">查看单个赛道集合详情和 variant 列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin course variant 创建 variant 赛道方案">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/course-sets/{courseSetPublicID}/variants</span></div>
<div class="api-desc">为赛道集合创建具体可运行赛道方案。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>sourceId</code>、<code>name</code>、<code>routeCode</code>、<code>mode</code>、<code>isDefault</code></div>
</div>
</div>
<div class="api-item" data-api="admin runtime bindings 列表 运行绑定">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/runtime-bindings</span></div>
<div class="api-desc">查看活动运行绑定列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin runtime binding 创建 活动 运行绑定">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/runtime-bindings</span></div>
<div class="api-desc">把活动和地点、地图资产、瓦片、赛道集合、variant 绑定起来。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>eventId</code>、<code>placeId</code>、<code>mapAssetId</code>、<code>tileReleaseId</code>、<code>courseSetId</code>、<code>courseVariantId</code></div>
</div>
</div>
<div class="api-item" data-api="admin runtime binding detail 明细">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/runtime-bindings/{runtimeBindingPublicID}</span></div>
<div class="api-desc">查看单个运行绑定详情。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin playfields 列表 赛场 kml 后台 资源">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/playfields</span></div>
<div class="api-desc">后台赛场对象列表接口。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin playfields create 创建 赛场 对象 kml">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/playfields</span></div>
<div class="api-desc">创建赛场对象,适合管理 KML / GeoJSON 这类可复用场地资源。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>name</code>、<code>kind</code>、<code>status</code></div>
</div>
</div>
<div class="api-item" data-api="admin playfield detail 赛场 明细 版本 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/playfields/{playfieldPublicID}</span></div>
<div class="api-desc">查看单个赛场对象和它的版本列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin playfield version 创建 赛场 版本 kml sourceUrl">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/playfields/{playfieldPublicID}/versions</span></div>
<div class="api-desc">为赛场对象创建一个版本,挂接 KML 等源文件地址和控制点摘要。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>versionCode</code>、<code>sourceType</code>、<code>sourceUrl</code>、<code>controlCount</code>、<code>setAsCurrent</code></div>
</div>
</div>
<div class="api-item" data-api="admin resource packs 列表 资源包 后台">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/resource-packs</span></div>
<div class="api-desc">后台资源包对象列表接口。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin resource packs create 创建 资源包">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/resource-packs</span></div>
<div class="api-desc">创建资源包对象,用来管理内容页、音频和主题资源。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>name</code>、<code>status</code></div>
</div>
</div>
<div class="api-item" data-api="admin resource pack detail 资源包 明细 版本">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/resource-packs/{resourcePackPublicID}</span></div>
<div class="api-desc">查看单个资源包对象和它的版本列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin resource pack version 创建 资源包 版本 content audio theme">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/resource-packs/{resourcePackPublicID}/versions</span></div>
<div class="api-desc">为资源包对象创建版本,配置内容入口、音频根路径和主题代码。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>versionCode</code>、<code>contentEntryUrl</code>、<code>audioRootUrl</code>、<code>themeProfileCode</code>、<code>setAsCurrent</code></div>
</div>
</div>
<div class="api-item" data-api="admin events 列表 后台 event">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/events</span></div>
<div class="api-desc">后台 event 列表接口。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin events create 创建 event">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events</span></div>
<div class="api-desc">创建 event 基础信息。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>tenantCode</code>、<code>slug</code>、<code>displayName</code>、<code>status</code></div>
</div>
</div>
<div class="api-item" data-api="admin event detail 后台 event 明细 latest source">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/events/{eventPublicID}</span></div>
<div class="api-desc">查看 event 明细、最新 source 和当前 source 摘要。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin event update 更新 event 基础信息">
<div class="api-head"><span class="api-method">PUT</span><span class="api-path">/admin/events/{eventPublicID}</span></div>
<div class="api-desc">更新 event 基础信息。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>tenantCode</code>、<code>slug</code>、<code>displayName</code>、<code>status</code></div>
</div>
</div>
<div class="api-item" data-api="admin event source 组装 map playfield resource pack game mode">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events/{eventPublicID}/source</span></div>
<div class="api-desc">把 map/playfield/resource pack 版本和 gameModeCode 组装成 source config。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>map.mapId</code>、<code>map.versionId</code>、<code>playfield.playfieldId</code>、<code>playfield.versionId</code>、<code>gameModeCode</code>、<code>overrides</code></div>
</div>
</div>
<div class="api-item" data-api="admin event presentations 列表 展示定义 presentation">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/events/{eventPublicID}/presentations</span></div>
<div class="api-desc">查看某个 event 下的展示定义列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin event presentations create 创建 展示定义 presentation schema">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events/{eventPublicID}/presentations</span></div>
<div class="api-desc">为 event 创建一条最小 presentation 定义,供 release 绑定使用。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>name</code>、<code>presentationType</code>、<code>schema</code></div>
</div>
</div>
<div class="api-item" data-api="admin event presentations import 导入 展示定义 templateKey schemaUrl version">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events/{eventPublicID}/presentations/import</span></div>
<div class="api-desc">通过统一导入入口为 event 创建展示定义,先记录 templateKey、sourceType、schemaUrl、version 和 title。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>title</code>、<code>templateKey</code>、<code>sourceType</code>、<code>schemaUrl</code>、<code>version</code></div>
</div>
</div>
<div class="api-item" data-api="admin presentation detail 展示定义 明细">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/presentations/{presentationPublicID}</span></div>
<div class="api-desc">查看单条 presentation 明细。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin event content bundles 列表 内容包">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/events/{eventPublicID}/content-bundles</span></div>
<div class="api-desc">查看某个 event 下的内容包列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin event content bundles create 创建 内容包 entry asset metadata">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events/{eventPublicID}/content-bundles</span></div>
<div class="api-desc">为 event 创建一条最小 content bundle供 release 绑定使用。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>code</code>、<code>name</code>、<code>entryUrl</code>、<code>assetRootUrl</code>、<code>metadata</code></div>
</div>
</div>
<div class="api-item" data-api="admin event content bundles import 导入 内容包 manifest bundleType version">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events/{eventPublicID}/content-bundles/import</span></div>
<div class="api-desc">通过统一导入入口为 event 创建内容包,先记录 bundleType、sourceType、manifestUrl、version 和 assetManifest。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>title</code>、<code>bundleType</code>、<code>sourceType</code>、<code>manifestUrl</code>、<code>version</code></div>
</div>
</div>
<div class="api-item" data-api="admin content bundle detail 内容包 明细">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/content-bundles/{contentBundlePublicID}</span></div>
<div class="api-desc">查看单条内容包明细。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin event defaults 默认绑定 presentation content bundle runtime">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events/{eventPublicID}/defaults</span></div>
<div class="api-desc">固化 event 当前默认 active 绑定,供后续 publish 在未显式传参时继承。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>presentationId</code>、<code>contentBundleId</code>、<code>runtimeBindingId</code></div>
</div>
</div>
<div class="api-item" data-api="admin event pipeline 流水线 source build release">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/events/{eventPublicID}/pipeline</span></div>
<div class="api-desc">查看 event 下的 source、build、release 流水线概览。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin source build 后台 build source">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/sources/{sourceID}/build</span></div>
<div class="api-desc">基于 source 生成一条 build 记录和 preview manifest。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin build detail 后台 build 明细">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/builds/{buildID}</span></div>
<div class="api-desc">查看后台 build 明细。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin build publish 后台 发布 release runtime binding">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/builds/{buildID}/publish</span></div>
<div class="api-desc">把后台 build 发布为正式 release可选直接挂接 runtime binding、presentation 和内容包,并切换为 event 当前发布版本。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>runtimeBindingId</code>、<code>presentationId</code>、<code>contentBundleId</code></div>
</div>
</div>
<div class="api-item" data-api="admin release detail runtime binding 运行 摘要">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/admin/releases/{releasePublicID}</span></div>
<div class="api-desc">查看单个 release 明细,并带出当前已挂接的 runtime 摘要。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="admin release runtime binding 挂接 运行对象">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/releases/{releasePublicID}/runtime-binding</span></div>
<div class="api-desc">把某个 runtime binding 挂接到指定 release上游 launch 会透出新的 runtime 摘要。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>runtimeBindingId</code></div>
</div>
</div>
<div class="api-item" data-api="admin event rollback 回滚 发布版本">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/admin/events/{eventPublicID}/rollback</span></div>
<div class="api-desc">将 event 当前发布版本回滚到指定 releaseId。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>releaseId</code></div>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
<script>
const STORAGE_KEY = 'cmr-backend-workbench-state-v1';
const HISTORY_KEY = 'cmr-backend-workbench-history-v1';
const SCENARIO_KEY = 'cmr-backend-workbench-scenarios-v1';
const MODE_KEY = 'cmr-backend-workbench-mode-v1';
const state = {
accessToken: '',
refreshToken: '',
sourceId: '',
buildId: '',
releaseId: '',
sessionId: '',
sessionToken: '',
lastCurl: ''
};
const $ = (id) => document.getElementById(id);
const logEl = $('log');
const curlEl = $('curl');
const historyEl = $('history');
const statusEl = $('status');
const modeNodes = Array.from(document.querySelectorAll('[data-modes]'));
const modeButtons = Array.from(document.querySelectorAll('[data-mode-btn]'));
const navLinks = Array.from(document.querySelectorAll('[data-nav-target]'));
const builtInScenarios = [
{
id: 'preset-demo-wechat',
builtin: true,
name: 'Preset: Demo WeChat Flow',
fields: {
smsClientType: 'wechat',
smsScene: 'login',
smsMobile: '13800138000',
smsDevice: 'workbench-device-001',
smsCountry: '86',
smsCode: '',
wechatCode: 'dev-workbench-user',
wechatDevice: 'wechat-device-001',
entryChannelCode: 'mini-demo',
entryChannelType: 'wechat_mini',
eventId: 'evt_demo_001',
eventReleaseId: 'rel_demo_001',
eventVariantId: '',
eventDevice: 'wechat-device-001',
finishStatus: 'finished',
finishDuration: '960',
finishScore: '88',
finishControlsDone: '7',
finishControlsTotal: '8',
finishDistance: '5230',
finishSpeed: '6.45',
finishHeartRate: '168',
adminMapCode: 'map-demo-001',
adminMapName: 'Demo Park Map',
adminMapVersionCode: 'v2026-04-02',
adminMapStatus: 'active',
adminMapmetaUrl: 'https://example.com/maps/demo/mapmeta.json',
adminTilesRootUrl: 'https://example.com/maps/demo/tiles/',
adminPlayfieldCode: 'pf-demo-001',
adminPlayfieldName: 'Demo Course',
adminPlayfieldKind: 'course',
adminPlayfieldStatus: 'active',
adminPlayfieldVersionCode: 'v2026-04-02',
adminPlayfieldSourceType: 'kml',
adminPlayfieldSourceUrl: 'https://example.com/playfields/demo/course.kml',
adminPlayfieldControlCount: '8',
adminPackCode: 'pack-demo-001',
adminPackName: 'Demo Resource Pack',
adminPackVersionCode: 'v2026-04-02',
adminPackStatus: 'active',
adminPackContentUrl: 'https://example.com/packs/demo/content.html',
adminPackAudioUrl: 'https://example.com/packs/demo/audio/',
adminPackThemeCode: 'theme-demo',
adminPublishedAssetRoot: '',
adminTenantCode: 'tenant_demo',
adminEventStatus: 'active',
adminEventRefId: 'evt_demo_001',
adminEventSlug: 'demo-city-run',
adminEventName: 'Demo City Run',
adminEventSummary: '后台第一版 Event用于资源对象组装和发布链路验证。',
adminGameModeCode: 'classic-sequential',
adminRouteCode: 'route-demo-001',
adminSourceNotes: 'workbench assembled source',
adminOverridesJSON: '{}',
adminPipelineSourceId: '',
adminPipelineBuildId: '',
adminPipelineReleaseId: '',
adminRollbackReleaseId: ''
}
},
{
id: 'preset-demo-app-launch',
builtin: true,
name: 'Preset: Demo App Launch Flow',
fields: {
smsClientType: 'app',
smsScene: 'login',
smsMobile: '13800138000',
smsDevice: 'workbench-device-001',
smsCountry: '86',
smsCode: '',
wechatCode: 'dev-workbench-user',
wechatDevice: 'wechat-device-001',
entryChannelCode: 'mini-demo',
entryChannelType: 'wechat_mini',
eventId: 'evt_demo_001',
eventReleaseId: 'rel_demo_001',
eventVariantId: '',
eventDevice: 'workbench-device-001',
finishStatus: 'finished',
finishDuration: '960',
finishScore: '88',
finishControlsDone: '7',
finishControlsTotal: '8',
finishDistance: '5230',
finishSpeed: '6.45',
finishHeartRate: '168',
adminMapCode: 'map-demo-001',
adminMapName: 'Demo Park Map',
adminMapVersionCode: 'v2026-04-02',
adminMapStatus: 'active',
adminMapmetaUrl: 'https://example.com/maps/demo/mapmeta.json',
adminTilesRootUrl: 'https://example.com/maps/demo/tiles/',
adminPlayfieldCode: 'pf-demo-001',
adminPlayfieldName: 'Demo Course',
adminPlayfieldKind: 'course',
adminPlayfieldStatus: 'active',
adminPlayfieldVersionCode: 'v2026-04-02',
adminPlayfieldSourceType: 'kml',
adminPlayfieldSourceUrl: 'https://example.com/playfields/demo/course.kml',
adminPlayfieldControlCount: '8',
adminPackCode: 'pack-demo-001',
adminPackName: 'Demo Resource Pack',
adminPackVersionCode: 'v2026-04-02',
adminPackStatus: 'active',
adminPackContentUrl: 'https://example.com/packs/demo/content.html',
adminPackAudioUrl: 'https://example.com/packs/demo/audio/',
adminPackThemeCode: 'theme-demo',
adminPublishedAssetRoot: '',
adminTenantCode: 'tenant_demo',
adminEventStatus: 'active',
adminEventRefId: 'evt_demo_001',
adminEventSlug: 'demo-city-run',
adminEventName: 'Demo City Run',
adminEventSummary: '后台第一版 Event用于资源对象组装和发布链路验证。',
adminGameModeCode: 'classic-sequential',
adminRouteCode: 'route-demo-001',
adminSourceNotes: 'workbench assembled source',
adminOverridesJSON: '{}',
adminPipelineSourceId: '',
adminPipelineBuildId: '',
adminPipelineReleaseId: '',
adminRollbackReleaseId: ''
}
}
];
function syncState() {
$('state-access').textContent = state.accessToken || '-';
$('state-refresh').textContent = state.refreshToken || '-';
$('state-source').textContent = state.sourceId || '-';
$('state-build').textContent = state.buildId || '-';
$('state-release').textContent = state.releaseId || '-';
$('state-session').textContent = state.sessionId || '-';
$('state-session-token').textContent = state.sessionToken || '-';
$('config-source-id').value = state.sourceId || '';
$('config-build-id').value = state.buildId || '';
$('admin-pipeline-source-id').value = state.sourceId || '';
$('admin-pipeline-build-id').value = state.buildId || '';
$('event-release-id').value = state.releaseId || $('event-release-id').value;
$('admin-pipeline-release-id').value = state.releaseId || $('admin-pipeline-release-id').value;
$('session-id').value = state.sessionId || '';
$('session-token').value = state.sessionToken || '';
curlEl.textContent = state.lastCurl || '-';
persistState();
}
function setDefaultPublishExpectation(result) {
const release = result || {};
const releaseId = release.id || '-';
const presentationId = release.presentation && release.presentation.presentationId ? release.presentation.presentationId : '-';
const contentBundleId = release.contentBundle && release.contentBundle.contentBundleId ? release.contentBundle.contentBundleId : '-';
const runtimeBindingId = release.runtime && release.runtime.runtimeBindingId ? release.runtime.runtimeBindingId : '-';
const expectedRuntime = trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value);
const hasPresentation = presentationId !== '-';
const hasContentBundle = contentBundleId !== '-';
const runtimeSatisfied = expectedRuntime ? runtimeBindingId !== '-' : true;
let verdict = '未通过';
if (hasPresentation && hasContentBundle && runtimeSatisfied) {
verdict = expectedRuntime ? '通过presentation / content bundle / runtime 已继承' : '通过presentation / content bundle 已继承runtime 未配置';
}
$('flow-admin-release-result').textContent = releaseId;
$('flow-admin-presentation-result').textContent = presentationId;
$('flow-admin-content-bundle-result').textContent = contentBundleId;
$('flow-admin-runtime-result').textContent = runtimeBindingId;
$('flow-admin-verdict').textContent = verdict;
}
async function runAdminDefaultPublishFlow(options) {
const ensureRuntime = options && options.ensureRuntime === true;
const flowTitle = ensureRuntime ? 'flow-admin-runtime-publish' : 'flow-admin-default-publish';
const eventId = $('admin-event-ref-id').value || $('event-id').value;
if (!trimmedOrUndefined(eventId)) {
throw new Error('admin event id is required');
}
if (ensureRuntime) {
writeLog(flowTitle + '.step', { step: 'bootstrap-demo' });
const bootstrap = await request('POST', '/dev/bootstrap-demo');
if (bootstrap.data) {
state.sourceId = bootstrap.data.sourceId || state.sourceId;
state.buildId = bootstrap.data.buildId || state.buildId;
state.releaseId = bootstrap.data.releaseId || state.releaseId;
$('admin-pipeline-source-id').value = bootstrap.data.sourceId || $('admin-pipeline-source-id').value;
$('admin-pipeline-build-id').value = bootstrap.data.buildId || $('admin-pipeline-build-id').value;
$('admin-pipeline-release-id').value = bootstrap.data.releaseId || $('admin-pipeline-release-id').value;
$('prod-runtime-event-id').value = bootstrap.data.eventId || $('prod-runtime-event-id').value;
$('prod-place-id').value = bootstrap.data.placeId || $('prod-place-id').value;
$('prod-map-asset-id').value = bootstrap.data.mapAssetId || $('prod-map-asset-id').value;
$('prod-tile-release-id').value = bootstrap.data.tileReleaseId || $('prod-tile-release-id').value;
$('prod-course-source-id').value = bootstrap.data.courseSourceId || $('prod-course-source-id').value;
$('prod-course-set-id').value = bootstrap.data.courseSetId || $('prod-course-set-id').value;
$('prod-course-variant-id').value = bootstrap.data.courseVariantId || $('prod-course-variant-id').value;
$('prod-runtime-binding-id').value = bootstrap.data.runtimeBindingId || $('prod-runtime-binding-id').value;
}
}
writeLog(flowTitle + '.step', { step: 'get-event', eventId: eventId });
const eventDetail = await request('GET', '/admin/events/' + encodeURIComponent(eventId), undefined, true);
if (eventDetail.data && eventDetail.data.event) {
$('admin-event-ref-id').value = eventDetail.data.event.id || $('admin-event-ref-id').value;
$('event-id').value = eventDetail.data.event.id || $('event-id').value;
$('prod-runtime-event-id').value = eventDetail.data.event.id || $('prod-runtime-event-id').value;
if (eventDetail.data.latestSource && eventDetail.data.latestSource.id) {
state.sourceId = eventDetail.data.latestSource.id;
$('admin-pipeline-source-id').value = eventDetail.data.latestSource.id;
}
if (eventDetail.data.currentRuntime && eventDetail.data.currentRuntime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId;
$('prod-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId;
}
}
writeLog(flowTitle + '.step', { step: 'import-presentation', eventId: eventId });
const importedPresentation = await request('POST', '/admin/events/' + encodeURIComponent(eventId) + '/presentations/import', {
title: $('admin-presentation-import-title').value,
templateKey: $('admin-presentation-import-template-key').value,
sourceType: $('admin-presentation-import-source-type').value,
schemaUrl: $('admin-presentation-import-schema-url').value,
version: $('admin-presentation-import-version').value,
status: 'active',
isDefault: true
}, true);
if (importedPresentation.data) {
$('admin-presentation-id').value = importedPresentation.data.id || $('admin-presentation-id').value;
$('admin-release-presentation-id').value = importedPresentation.data.id || $('admin-release-presentation-id').value;
$('config-presentation-id').value = importedPresentation.data.id || $('config-presentation-id').value;
}
writeLog(flowTitle + '.step', { step: 'import-bundle', eventId: eventId });
const importedBundle = await request('POST', '/admin/events/' + encodeURIComponent(eventId) + '/content-bundles/import', {
title: $('admin-content-import-title').value,
bundleType: $('admin-content-import-bundle-type').value,
sourceType: $('admin-content-import-source-type').value,
manifestUrl: $('admin-content-import-manifest-url').value,
version: $('admin-content-import-version').value,
status: 'active',
isDefault: true,
assetManifest: parseJSONObjectOrUndefined($('admin-content-import-asset-manifest-json').value, 'Content Asset Manifest JSON')
}, true);
if (importedBundle.data) {
$('admin-content-bundle-id').value = importedBundle.data.id || $('admin-content-bundle-id').value;
$('admin-release-content-bundle-id').value = importedBundle.data.id || $('admin-release-content-bundle-id').value;
$('config-content-bundle-id').value = importedBundle.data.id || $('config-content-bundle-id').value;
}
if (ensureRuntime && !trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value)) {
const missing = [];
if (!trimmedOrUndefined($('prod-place-id').value)) {
missing.push('Place ID');
}
if (!trimmedOrUndefined($('prod-map-asset-id').value)) {
missing.push('Map Asset ID');
}
if (!trimmedOrUndefined($('prod-tile-release-id').value)) {
missing.push('Tile Release ID');
}
if (!trimmedOrUndefined($('prod-course-set-id').value)) {
missing.push('Course Set ID');
}
if (!trimmedOrUndefined($('prod-course-variant-id').value)) {
missing.push('Course Variant ID');
}
if (missing.length > 0) {
throw new Error('创建 runtime binding 前缺少字段: ' + missing.join(', '));
}
writeLog(flowTitle + '.step', {
step: 'create-runtime-binding',
eventId: eventId,
placeId: $('prod-place-id').value,
mapAssetId: $('prod-map-asset-id').value,
tileReleaseId: $('prod-tile-release-id').value,
courseSetId: $('prod-course-set-id').value,
courseVariantId: $('prod-course-variant-id').value
});
const createdRuntime = await request('POST', '/admin/runtime-bindings', {
eventId: $('prod-runtime-event-id').value || eventId,
placeId: $('prod-place-id').value,
mapAssetId: $('prod-map-asset-id').value,
tileReleaseId: $('prod-tile-release-id').value,
courseSetId: $('prod-course-set-id').value,
courseVariantId: $('prod-course-variant-id').value,
status: $('prod-runtime-binding-status').value,
notes: trimmedOrUndefined($('prod-runtime-notes').value)
}, true);
if (createdRuntime.data && createdRuntime.data.id) {
$('prod-runtime-binding-id').value = createdRuntime.data.id;
$('admin-release-runtime-binding-id').value = createdRuntime.data.id;
}
}
writeLog(flowTitle + '.step', { step: 'save-defaults', eventId: eventId });
const defaults = await request('POST', '/admin/events/' + encodeURIComponent(eventId) + '/defaults', {
presentationId: trimmedOrUndefined($('admin-release-presentation-id').value),
contentBundleId: trimmedOrUndefined($('admin-release-content-bundle-id').value),
runtimeBindingId: trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value)
}, true);
if (defaults.data && defaults.data.currentPresentation && defaults.data.currentPresentation.presentationId) {
$('admin-presentation-id').value = defaults.data.currentPresentation.presentationId;
$('admin-release-presentation-id').value = defaults.data.currentPresentation.presentationId;
$('config-presentation-id').value = defaults.data.currentPresentation.presentationId;
}
if (defaults.data && defaults.data.currentContentBundle && defaults.data.currentContentBundle.contentBundleId) {
$('admin-content-bundle-id').value = defaults.data.currentContentBundle.contentBundleId;
$('admin-release-content-bundle-id').value = defaults.data.currentContentBundle.contentBundleId;
$('config-content-bundle-id').value = defaults.data.currentContentBundle.contentBundleId;
}
if (defaults.data && defaults.data.currentRuntime && defaults.data.currentRuntime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = defaults.data.currentRuntime.runtimeBindingId;
$('prod-runtime-binding-id').value = defaults.data.currentRuntime.runtimeBindingId;
}
const sourceId = $('admin-pipeline-source-id').value || state.sourceId;
if (!trimmedOrUndefined(sourceId)) {
throw new Error('no source id available for build');
}
writeLog(flowTitle + '.step', { step: 'build-source', sourceId: sourceId });
const build = await request('POST', '/admin/sources/' + encodeURIComponent(sourceId) + '/build', undefined, true);
state.sourceId = build.data.sourceId || state.sourceId;
state.buildId = build.data.id || state.buildId;
$('admin-pipeline-build-id').value = build.data.id || $('admin-pipeline-build-id').value;
$('admin-pipeline-source-id').value = build.data.sourceId || $('admin-pipeline-source-id').value;
writeLog(flowTitle + '.step', { step: 'publish-build', buildId: $('admin-pipeline-build-id').value || state.buildId });
const published = await request('POST', '/admin/builds/' + encodeURIComponent($('admin-pipeline-build-id').value || state.buildId) + '/publish', {}, true);
state.releaseId = published.data.release.releaseId || state.releaseId;
$('admin-pipeline-release-id').value = published.data.release.releaseId || $('admin-pipeline-release-id').value;
if (published.data.runtime && published.data.runtime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = published.data.runtime.runtimeBindingId;
$('prod-runtime-binding-id').value = published.data.runtime.runtimeBindingId;
}
if (published.data.presentation && published.data.presentation.presentationId) {
$('admin-release-presentation-id').value = published.data.presentation.presentationId;
$('admin-presentation-id').value = published.data.presentation.presentationId;
$('config-presentation-id').value = published.data.presentation.presentationId;
}
if (published.data.contentBundle && published.data.contentBundle.contentBundleId) {
$('admin-release-content-bundle-id').value = published.data.contentBundle.contentBundleId;
$('admin-content-bundle-id').value = published.data.contentBundle.contentBundleId;
$('config-content-bundle-id').value = published.data.contentBundle.contentBundleId;
}
writeLog(flowTitle + '.step', { step: 'get-release', releaseId: $('admin-pipeline-release-id').value || state.releaseId });
const releaseDetail = await request('GET', '/admin/releases/' + encodeURIComponent($('admin-pipeline-release-id').value || state.releaseId), undefined, true);
setDefaultPublishExpectation(releaseDetail.data);
writeLog(flowTitle + '.expected', {
releaseId: $('flow-admin-release-result').textContent,
presentationId: $('flow-admin-presentation-result').textContent,
contentBundleId: $('flow-admin-content-bundle-result').textContent,
runtimeBindingId: $('flow-admin-runtime-result').textContent,
verdict: $('flow-admin-verdict').textContent
});
syncState();
persistState();
return releaseDetail;
}
function setStatus(text, isError = false) {
statusEl.textContent = text;
statusEl.className = isError ? 'status error' : 'status';
}
function getWorkbenchMode() {
return localStorage.getItem(MODE_KEY) || 'frontend';
}
function syncWorkbenchMode() {
const mode = getWorkbenchMode();
modeNodes.forEach(function(node) {
const supported = String(node.dataset.modes || '').split(/\s+/).filter(Boolean);
const visible = mode === 'all' || supported.includes(mode) || supported.includes('common');
node.classList.toggle('mode-hidden', !visible);
});
modeButtons.forEach(function(button) {
button.classList.toggle('active', button.dataset.modeBtn === mode);
});
}
function normalizeLogPayload(payload) {
if (payload instanceof Error) {
return {
name: payload.name,
message: payload.message,
stack: payload.stack
};
}
if (payload && typeof payload === 'object') {
if (payload.error instanceof Error) {
const next = Object.assign({}, payload);
next.error = normalizeLogPayload(payload.error);
return next;
}
return payload;
}
return payload;
}
function writeLog(title, payload) {
logEl.textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(normalizeLogPayload(payload), null, 2);
}
function persistState() {
const payload = {
state,
fields: collectFields()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function collectFields() {
return {
smsClientType: $('sms-client-type').value,
smsScene: $('sms-scene').value,
smsMobile: $('sms-mobile').value,
smsDevice: $('sms-device').value,
smsCountry: $('sms-country').value,
smsCode: $('sms-code').value,
wechatCode: $('wechat-code').value,
wechatDevice: $('wechat-device').value,
localConfigFile: $('local-config-file').value,
configEventId: $('config-event-id').value,
configRuntimeBindingId: $('config-runtime-binding-id').value,
configPresentationId: $('config-presentation-id').value,
configContentBundleId: $('config-content-bundle-id').value,
entryChannelCode: $('entry-channel-code').value,
entryChannelType: $('entry-channel-type').value,
eventId: $('event-id').value,
eventReleaseId: $('event-release-id').value,
eventVariantId: $('event-variant-id').value,
eventDevice: $('event-device').value,
finishStatus: $('finish-status').value,
finishDuration: $('finish-duration').value,
finishScore: $('finish-score').value,
finishControlsDone: $('finish-controls-done').value,
finishControlsTotal: $('finish-controls-total').value,
finishDistance: $('finish-distance').value,
finishSpeed: $('finish-speed').value,
finishHeartRate: $('finish-heart-rate').value,
prodPlaceCode: $('prod-place-code').value,
prodPlaceName: $('prod-place-name').value,
prodPlaceId: $('prod-place-id').value,
prodPlaceStatus: $('prod-place-status').value,
prodPlaceRegion: $('prod-place-region').value,
prodPlaceCoverUrl: $('prod-place-cover-url').value,
prodMapAssetCode: $('prod-map-asset-code').value,
prodMapAssetName: $('prod-map-asset-name').value,
prodMapAssetId: $('prod-map-asset-id').value,
prodMapAssetLegacyMapId: $('prod-map-asset-legacy-map-id').value,
prodMapAssetType: $('prod-map-asset-type').value,
prodMapAssetStatus: $('prod-map-asset-status').value,
prodTileReleaseId: $('prod-tile-release-id').value,
prodTileLegacyVersionId: $('prod-tile-legacy-version-id').value,
prodTileVersionCode: $('prod-tile-version-code').value,
prodTileStatus: $('prod-tile-status').value,
prodTileBaseUrl: $('prod-tile-base-url').value,
prodTileMetaUrl: $('prod-tile-meta-url').value,
prodCourseSourceId: $('prod-course-source-id').value,
prodCourseSourceLegacyPlayfieldId: $('prod-course-source-legacy-playfield-id').value,
prodCourseSourceLegacyVersionId: $('prod-course-source-legacy-version-id').value,
prodCourseSourceType: $('prod-course-source-type').value,
prodCourseSourceFileUrl: $('prod-course-source-file-url').value,
prodCourseSourceStatus: $('prod-course-source-status').value,
prodCourseSetCode: $('prod-course-set-code').value,
prodCourseSetName: $('prod-course-set-name').value,
prodCourseSetId: $('prod-course-set-id').value,
prodCourseMode: $('prod-course-mode').value,
prodCourseSetStatus: $('prod-course-set-status').value,
prodCourseVariantId: $('prod-course-variant-id').value,
prodCourseVariantName: $('prod-course-variant-name').value,
prodCourseVariantRouteCode: $('prod-course-variant-route-code').value,
prodCourseVariantStatus: $('prod-course-variant-status').value,
prodCourseVariantControlCount: $('prod-course-variant-control-count').value,
prodRuntimeBindingId: $('prod-runtime-binding-id').value,
prodRuntimeEventId: $('prod-runtime-event-id').value,
prodRuntimeBindingStatus: $('prod-runtime-binding-status').value,
prodRuntimeNotes: $('prod-runtime-notes').value,
adminMapCode: $('admin-map-code').value,
adminMapName: $('admin-map-name').value,
adminMapId: $('admin-map-id').value,
adminMapVersionId: $('admin-map-version-id').value,
adminMapVersionCode: $('admin-map-version-code').value,
adminMapStatus: $('admin-map-status').value,
adminMapmetaUrl: $('admin-mapmeta-url').value,
adminTilesRootUrl: $('admin-tiles-root-url').value,
adminPlayfieldCode: $('admin-playfield-code').value,
adminPlayfieldName: $('admin-playfield-name').value,
adminPlayfieldId: $('admin-playfield-id').value,
adminPlayfieldVersionId: $('admin-playfield-version-id').value,
adminPlayfieldKind: $('admin-playfield-kind').value,
adminPlayfieldStatus: $('admin-playfield-status').value,
adminPlayfieldVersionCode: $('admin-playfield-version-code').value,
adminPlayfieldSourceType: $('admin-playfield-source-type').value,
adminPlayfieldSourceUrl: $('admin-playfield-source-url').value,
adminPlayfieldControlCount: $('admin-playfield-control-count').value,
adminPackCode: $('admin-pack-code').value,
adminPackName: $('admin-pack-name').value,
adminPackId: $('admin-pack-id').value,
adminPackVersionId: $('admin-pack-version-id').value,
adminPackVersionCode: $('admin-pack-version-code').value,
adminPackStatus: $('admin-pack-status').value,
adminPackContentUrl: $('admin-pack-content-url').value,
adminPackAudioUrl: $('admin-pack-audio-url').value,
adminPackThemeCode: $('admin-pack-theme-code').value,
adminPublishedAssetRoot: $('admin-published-asset-root').value,
adminTenantCode: $('admin-tenant-code').value,
adminEventStatus: $('admin-event-status').value,
adminEventRefId: $('admin-event-ref-id').value,
adminEventSlug: $('admin-event-slug').value,
adminEventName: $('admin-event-name').value,
adminEventSummary: $('admin-event-summary').value,
adminGameModeCode: $('admin-game-mode-code').value,
adminRouteCode: $('admin-route-code').value,
adminSourceNotes: $('admin-source-notes').value,
adminOverridesJSON: $('admin-overrides-json').value,
adminPresentationId: $('admin-presentation-id').value,
adminPresentationCode: $('admin-presentation-code').value,
adminPresentationName: $('admin-presentation-name').value,
adminPresentationType: $('admin-presentation-type').value,
adminPresentationSchemaJSON: $('admin-presentation-schema-json').value,
adminPresentationImportTitle: $('admin-presentation-import-title').value,
adminPresentationImportTemplateKey: $('admin-presentation-import-template-key').value,
adminPresentationImportSourceType: $('admin-presentation-import-source-type').value,
adminPresentationImportVersion: $('admin-presentation-import-version').value,
adminPresentationImportSchemaURL: $('admin-presentation-import-schema-url').value,
adminContentBundleId: $('admin-content-bundle-id').value,
adminContentBundleCode: $('admin-content-bundle-code').value,
adminContentBundleName: $('admin-content-bundle-name').value,
adminContentEntryURL: $('admin-content-entry-url').value,
adminContentAssetRootURL: $('admin-content-asset-root-url').value,
adminContentMetadataJSON: $('admin-content-metadata-json').value,
adminContentImportTitle: $('admin-content-import-title').value,
adminContentImportBundleType: $('admin-content-import-bundle-type').value,
adminContentImportSourceType: $('admin-content-import-source-type').value,
adminContentImportVersion: $('admin-content-import-version').value,
adminContentImportManifestURL: $('admin-content-import-manifest-url').value,
adminContentImportAssetManifestJSON: $('admin-content-import-asset-manifest-json').value,
adminPipelineSourceId: $('admin-pipeline-source-id').value,
adminPipelineBuildId: $('admin-pipeline-build-id').value,
adminPipelineReleaseId: $('admin-pipeline-release-id').value,
adminReleaseRuntimeBindingId: $('admin-release-runtime-binding-id').value,
adminReleasePresentationId: $('admin-release-presentation-id').value,
adminReleaseContentBundleId: $('admin-release-content-bundle-id').value,
adminRollbackReleaseId: $('admin-rollback-release-id').value
};
}
function restoreState() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
try {
const payload = JSON.parse(raw);
if (payload.state) {
state.accessToken = payload.state.accessToken || '';
state.refreshToken = payload.state.refreshToken || '';
state.sourceId = payload.state.sourceId || '';
state.buildId = payload.state.buildId || '';
state.releaseId = payload.state.releaseId || '';
state.sessionId = payload.state.sessionId || '';
state.sessionToken = payload.state.sessionToken || '';
state.lastCurl = payload.state.lastCurl || '';
}
applyFields(payload.fields || {});
} catch (_) {}
}
function applyFields(fields) {
$('sms-client-type').value = fields.smsClientType || $('sms-client-type').value;
$('sms-scene').value = fields.smsScene || $('sms-scene').value;
$('sms-mobile').value = fields.smsMobile || $('sms-mobile').value;
$('sms-device').value = fields.smsDevice || $('sms-device').value;
$('sms-country').value = fields.smsCountry || $('sms-country').value;
$('sms-code').value = fields.smsCode || '';
$('wechat-code').value = fields.wechatCode || $('wechat-code').value;
$('wechat-device').value = fields.wechatDevice || $('wechat-device').value;
$('local-config-file').value = fields.localConfigFile || $('local-config-file').value;
$('config-event-id').value = fields.configEventId || $('config-event-id').value;
$('config-runtime-binding-id').value = fields.configRuntimeBindingId || $('config-runtime-binding-id').value;
$('config-presentation-id').value = fields.configPresentationId || $('config-presentation-id').value;
$('config-content-bundle-id').value = fields.configContentBundleId || $('config-content-bundle-id').value;
$('entry-channel-code').value = fields.entryChannelCode || $('entry-channel-code').value;
$('entry-channel-type').value = fields.entryChannelType || $('entry-channel-type').value;
$('event-id').value = fields.eventId || $('event-id').value;
$('event-release-id').value = fields.eventReleaseId || $('event-release-id').value;
$('event-variant-id').value = fields.eventVariantId || $('event-variant-id').value;
$('event-device').value = fields.eventDevice || $('event-device').value;
$('finish-status').value = fields.finishStatus || $('finish-status').value;
$('finish-duration').value = fields.finishDuration || $('finish-duration').value;
$('finish-score').value = fields.finishScore || $('finish-score').value;
$('finish-controls-done').value = fields.finishControlsDone || $('finish-controls-done').value;
$('finish-controls-total').value = fields.finishControlsTotal || $('finish-controls-total').value;
$('finish-distance').value = fields.finishDistance || $('finish-distance').value;
$('finish-speed').value = fields.finishSpeed || $('finish-speed').value;
$('finish-heart-rate').value = fields.finishHeartRate || $('finish-heart-rate').value;
$('prod-place-code').value = fields.prodPlaceCode || $('prod-place-code').value;
$('prod-place-name').value = fields.prodPlaceName || $('prod-place-name').value;
$('prod-place-id').value = fields.prodPlaceId || $('prod-place-id').value;
$('prod-place-status').value = fields.prodPlaceStatus || $('prod-place-status').value;
$('prod-place-region').value = fields.prodPlaceRegion || $('prod-place-region').value;
$('prod-place-cover-url').value = fields.prodPlaceCoverUrl || $('prod-place-cover-url').value;
$('prod-map-asset-code').value = fields.prodMapAssetCode || $('prod-map-asset-code').value;
$('prod-map-asset-name').value = fields.prodMapAssetName || $('prod-map-asset-name').value;
$('prod-map-asset-id').value = fields.prodMapAssetId || $('prod-map-asset-id').value;
$('prod-map-asset-legacy-map-id').value = fields.prodMapAssetLegacyMapId || $('prod-map-asset-legacy-map-id').value;
$('prod-map-asset-type').value = fields.prodMapAssetType || $('prod-map-asset-type').value;
$('prod-map-asset-status').value = fields.prodMapAssetStatus || $('prod-map-asset-status').value;
$('prod-tile-release-id').value = fields.prodTileReleaseId || $('prod-tile-release-id').value;
$('prod-tile-legacy-version-id').value = fields.prodTileLegacyVersionId || $('prod-tile-legacy-version-id').value;
$('prod-tile-version-code').value = fields.prodTileVersionCode || $('prod-tile-version-code').value;
$('prod-tile-status').value = fields.prodTileStatus || $('prod-tile-status').value;
$('prod-tile-base-url').value = fields.prodTileBaseUrl || $('prod-tile-base-url').value;
$('prod-tile-meta-url').value = fields.prodTileMetaUrl || $('prod-tile-meta-url').value;
$('prod-course-source-id').value = fields.prodCourseSourceId || $('prod-course-source-id').value;
$('prod-course-source-legacy-playfield-id').value = fields.prodCourseSourceLegacyPlayfieldId || $('prod-course-source-legacy-playfield-id').value;
$('prod-course-source-legacy-version-id').value = fields.prodCourseSourceLegacyVersionId || $('prod-course-source-legacy-version-id').value;
$('prod-course-source-type').value = fields.prodCourseSourceType || $('prod-course-source-type').value;
$('prod-course-source-file-url').value = fields.prodCourseSourceFileUrl || $('prod-course-source-file-url').value;
$('prod-course-source-status').value = fields.prodCourseSourceStatus || $('prod-course-source-status').value;
$('prod-course-set-code').value = fields.prodCourseSetCode || $('prod-course-set-code').value;
$('prod-course-set-name').value = fields.prodCourseSetName || $('prod-course-set-name').value;
$('prod-course-set-id').value = fields.prodCourseSetId || $('prod-course-set-id').value;
$('prod-course-mode').value = fields.prodCourseMode || $('prod-course-mode').value;
$('prod-course-set-status').value = fields.prodCourseSetStatus || $('prod-course-set-status').value;
$('prod-course-variant-id').value = fields.prodCourseVariantId || $('prod-course-variant-id').value;
$('prod-course-variant-name').value = fields.prodCourseVariantName || $('prod-course-variant-name').value;
$('prod-course-variant-route-code').value = fields.prodCourseVariantRouteCode || $('prod-course-variant-route-code').value;
$('prod-course-variant-status').value = fields.prodCourseVariantStatus || $('prod-course-variant-status').value;
$('prod-course-variant-control-count').value = fields.prodCourseVariantControlCount || $('prod-course-variant-control-count').value;
$('prod-runtime-binding-id').value = fields.prodRuntimeBindingId || $('prod-runtime-binding-id').value;
$('prod-runtime-event-id').value = fields.prodRuntimeEventId || $('prod-runtime-event-id').value;
$('prod-runtime-binding-status').value = fields.prodRuntimeBindingStatus || $('prod-runtime-binding-status').value;
$('prod-runtime-notes').value = fields.prodRuntimeNotes || $('prod-runtime-notes').value;
$('admin-map-code').value = fields.adminMapCode || $('admin-map-code').value;
$('admin-map-name').value = fields.adminMapName || $('admin-map-name').value;
$('admin-map-id').value = fields.adminMapId || $('admin-map-id').value;
$('admin-map-version-id').value = fields.adminMapVersionId || $('admin-map-version-id').value;
$('admin-map-version-code').value = fields.adminMapVersionCode || $('admin-map-version-code').value;
$('admin-map-status').value = fields.adminMapStatus || $('admin-map-status').value;
$('admin-mapmeta-url').value = fields.adminMapmetaUrl || $('admin-mapmeta-url').value;
$('admin-tiles-root-url').value = fields.adminTilesRootUrl || $('admin-tiles-root-url').value;
$('admin-playfield-code').value = fields.adminPlayfieldCode || $('admin-playfield-code').value;
$('admin-playfield-name').value = fields.adminPlayfieldName || $('admin-playfield-name').value;
$('admin-playfield-id').value = fields.adminPlayfieldId || $('admin-playfield-id').value;
$('admin-playfield-version-id').value = fields.adminPlayfieldVersionId || $('admin-playfield-version-id').value;
$('admin-playfield-kind').value = fields.adminPlayfieldKind || $('admin-playfield-kind').value;
$('admin-playfield-status').value = fields.adminPlayfieldStatus || $('admin-playfield-status').value;
$('admin-playfield-version-code').value = fields.adminPlayfieldVersionCode || $('admin-playfield-version-code').value;
$('admin-playfield-source-type').value = fields.adminPlayfieldSourceType || $('admin-playfield-source-type').value;
$('admin-playfield-source-url').value = fields.adminPlayfieldSourceUrl || $('admin-playfield-source-url').value;
$('admin-playfield-control-count').value = fields.adminPlayfieldControlCount || $('admin-playfield-control-count').value;
$('admin-pack-code').value = fields.adminPackCode || $('admin-pack-code').value;
$('admin-pack-name').value = fields.adminPackName || $('admin-pack-name').value;
$('admin-pack-id').value = fields.adminPackId || $('admin-pack-id').value;
$('admin-pack-version-id').value = fields.adminPackVersionId || $('admin-pack-version-id').value;
$('admin-pack-version-code').value = fields.adminPackVersionCode || $('admin-pack-version-code').value;
$('admin-pack-status').value = fields.adminPackStatus || $('admin-pack-status').value;
$('admin-pack-content-url').value = fields.adminPackContentUrl || $('admin-pack-content-url').value;
$('admin-pack-audio-url').value = fields.adminPackAudioUrl || $('admin-pack-audio-url').value;
$('admin-pack-theme-code').value = fields.adminPackThemeCode || $('admin-pack-theme-code').value;
$('admin-published-asset-root').value = fields.adminPublishedAssetRoot || $('admin-published-asset-root').value;
$('admin-tenant-code').value = fields.adminTenantCode || $('admin-tenant-code').value;
$('admin-event-status').value = fields.adminEventStatus || $('admin-event-status').value;
$('admin-event-ref-id').value = fields.adminEventRefId || $('admin-event-ref-id').value;
$('admin-event-slug').value = fields.adminEventSlug || $('admin-event-slug').value;
$('admin-event-name').value = fields.adminEventName || $('admin-event-name').value;
$('admin-event-summary').value = fields.adminEventSummary || $('admin-event-summary').value;
$('admin-game-mode-code').value = fields.adminGameModeCode || $('admin-game-mode-code').value;
$('admin-route-code').value = fields.adminRouteCode || $('admin-route-code').value;
$('admin-source-notes').value = fields.adminSourceNotes || $('admin-source-notes').value;
$('admin-overrides-json').value = fields.adminOverridesJSON || $('admin-overrides-json').value;
$('admin-presentation-id').value = fields.adminPresentationId || $('admin-presentation-id').value;
$('admin-presentation-code').value = fields.adminPresentationCode || $('admin-presentation-code').value;
$('admin-presentation-name').value = fields.adminPresentationName || $('admin-presentation-name').value;
$('admin-presentation-type').value = fields.adminPresentationType || $('admin-presentation-type').value;
$('admin-presentation-schema-json').value = fields.adminPresentationSchemaJSON || $('admin-presentation-schema-json').value;
$('admin-presentation-import-title').value = fields.adminPresentationImportTitle || $('admin-presentation-import-title').value;
$('admin-presentation-import-template-key').value = fields.adminPresentationImportTemplateKey || $('admin-presentation-import-template-key').value;
$('admin-presentation-import-source-type').value = fields.adminPresentationImportSourceType || $('admin-presentation-import-source-type').value;
$('admin-presentation-import-version').value = fields.adminPresentationImportVersion || $('admin-presentation-import-version').value;
$('admin-presentation-import-schema-url').value = fields.adminPresentationImportSchemaURL || $('admin-presentation-import-schema-url').value;
$('admin-content-bundle-id').value = fields.adminContentBundleId || $('admin-content-bundle-id').value;
$('admin-content-bundle-code').value = fields.adminContentBundleCode || $('admin-content-bundle-code').value;
$('admin-content-bundle-name').value = fields.adminContentBundleName || $('admin-content-bundle-name').value;
$('admin-content-entry-url').value = fields.adminContentEntryURL || $('admin-content-entry-url').value;
$('admin-content-asset-root-url').value = fields.adminContentAssetRootURL || $('admin-content-asset-root-url').value;
$('admin-content-metadata-json').value = fields.adminContentMetadataJSON || $('admin-content-metadata-json').value;
$('admin-content-import-title').value = fields.adminContentImportTitle || $('admin-content-import-title').value;
$('admin-content-import-bundle-type').value = fields.adminContentImportBundleType || $('admin-content-import-bundle-type').value;
$('admin-content-import-source-type').value = fields.adminContentImportSourceType || $('admin-content-import-source-type').value;
$('admin-content-import-version').value = fields.adminContentImportVersion || $('admin-content-import-version').value;
$('admin-content-import-manifest-url').value = fields.adminContentImportManifestURL || $('admin-content-import-manifest-url').value;
$('admin-content-import-asset-manifest-json').value = fields.adminContentImportAssetManifestJSON || $('admin-content-import-asset-manifest-json').value;
$('admin-pipeline-source-id').value = fields.adminPipelineSourceId || $('admin-pipeline-source-id').value;
$('admin-pipeline-build-id').value = fields.adminPipelineBuildId || $('admin-pipeline-build-id').value;
$('admin-pipeline-release-id').value = fields.adminPipelineReleaseId || $('admin-pipeline-release-id').value;
$('admin-release-runtime-binding-id').value = fields.adminReleaseRuntimeBindingId || $('admin-release-runtime-binding-id').value;
$('admin-release-presentation-id').value = fields.adminReleasePresentationId || $('admin-release-presentation-id').value;
$('admin-release-content-bundle-id').value = fields.adminReleaseContentBundleId || $('admin-release-content-bundle-id').value;
$('admin-rollback-release-id').value = fields.adminRollbackReleaseId || $('admin-rollback-release-id').value;
}
function parseIntOrNull(value) {
if (value === '' || value === null || value === undefined) {
return null;
}
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function parseFloatOrNull(value) {
if (value === '' || value === null || value === undefined) {
return null;
}
const parsed = parseFloat(value);
return Number.isNaN(parsed) ? null : parsed;
}
function trimmedOrUndefined(value) {
if (value === null || value === undefined) {
return undefined;
}
const trimmed = String(value).trim();
return trimmed === '' ? undefined : trimmed;
}
function parseJSONObjectOrUndefined(value, label) {
const trimmed = trimmedOrUndefined(value);
if (trimmed === undefined) {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch (_) {
throw new Error(label + ' must be valid JSON');
}
}
function buildFinishSummary() {
const summary = {
finalDurationSec: parseIntOrNull($('finish-duration').value),
finalScore: parseIntOrNull($('finish-score').value),
completedControls: parseIntOrNull($('finish-controls-done').value),
totalControls: parseIntOrNull($('finish-controls-total').value),
distanceMeters: parseFloatOrNull($('finish-distance').value),
averageSpeedKmh: parseFloatOrNull($('finish-speed').value),
maxHeartRateBpm: parseIntOrNull($('finish-heart-rate').value)
};
Object.keys(summary).forEach(function(key) {
if (summary[key] === null) {
delete summary[key];
}
});
return summary;
}
function buildCurl(method, url, body, headers) {
let curl = 'curl -X ' + method + ' "' + window.location.origin + url + '"';
Object.entries(headers || {}).forEach(function(entry) {
curl += ' -H "' + entry[0] + ': ' + String(entry[1]).replace(/"/g, '\\"') + '"';
});
if (body !== undefined) {
curl += " --data-raw '" + JSON.stringify(body).replace(/'/g, "'\"'\"'") + "'";
}
return curl;
}
function getHistory() {
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) {
return [];
}
try {
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (_) {
return [];
}
}
function pushHistory(item) {
const next = [item].concat(getHistory()).slice(0, 12);
localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
renderHistory();
}
function renderHistory() {
const history = getHistory();
historyEl.innerHTML = '';
if (!history.length) {
historyEl.innerHTML = '<div class="muted-note">No requests yet.</div>';
return;
}
history.forEach(function(item) {
const node = document.createElement('div');
node.className = 'history-item';
node.innerHTML =
'<strong>' + item.title + '</strong><br>' +
item.time + '<br>' +
'status=' + item.status + '<br>' +
'url=' + item.url;
historyEl.appendChild(node);
});
}
function applyAPIFilter() {
const keyword = $('api-filter').value.trim().toLowerCase();
document.querySelectorAll('.api-item').forEach(function(node) {
const haystack = String(node.dataset.api || '').toLowerCase();
if (!keyword || haystack.indexOf(keyword) >= 0) {
node.classList.remove('hidden');
} else {
node.classList.add('hidden');
}
});
syncAPICounts();
}
function categorizeApiPath(path) {
if (path === '/healthz') {
return 'Health';
}
if (path.indexOf('/auth/') === 0) {
return 'Auth';
}
if (path.indexOf('/entry/') === 0 || path === '/home' || path === '/cards' || path === '/me/entry-home') {
return 'Entry/Home';
}
if (path.indexOf('/events/') === 0 || path.indexOf('/config-sources/') === 0 || path.indexOf('/config-builds/') === 0) {
return 'Event';
}
if (path.indexOf('/sessions/') === 0 || path === '/me/sessions') {
return 'Session';
}
if (path === '/me/results') {
return 'Result';
}
if (path === '/me' || path === '/me/profile') {
return 'Profile';
}
if (path.indexOf('/dev/') === 0) {
return 'Dev';
}
if (path.indexOf('/admin/') === 0) {
return 'Admin';
}
return 'Other';
}
function syncAPICounts() {
const items = Array.from(document.querySelectorAll('.api-item'));
const total = items.length;
const visibleItems = items.filter(function(node) {
return !node.classList.contains('hidden');
});
$('nav-api-count').textContent = '(' + total + ')';
$('api-total-count').textContent = '(' + total + ')';
$('api-filter-meta').textContent = '当前 ' + visibleItems.length + ' / 总计 ' + total + ' 个接口,支持按关键词筛选。';
const order = ['Health', 'Auth', 'Entry/Home', 'Event', 'Session', 'Result', 'Profile', 'Dev', 'Admin'];
const counts = {};
order.forEach(function(key) { counts[key] = 0; });
items.forEach(function(node) {
const pathEl = node.querySelector('.api-path');
const path = pathEl ? pathEl.textContent.trim() : '';
const category = categorizeApiPath(path);
counts[category] = (counts[category] || 0) + 1;
});
$('api-summary').innerHTML = order.map(function(key) {
const value = counts[key] || 0;
return '<span class=\"api-chip\">' + key + ' ' + value + '</span>';
}).join('');
}
function getSavedScenarios() {
const raw = localStorage.getItem(SCENARIO_KEY);
if (!raw) {
return [];
}
try {
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (_) {
return [];
}
}
function setSavedScenarios(items) {
localStorage.setItem(SCENARIO_KEY, JSON.stringify(items));
renderScenarioOptions();
}
function allScenarios() {
return builtInScenarios.concat(getSavedScenarios());
}
function renderScenarioOptions() {
const select = $('scenario-select');
const scenarios = allScenarios();
select.innerHTML = '';
if (!scenarios.length) {
select.innerHTML = '<option value="">No scenarios</option>';
return;
}
scenarios.forEach(function(item) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name + (item.builtin ? ' [preset]' : '');
select.appendChild(option);
});
}
function findScenario(id) {
return allScenarios().find(function(item) {
return item.id === id;
}) || null;
}
function saveCurrentScenario() {
const name = $('scenario-name').value.trim();
if (!name) {
setStatus('error: scenario name required', true);
return;
}
const saved = getSavedScenarios();
const scenario = {
id: 'custom-' + Date.now(),
builtin: false,
name: name,
fields: collectFields()
};
saved.unshift(scenario);
setSavedScenarios(saved.slice(0, 20));
$('scenario-select').value = scenario.id;
$('scenario-json').value = JSON.stringify(scenario, null, 2);
setStatus('ok: scenario saved');
}
function loadSelectedScenario() {
const scenario = findScenario($('scenario-select').value);
if (!scenario) {
setStatus('error: scenario not found', true);
return;
}
applyFields(scenario.fields || {});
$('scenario-name').value = scenario.name || '';
$('scenario-json').value = JSON.stringify(scenario, null, 2);
persistState();
setStatus('ok: scenario loaded');
}
function deleteSelectedScenario() {
const id = $('scenario-select').value;
const scenario = findScenario(id);
if (!scenario || scenario.builtin) {
setStatus('error: builtin scenario cannot be deleted', true);
return;
}
const next = getSavedScenarios().filter(function(item) {
return item.id !== id;
});
setSavedScenarios(next);
$('scenario-json').value = '';
setStatus('ok: scenario deleted');
}
function exportSelectedScenario() {
const scenario = findScenario($('scenario-select').value);
if (!scenario) {
setStatus('error: scenario not found', true);
return;
}
$('scenario-json').value = JSON.stringify(scenario, null, 2);
setStatus('ok: scenario exported');
}
function importScenarioFromJSON() {
const raw = $('scenario-json').value.trim();
if (!raw) {
setStatus('error: scenario json is empty', true);
return;
}
try {
const scenario = JSON.parse(raw);
if (!scenario.name || !scenario.fields) {
throw new Error('scenario must include name and fields');
}
const saved = getSavedScenarios();
saved.unshift({
id: 'custom-' + Date.now(),
builtin: false,
name: String(scenario.name),
fields: scenario.fields
});
setSavedScenarios(saved.slice(0, 20));
setStatus('ok: scenario imported');
} catch (err) {
setStatus('error: invalid scenario json', true);
}
}
async function request(method, url, body, needAuth = false) {
const headers = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (needAuth) {
headers['Authorization'] = 'Bearer ' + state.accessToken;
}
state.lastCurl = buildCurl(method, url, body, headers);
syncState();
const resp = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw { status: resp.status, body: data, url: url, method: method };
}
return data;
}
async function run(title, fn) {
setStatus('running: ' + title);
try {
const result = await fn();
setStatus('ok: ' + title);
writeLog(title, result);
pushHistory({
title: title,
time: new Date().toLocaleString(),
status: 'ok',
url: state.lastCurl
});
syncState();
} catch (err) {
setStatus('error: ' + title + ' -> ' + (err && err.message ? err.message : 'unknown error'), true);
writeLog(title, {
error: err,
lastCurl: state.lastCurl || null
});
pushHistory({
title: title,
time: new Date().toLocaleString(),
status: 'error',
url: state.lastCurl
});
}
}
$('btn-clear-state').onclick = () => {
state.accessToken = '';
state.refreshToken = '';
state.sourceId = '';
state.buildId = '';
state.releaseId = '';
state.sessionId = '';
state.sessionToken = '';
state.lastCurl = '';
syncState();
writeLog('clear-state', { ok: true });
setStatus('ready');
};
modeButtons.forEach(function(button) {
button.onclick = () => {
localStorage.setItem(MODE_KEY, button.dataset.modeBtn);
syncWorkbenchMode();
writeLog('workbench-mode', { mode: button.dataset.modeBtn });
setStatus('ok: mode -> ' + button.dataset.modeBtn);
};
});
navLinks.forEach(function(link) {
link.onclick = function(event) {
event.preventDefault();
const targetId = link.dataset.navTarget;
const targetMode = link.dataset.navMode;
if (targetMode) {
localStorage.setItem(MODE_KEY, targetMode);
syncWorkbenchMode();
}
const targetEl = document.getElementById(targetId);
if (targetEl) {
targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
writeLog('workbench-nav', { target: targetId, mode: targetMode || getWorkbenchMode() });
setStatus('ok: nav -> ' + targetId);
}
};
});
$('btn-config-files').onclick = () => run('config/local-files', () =>
request('GET', '/dev/config/local-files')
);
$('btn-config-import').onclick = () => run('config/import-local', async () => {
const result = await request('POST', '/dev/events/' + encodeURIComponent($('config-event-id').value) + '/config-sources/import-local', {
fileName: $('local-config-file').value
});
state.sourceId = result.data.id;
return result;
});
$('btn-config-preview').onclick = () => run('config/build-preview', async () => {
const result = await request('POST', '/dev/config-builds/preview', {
sourceId: $('config-source-id').value
});
state.buildId = result.data.id;
return result;
});
$('btn-config-publish').onclick = () => run('config/publish-build', async () => {
const result = await request('POST', '/dev/config-builds/publish', {
buildId: $('config-build-id').value,
runtimeBindingId: trimmedOrUndefined($('config-runtime-binding-id').value),
presentationId: trimmedOrUndefined($('config-presentation-id').value),
contentBundleId: trimmedOrUndefined($('config-content-bundle-id').value)
});
state.releaseId = result.data.release.releaseId;
$('event-release-id').value = result.data.release.releaseId;
if (result.data.runtime && result.data.runtime.runtimeBindingId) {
$('config-runtime-binding-id').value = result.data.runtime.runtimeBindingId;
}
if (result.data.presentation && result.data.presentation.presentationId) {
$('config-presentation-id').value = result.data.presentation.presentationId;
$('admin-release-presentation-id').value = result.data.presentation.presentationId;
}
if (result.data.contentBundle && result.data.contentBundle.contentBundleId) {
$('config-content-bundle-id').value = result.data.contentBundle.contentBundleId;
$('admin-release-content-bundle-id').value = result.data.contentBundle.contentBundleId;
}
return result;
});
$('btn-config-source').onclick = () => run('config/get-source', () =>
request('GET', '/config-sources/' + encodeURIComponent($('config-source-id').value), undefined, true)
);
$('btn-config-build').onclick = () => run('config/get-build', () =>
request('GET', '/config-builds/' + encodeURIComponent($('config-build-id').value), undefined, true)
);
$('btn-send-sms').onclick = () => run('auth/sms/send', async () => {
const result = await request('POST', '/auth/sms/send', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value,
scene: $('sms-scene').value
});
if (result.data && result.data.devCode) {
$('sms-code').value = result.data.devCode;
}
return result;
});
$('btn-login-sms').onclick = () => run('auth/login/sms', async () => {
const result = await request('POST', '/auth/login/sms', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
code: $('sms-code').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value
});
state.accessToken = result.data.tokens.accessToken;
state.refreshToken = result.data.tokens.refreshToken;
return result;
});
$('btn-bind-mobile').onclick = () => run('auth/bind/mobile', async () => {
const result = await request('POST', '/auth/bind/mobile', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
code: $('sms-code').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value
}, true);
state.accessToken = result.data.tokens.accessToken;
state.refreshToken = result.data.tokens.refreshToken;
return result;
});
$('btn-login-wechat').onclick = () => run('auth/login/wechat-mini', async () => {
const result = await request('POST', '/auth/login/wechat-mini', {
code: $('wechat-code').value,
clientType: 'wechat',
deviceKey: $('wechat-device').value
});
state.accessToken = result.data.tokens.accessToken;
state.refreshToken = result.data.tokens.refreshToken;
return result;
});
$('btn-resolve-entry').onclick = () => run('entry/resolve', () =>
request('GET', '/entry/resolve?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
);
$('btn-home').onclick = () => run('home', () =>
request('GET', '/home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
);
$('btn-entry-home').onclick = () => run('me/entry-home', () =>
request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true)
);
$('btn-event-detail').onclick = () => run('event-detail', () =>
request('GET', '/events/' + encodeURIComponent($('event-id').value))
);
$('btn-event-play').onclick = () => run('event-play', () =>
request('GET', '/events/' + encodeURIComponent($('event-id').value) + '/play', undefined, true)
);
$('btn-launch').onclick = () => run('event-launch', async () => {
const result = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
releaseId: $('event-release-id').value,
variantId: trimmedOrUndefined($('event-variant-id').value),
clientType: $('sms-client-type').value,
deviceKey: $('event-device').value
}, true);
state.sessionId = result.data.launch.business.sessionId;
state.sessionToken = result.data.launch.business.sessionToken;
return result;
});
$('btn-session-detail').onclick = () => run('session-detail', () =>
request('GET', '/sessions/' + encodeURIComponent($('session-id').value), undefined, true)
);
$('btn-session-start').onclick = () => run('session-start', () =>
request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/start', {
sessionToken: $('session-token').value
})
);
$('btn-session-finish').onclick = () => run('session-finish', () =>
request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
sessionToken: $('session-token').value,
status: $('finish-status').value,
summary: buildFinishSummary()
})
);
$('btn-my-sessions').onclick = () => run('me/sessions', () =>
request('GET', '/me/sessions?limit=10', undefined, true)
);
$('btn-session-result').onclick = () => run('session-result', () =>
request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true)
);
$('btn-my-results').onclick = () => run('me/results', () =>
request('GET', '/me/results?limit=10', undefined, true)
);
$('btn-me').onclick = () => run('me', () =>
request('GET', '/me', undefined, true)
);
$('btn-profile').onclick = () => run('me/profile', () =>
request('GET', '/me/profile', undefined, true)
);
$('btn-prod-places-list').onclick = () => run('admin/places/list', async () => {
const result = await request('GET', '/admin/places?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('prod-place-id').value = first.id || $('prod-place-id').value;
}
persistState();
return result;
});
$('btn-prod-place-create').onclick = () => run('admin/places/create', async () => {
const result = await request('POST', '/admin/places', {
code: $('prod-place-code').value,
name: $('prod-place-name').value,
region: trimmedOrUndefined($('prod-place-region').value),
coverUrl: trimmedOrUndefined($('prod-place-cover-url').value),
status: $('prod-place-status').value
}, true);
$('prod-place-id').value = result.data.id || $('prod-place-id').value;
persistState();
return result;
});
$('btn-prod-place-detail').onclick = () => run('admin/places/detail', async () => {
const result = await request('GET', '/admin/places/' + encodeURIComponent($('prod-place-id').value), undefined, true);
if (result.data && result.data.place) {
$('prod-place-id').value = result.data.place.id || $('prod-place-id').value;
}
if (result.data && result.data.mapAssets && result.data.mapAssets[0]) {
$('prod-map-asset-id').value = result.data.mapAssets[0].id || $('prod-map-asset-id').value;
}
persistState();
return result;
});
$('btn-prod-map-asset-create').onclick = () => run('admin/map-assets/create', async () => {
const result = await request('POST', '/admin/places/' + encodeURIComponent($('prod-place-id').value) + '/map-assets', {
code: $('prod-map-asset-code').value,
name: $('prod-map-asset-name').value,
mapType: $('prod-map-asset-type').value,
legacyMapId: trimmedOrUndefined($('prod-map-asset-legacy-map-id').value),
status: $('prod-map-asset-status').value
}, true);
$('prod-map-asset-id').value = result.data.id || $('prod-map-asset-id').value;
persistState();
return result;
});
$('btn-prod-map-asset-detail').onclick = () => run('admin/map-assets/detail', async () => {
const result = await request('GET', '/admin/map-assets/' + encodeURIComponent($('prod-map-asset-id').value), undefined, true);
if (result.data && result.data.mapAsset) {
$('prod-map-asset-id').value = result.data.mapAsset.id || $('prod-map-asset-id').value;
}
if (result.data && result.data.mapAsset && result.data.mapAsset.currentTileRelease) {
$('prod-tile-release-id').value = result.data.mapAsset.currentTileRelease.id || $('prod-tile-release-id').value;
} else if (result.data && result.data.tileReleases && result.data.tileReleases[0]) {
$('prod-tile-release-id').value = result.data.tileReleases[0].id || $('prod-tile-release-id').value;
}
if (result.data && result.data.courseSets && result.data.courseSets[0]) {
$('prod-course-set-id').value = result.data.courseSets[0].id || $('prod-course-set-id').value;
}
persistState();
return result;
});
$('btn-prod-tile-create').onclick = () => run('admin/tile-releases/create', async () => {
const result = await request('POST', '/admin/map-assets/' + encodeURIComponent($('prod-map-asset-id').value) + '/tile-releases', {
legacyVersionId: trimmedOrUndefined($('prod-tile-legacy-version-id').value),
versionCode: $('prod-tile-version-code').value,
status: $('prod-tile-status').value,
tileBaseUrl: $('prod-tile-base-url').value,
metaUrl: $('prod-tile-meta-url').value,
setAsCurrent: true
}, true);
$('prod-tile-release-id').value = result.data.id || $('prod-tile-release-id').value;
persistState();
return result;
});
$('btn-prod-course-sources-list').onclick = () => run('admin/course-sources/list', async () => {
const result = await request('GET', '/admin/course-sources?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('prod-course-source-id').value = first.id || $('prod-course-source-id').value;
}
persistState();
return result;
});
$('btn-prod-course-source-create').onclick = () => run('admin/course-sources/create', async () => {
const result = await request('POST', '/admin/course-sources', {
legacyPlayfieldId: trimmedOrUndefined($('prod-course-source-legacy-playfield-id').value),
legacyVersionId: trimmedOrUndefined($('prod-course-source-legacy-version-id').value),
sourceType: $('prod-course-source-type').value,
fileUrl: $('prod-course-source-file-url').value,
importStatus: $('prod-course-source-status').value
}, true);
$('prod-course-source-id').value = result.data.id || $('prod-course-source-id').value;
persistState();
return result;
});
$('btn-prod-course-source-detail').onclick = () => run('admin/course-sources/detail', async () => {
const result = await request('GET', '/admin/course-sources/' + encodeURIComponent($('prod-course-source-id').value), undefined, true);
if (result.data) {
$('prod-course-source-id').value = result.data.id || $('prod-course-source-id').value;
}
persistState();
return result;
});
$('btn-prod-course-set-create').onclick = () => run('admin/course-sets/create', async () => {
const result = await request('POST', '/admin/map-assets/' + encodeURIComponent($('prod-map-asset-id').value) + '/course-sets', {
code: $('prod-course-set-code').value,
mode: $('prod-course-mode').value,
name: $('prod-course-set-name').value,
status: $('prod-course-set-status').value
}, true);
$('prod-course-set-id').value = result.data.id || $('prod-course-set-id').value;
persistState();
return result;
});
$('btn-prod-course-set-detail').onclick = () => run('admin/course-sets/detail', async () => {
const result = await request('GET', '/admin/course-sets/' + encodeURIComponent($('prod-course-set-id').value), undefined, true);
if (result.data && result.data.courseSet) {
$('prod-course-set-id').value = result.data.courseSet.id || $('prod-course-set-id').value;
if (result.data.courseSet.currentVariant) {
$('prod-course-variant-id').value = result.data.courseSet.currentVariant.id || $('prod-course-variant-id').value;
}
}
if (result.data && result.data.variants && result.data.variants[0]) {
$('prod-course-variant-id').value = result.data.variants[0].id || $('prod-course-variant-id').value;
}
persistState();
return result;
});
$('btn-prod-course-variant-create').onclick = () => run('admin/course-variants/create', async () => {
const result = await request('POST', '/admin/course-sets/' + encodeURIComponent($('prod-course-set-id').value) + '/variants', {
sourceId: trimmedOrUndefined($('prod-course-source-id').value),
name: $('prod-course-variant-name').value,
routeCode: trimmedOrUndefined($('prod-course-variant-route-code').value),
mode: $('prod-course-mode').value,
controlCount: parseIntOrNull($('prod-course-variant-control-count').value),
status: $('prod-course-variant-status').value,
isDefault: true
}, true);
$('prod-course-variant-id').value = result.data.id || $('prod-course-variant-id').value;
persistState();
return result;
});
$('btn-prod-runtime-bindings-list').onclick = () => run('admin/runtime-bindings/list', async () => {
const result = await request('GET', '/admin/runtime-bindings?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('prod-runtime-binding-id').value = first.id || $('prod-runtime-binding-id').value;
}
persistState();
return result;
});
$('btn-prod-runtime-binding-create').onclick = () => run('admin/runtime-bindings/create', async () => {
const result = await request('POST', '/admin/runtime-bindings', {
eventId: $('prod-runtime-event-id').value,
placeId: $('prod-place-id').value,
mapAssetId: $('prod-map-asset-id').value,
tileReleaseId: $('prod-tile-release-id').value,
courseSetId: $('prod-course-set-id').value,
courseVariantId: $('prod-course-variant-id').value,
status: $('prod-runtime-binding-status').value,
notes: trimmedOrUndefined($('prod-runtime-notes').value)
}, true);
$('prod-runtime-binding-id').value = result.data.id || $('prod-runtime-binding-id').value;
persistState();
return result;
});
$('btn-prod-runtime-binding-detail').onclick = () => run('admin/runtime-bindings/detail', async () => {
const result = await request('GET', '/admin/runtime-bindings/' + encodeURIComponent($('prod-runtime-binding-id').value), undefined, true);
if (result.data) {
$('prod-runtime-binding-id').value = result.data.id || $('prod-runtime-binding-id').value;
}
persistState();
return result;
});
$('btn-admin-maps-list').onclick = () => run('admin/maps/list', async () => {
const result = await request('GET', '/admin/maps?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('admin-map-id').value = first.id || $('admin-map-id').value;
$('admin-map-version-id').value = first.currentVersionId || $('admin-map-version-id').value;
}
persistState();
return result;
});
$('btn-admin-map-create').onclick = () => run('admin/maps/create', async () => {
const result = await request('POST', '/admin/maps', {
code: $('admin-map-code').value,
name: $('admin-map-name').value,
status: $('admin-map-status').value
}, true);
$('admin-map-id').value = result.data.id || $('admin-map-id').value;
persistState();
return result;
});
$('btn-admin-map-version').onclick = () => run('admin/maps/version', async () => {
const result = await request('POST', '/admin/maps/' + encodeURIComponent($('admin-map-id').value) + '/versions', {
versionCode: $('admin-map-version-code').value,
status: $('admin-map-status').value,
mapmetaUrl: $('admin-mapmeta-url').value,
tilesRootUrl: $('admin-tiles-root-url').value,
publishedAssetRoot: trimmedOrUndefined($('admin-published-asset-root').value),
setAsCurrent: true
}, true);
$('admin-map-version-id').value = result.data.id || $('admin-map-version-id').value;
persistState();
return result;
});
$('btn-admin-map-detail').onclick = () => run('admin/maps/detail', async () => {
const result = await request('GET', '/admin/maps/' + encodeURIComponent($('admin-map-id').value), undefined, true);
if (result.data && result.data.map) {
$('admin-map-id').value = result.data.map.id || $('admin-map-id').value;
$('admin-map-version-id').value = result.data.map.currentVersionId || $('admin-map-version-id').value;
}
persistState();
return result;
});
$('btn-admin-playfields-list').onclick = () => run('admin/playfields/list', async () => {
const result = await request('GET', '/admin/playfields?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('admin-playfield-id').value = first.id || $('admin-playfield-id').value;
$('admin-playfield-version-id').value = first.currentVersionId || $('admin-playfield-version-id').value;
}
persistState();
return result;
});
$('btn-admin-playfield-create').onclick = () => run('admin/playfields/create', async () => {
const result = await request('POST', '/admin/playfields', {
code: $('admin-playfield-code').value,
name: $('admin-playfield-name').value,
kind: $('admin-playfield-kind').value,
status: $('admin-playfield-status').value
}, true);
$('admin-playfield-id').value = result.data.id || $('admin-playfield-id').value;
persistState();
return result;
});
$('btn-admin-playfield-version').onclick = () => run('admin/playfields/version', async () => {
const result = await request('POST', '/admin/playfields/' + encodeURIComponent($('admin-playfield-id').value) + '/versions', {
versionCode: $('admin-playfield-version-code').value,
status: $('admin-playfield-status').value,
sourceType: $('admin-playfield-source-type').value,
sourceUrl: $('admin-playfield-source-url').value,
publishedAssetRoot: trimmedOrUndefined($('admin-published-asset-root').value),
controlCount: parseIntOrNull($('admin-playfield-control-count').value),
setAsCurrent: true
}, true);
$('admin-playfield-version-id').value = result.data.id || $('admin-playfield-version-id').value;
persistState();
return result;
});
$('btn-admin-playfield-detail').onclick = () => run('admin/playfields/detail', async () => {
const result = await request('GET', '/admin/playfields/' + encodeURIComponent($('admin-playfield-id').value), undefined, true);
if (result.data && result.data.playfield) {
$('admin-playfield-id').value = result.data.playfield.id || $('admin-playfield-id').value;
$('admin-playfield-version-id').value = result.data.playfield.currentVersionId || $('admin-playfield-version-id').value;
}
persistState();
return result;
});
$('btn-admin-packs-list').onclick = () => run('admin/resource-packs/list', async () => {
const result = await request('GET', '/admin/resource-packs?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('admin-pack-id').value = first.id || $('admin-pack-id').value;
$('admin-pack-version-id').value = first.currentVersionId || $('admin-pack-version-id').value;
}
persistState();
return result;
});
$('btn-admin-pack-create').onclick = () => run('admin/resource-packs/create', async () => {
const result = await request('POST', '/admin/resource-packs', {
code: $('admin-pack-code').value,
name: $('admin-pack-name').value,
status: $('admin-pack-status').value
}, true);
$('admin-pack-id').value = result.data.id || $('admin-pack-id').value;
persistState();
return result;
});
$('btn-admin-pack-version').onclick = () => run('admin/resource-packs/version', async () => {
const result = await request('POST', '/admin/resource-packs/' + encodeURIComponent($('admin-pack-id').value) + '/versions', {
versionCode: $('admin-pack-version-code').value,
status: $('admin-pack-status').value,
contentEntryUrl: trimmedOrUndefined($('admin-pack-content-url').value),
audioRootUrl: trimmedOrUndefined($('admin-pack-audio-url').value),
themeProfileCode: trimmedOrUndefined($('admin-pack-theme-code').value),
publishedAssetRoot: trimmedOrUndefined($('admin-published-asset-root').value),
setAsCurrent: true
}, true);
$('admin-pack-version-id').value = result.data.id || $('admin-pack-version-id').value;
persistState();
return result;
});
$('btn-admin-pack-detail').onclick = () => run('admin/resource-packs/detail', async () => {
const result = await request('GET', '/admin/resource-packs/' + encodeURIComponent($('admin-pack-id').value), undefined, true);
if (result.data && result.data.resourcePack) {
$('admin-pack-id').value = result.data.resourcePack.id || $('admin-pack-id').value;
$('admin-pack-version-id').value = result.data.resourcePack.currentVersionId || $('admin-pack-version-id').value;
}
persistState();
return result;
});
$('btn-admin-events-list').onclick = () => run('admin/events/list', async () => {
const result = await request('GET', '/admin/events?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('admin-event-ref-id').value = first.id || $('admin-event-ref-id').value;
$('event-id').value = first.id || $('event-id').value;
}
persistState();
return result;
});
$('btn-admin-event-create').onclick = () => run('admin/events/create', async () => {
const result = await request('POST', '/admin/events', {
tenantCode: trimmedOrUndefined($('admin-tenant-code').value),
slug: $('admin-event-slug').value,
displayName: $('admin-event-name').value,
summary: trimmedOrUndefined($('admin-event-summary').value),
status: $('admin-event-status').value
}, true);
$('admin-event-ref-id').value = result.data.id || $('admin-event-ref-id').value;
$('event-id').value = result.data.id || $('event-id').value;
persistState();
return result;
});
$('btn-admin-event-update').onclick = () => run('admin/events/update', () =>
request('PUT', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value), {
tenantCode: trimmedOrUndefined($('admin-tenant-code').value),
slug: $('admin-event-slug').value,
displayName: $('admin-event-name').value,
summary: trimmedOrUndefined($('admin-event-summary').value),
status: $('admin-event-status').value
}, true)
);
$('btn-admin-event-detail').onclick = () => run('admin/events/detail', async () => {
const result = await request('GET', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value), undefined, true);
if (result.data && result.data.event) {
$('admin-event-ref-id').value = result.data.event.id || $('admin-event-ref-id').value;
$('event-id').value = result.data.event.id || $('event-id').value;
if (result.data.event.currentRelease && result.data.event.currentRelease.id) {
state.releaseId = result.data.event.currentRelease.id;
}
if (result.data.latestSource && result.data.latestSource.id) {
state.sourceId = result.data.latestSource.id;
}
if (result.data.currentPresentation && result.data.currentPresentation.presentationId) {
$('admin-presentation-id').value = result.data.currentPresentation.presentationId;
$('admin-release-presentation-id').value = result.data.currentPresentation.presentationId;
$('config-presentation-id').value = result.data.currentPresentation.presentationId;
}
if (result.data.currentContentBundle && result.data.currentContentBundle.contentBundleId) {
$('admin-content-bundle-id').value = result.data.currentContentBundle.contentBundleId;
$('admin-release-content-bundle-id').value = result.data.currentContentBundle.contentBundleId;
$('config-content-bundle-id').value = result.data.currentContentBundle.contentBundleId;
}
if (result.data.currentRuntime && result.data.currentRuntime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId;
$('prod-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId;
}
}
syncState();
return result;
});
$('btn-admin-presentations-list').onclick = () => run('admin/presentations/list', async () => {
const result = await request('GET', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/presentations?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('admin-presentation-id').value = first.id || $('admin-presentation-id').value;
$('admin-release-presentation-id').value = first.id || $('admin-release-presentation-id').value;
$('config-presentation-id').value = first.id || $('config-presentation-id').value;
}
persistState();
return result;
});
$('btn-admin-presentation-create').onclick = () => run('admin/presentations/create', async () => {
const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/presentations', {
code: $('admin-presentation-code').value,
name: $('admin-presentation-name').value,
presentationType: $('admin-presentation-type').value,
status: 'active',
isDefault: true,
schema: parseJSONObjectOrUndefined($('admin-presentation-schema-json').value, 'Presentation Schema JSON') || {}
}, true);
$('admin-presentation-id').value = result.data.id || $('admin-presentation-id').value;
$('admin-release-presentation-id').value = result.data.id || $('admin-release-presentation-id').value;
$('config-presentation-id').value = result.data.id || $('config-presentation-id').value;
persistState();
return result;
});
$('btn-admin-presentation-import').onclick = () => run('admin/presentations/import', async () => {
const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/presentations/import', {
title: $('admin-presentation-import-title').value,
templateKey: $('admin-presentation-import-template-key').value,
sourceType: $('admin-presentation-import-source-type').value,
schemaUrl: $('admin-presentation-import-schema-url').value,
version: $('admin-presentation-import-version').value,
status: 'active',
isDefault: true
}, true);
$('admin-presentation-id').value = result.data.id || $('admin-presentation-id').value;
$('admin-presentation-name').value = result.data.name || $('admin-presentation-name').value;
$('admin-release-presentation-id').value = result.data.id || $('admin-release-presentation-id').value;
$('config-presentation-id').value = result.data.id || $('config-presentation-id').value;
persistState();
return result;
});
$('btn-admin-presentation-detail').onclick = () => run('admin/presentations/detail', async () => {
const result = await request('GET', '/admin/presentations/' + encodeURIComponent($('admin-presentation-id').value), undefined, true);
if (result.data) {
$('admin-presentation-id').value = result.data.id || $('admin-presentation-id').value;
$('admin-release-presentation-id').value = result.data.id || $('admin-release-presentation-id').value;
$('config-presentation-id').value = result.data.id || $('config-presentation-id').value;
}
persistState();
return result;
});
$('btn-admin-content-bundles-list').onclick = () => run('admin/content-bundles/list', async () => {
const result = await request('GET', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/content-bundles?limit=20', undefined, true);
const first = result.data && result.data[0];
if (first) {
$('admin-content-bundle-id').value = first.id || $('admin-content-bundle-id').value;
$('admin-release-content-bundle-id').value = first.id || $('admin-release-content-bundle-id').value;
$('config-content-bundle-id').value = first.id || $('config-content-bundle-id').value;
}
persistState();
return result;
});
$('btn-admin-content-bundle-create').onclick = () => run('admin/content-bundles/create', async () => {
const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/content-bundles', {
code: $('admin-content-bundle-code').value,
name: $('admin-content-bundle-name').value,
status: 'active',
isDefault: true,
entryUrl: trimmedOrUndefined($('admin-content-entry-url').value),
assetRootUrl: trimmedOrUndefined($('admin-content-asset-root-url').value),
metadata: parseJSONObjectOrUndefined($('admin-content-metadata-json').value, 'Content Metadata JSON') || {}
}, true);
$('admin-content-bundle-id').value = result.data.id || $('admin-content-bundle-id').value;
$('admin-release-content-bundle-id').value = result.data.id || $('admin-release-content-bundle-id').value;
$('config-content-bundle-id').value = result.data.id || $('config-content-bundle-id').value;
persistState();
return result;
});
$('btn-admin-content-bundle-import').onclick = () => run('admin/content-bundles/import', async () => {
const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/content-bundles/import', {
title: $('admin-content-import-title').value,
bundleType: $('admin-content-import-bundle-type').value,
sourceType: $('admin-content-import-source-type').value,
manifestUrl: $('admin-content-import-manifest-url').value,
version: $('admin-content-import-version').value,
status: 'active',
isDefault: true,
assetManifest: parseJSONObjectOrUndefined($('admin-content-import-asset-manifest-json').value, 'Content Asset Manifest JSON')
}, true);
$('admin-content-bundle-id').value = result.data.id || $('admin-content-bundle-id').value;
$('admin-content-bundle-name').value = result.data.name || $('admin-content-bundle-name').value;
$('admin-release-content-bundle-id').value = result.data.id || $('admin-release-content-bundle-id').value;
$('config-content-bundle-id').value = result.data.id || $('config-content-bundle-id').value;
persistState();
return result;
});
$('btn-admin-content-bundle-detail').onclick = () => run('admin/content-bundles/detail', async () => {
const result = await request('GET', '/admin/content-bundles/' + encodeURIComponent($('admin-content-bundle-id').value), undefined, true);
if (result.data) {
$('admin-content-bundle-id').value = result.data.id || $('admin-content-bundle-id').value;
$('admin-release-content-bundle-id').value = result.data.id || $('admin-release-content-bundle-id').value;
$('config-content-bundle-id').value = result.data.id || $('config-content-bundle-id').value;
}
persistState();
return result;
});
$('btn-admin-event-source').onclick = () => run('admin/events/source', async () => {
const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/source', {
map: {
mapId: $('admin-map-id').value,
versionId: $('admin-map-version-id').value
},
playfield: {
playfieldId: $('admin-playfield-id').value,
versionId: $('admin-playfield-version-id').value
},
resourcePack: $('admin-pack-id').value && $('admin-pack-version-id').value ? {
resourcePackId: $('admin-pack-id').value,
versionId: $('admin-pack-version-id').value
} : undefined,
gameModeCode: $('admin-game-mode-code').value,
routeCode: trimmedOrUndefined($('admin-route-code').value),
overrides: parseJSONObjectOrUndefined($('admin-overrides-json').value, 'Overrides JSON'),
notes: trimmedOrUndefined($('admin-source-notes').value)
}, true);
state.sourceId = result.data.id || state.sourceId;
syncState();
return result;
});
$('btn-admin-pipeline').onclick = () => run('admin/events/pipeline', async () => {
const result = await request('GET', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/pipeline?limit=20', undefined, true);
if (result.data) {
if (result.data.currentRelease && result.data.currentRelease.id) {
state.releaseId = result.data.currentRelease.id;
$('admin-pipeline-release-id').value = result.data.currentRelease.id;
}
if (result.data.currentRelease && result.data.currentRelease.runtime && result.data.currentRelease.runtime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = result.data.currentRelease.runtime.runtimeBindingId;
}
if (result.data.currentRelease && result.data.currentRelease.presentation && result.data.currentRelease.presentation.presentationId) {
$('admin-release-presentation-id').value = result.data.currentRelease.presentation.presentationId;
$('admin-presentation-id').value = result.data.currentRelease.presentation.presentationId;
$('config-presentation-id').value = result.data.currentRelease.presentation.presentationId;
}
if (result.data.currentRelease && result.data.currentRelease.contentBundle && result.data.currentRelease.contentBundle.contentBundleId) {
$('admin-release-content-bundle-id').value = result.data.currentRelease.contentBundle.contentBundleId;
$('admin-content-bundle-id').value = result.data.currentRelease.contentBundle.contentBundleId;
$('config-content-bundle-id').value = result.data.currentRelease.contentBundle.contentBundleId;
}
if (result.data.sources && result.data.sources[0] && result.data.sources[0].id) {
state.sourceId = result.data.sources[0].id;
}
if (result.data.builds && result.data.builds[0] && result.data.builds[0].id) {
state.buildId = result.data.builds[0].id;
}
}
syncState();
return result;
});
$('btn-admin-release-detail').onclick = () => run('admin/releases/detail', async () => {
const result = await request('GET', '/admin/releases/' + encodeURIComponent($('admin-pipeline-release-id').value || state.releaseId), undefined, true);
if (result.data) {
state.releaseId = result.data.id || state.releaseId;
if (result.data.runtime && result.data.runtime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = result.data.runtime.runtimeBindingId;
}
if (result.data.presentation && result.data.presentation.presentationId) {
$('admin-release-presentation-id').value = result.data.presentation.presentationId;
$('admin-presentation-id').value = result.data.presentation.presentationId;
$('config-presentation-id').value = result.data.presentation.presentationId;
}
if (result.data.contentBundle && result.data.contentBundle.contentBundleId) {
$('admin-release-content-bundle-id').value = result.data.contentBundle.contentBundleId;
$('admin-content-bundle-id').value = result.data.contentBundle.contentBundleId;
$('config-content-bundle-id').value = result.data.contentBundle.contentBundleId;
}
setDefaultPublishExpectation(result.data);
}
syncState();
return result;
});
$('btn-admin-bind-runtime').onclick = () => run('admin/releases/bind-runtime', async () => {
const runtimeBindingId = $('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value;
const result = await request('POST', '/admin/releases/' + encodeURIComponent($('admin-pipeline-release-id').value || state.releaseId) + '/runtime-binding', {
runtimeBindingId: runtimeBindingId
}, true);
if (result.data) {
state.releaseId = result.data.id || state.releaseId;
if (result.data.runtime && result.data.runtime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = result.data.runtime.runtimeBindingId;
}
}
syncState();
return result;
});
$('btn-admin-event-defaults').onclick = () => run('admin/events/defaults', async () => {
const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/defaults', {
presentationId: trimmedOrUndefined($('admin-release-presentation-id').value),
contentBundleId: trimmedOrUndefined($('admin-release-content-bundle-id').value),
runtimeBindingId: trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value)
}, true);
if (result.data && result.data.currentPresentation && result.data.currentPresentation.presentationId) {
$('admin-presentation-id').value = result.data.currentPresentation.presentationId;
$('admin-release-presentation-id').value = result.data.currentPresentation.presentationId;
$('config-presentation-id').value = result.data.currentPresentation.presentationId;
}
if (result.data && result.data.currentContentBundle && result.data.currentContentBundle.contentBundleId) {
$('admin-content-bundle-id').value = result.data.currentContentBundle.contentBundleId;
$('admin-release-content-bundle-id').value = result.data.currentContentBundle.contentBundleId;
$('config-content-bundle-id').value = result.data.currentContentBundle.contentBundleId;
}
if (result.data && result.data.currentRuntime && result.data.currentRuntime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId;
$('prod-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId;
}
persistState();
return result;
});
$('btn-admin-build-source').onclick = () => run('admin/sources/build', async () => {
const result = await request('POST', '/admin/sources/' + encodeURIComponent($('admin-pipeline-source-id').value || state.sourceId) + '/build', undefined, true);
state.buildId = result.data.id || state.buildId;
state.sourceId = result.data.sourceId || state.sourceId;
syncState();
return result;
});
$('btn-admin-build-detail').onclick = () => run('admin/builds/detail', async () => {
const result = await request('GET', '/admin/builds/' + encodeURIComponent($('admin-pipeline-build-id').value || state.buildId), undefined, true);
state.buildId = result.data.id || state.buildId;
state.sourceId = result.data.sourceId || state.sourceId;
syncState();
return result;
});
$('btn-admin-build-publish').onclick = () => run('admin/builds/publish', async () => {
const result = await request('POST', '/admin/builds/' + encodeURIComponent($('admin-pipeline-build-id').value || state.buildId) + '/publish', {
runtimeBindingId: trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value),
presentationId: trimmedOrUndefined($('admin-release-presentation-id').value),
contentBundleId: trimmedOrUndefined($('admin-release-content-bundle-id').value)
}, true);
state.releaseId = result.data.release.releaseId || state.releaseId;
if (result.data.runtime && result.data.runtime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = result.data.runtime.runtimeBindingId;
}
if (result.data.presentation && result.data.presentation.presentationId) {
$('admin-release-presentation-id').value = result.data.presentation.presentationId;
$('admin-presentation-id').value = result.data.presentation.presentationId;
$('config-presentation-id').value = result.data.presentation.presentationId;
}
if (result.data.contentBundle && result.data.contentBundle.contentBundleId) {
$('admin-release-content-bundle-id').value = result.data.contentBundle.contentBundleId;
$('admin-content-bundle-id').value = result.data.contentBundle.contentBundleId;
$('config-content-bundle-id').value = result.data.contentBundle.contentBundleId;
}
syncState();
return result;
});
$('btn-admin-rollback').onclick = () => run('admin/events/rollback', async () => {
const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/rollback', {
releaseId: $('admin-rollback-release-id').value
}, true);
state.releaseId = result.data.id || state.releaseId;
syncState();
return result;
});
$('btn-copy-curl').onclick = async () => {
if (!state.lastCurl) {
setStatus('error: no curl to copy', true);
return;
}
try {
await navigator.clipboard.writeText(state.lastCurl);
setStatus('ok: curl copied');
} catch (_) {
setStatus('error: clipboard unavailable', true);
}
};
$('btn-clear-history').onclick = () => {
localStorage.removeItem(HISTORY_KEY);
renderHistory();
setStatus('ok: history cleared');
};
$('btn-scenario-save').onclick = saveCurrentScenario;
$('btn-scenario-load').onclick = loadSelectedScenario;
$('btn-scenario-delete').onclick = deleteSelectedScenario;
$('btn-scenario-export').onclick = exportSelectedScenario;
$('btn-scenario-import').onclick = importScenarioFromJSON;
$('btn-flow-home').onclick = () => run('flow-home', async () => {
await request('POST', '/dev/bootstrap-demo');
const login = await request('POST', '/auth/login/wechat-mini', {
code: $('wechat-code').value,
clientType: 'wechat',
deviceKey: $('wechat-device').value
});
state.accessToken = login.data.tokens.accessToken;
state.refreshToken = login.data.tokens.refreshToken;
return await request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true);
});
$('btn-bootstrap').onclick = () => run('bootstrap-demo', async () => {
const result = await request('POST', '/dev/bootstrap-demo');
state.sourceId = result.data.sourceId || '';
state.buildId = result.data.buildId || '';
state.releaseId = result.data.releaseId || state.releaseId || '';
if (result.data.releaseId) {
$('event-release-id').value = result.data.releaseId;
}
$('prod-runtime-event-id').value = result.data.eventId || $('prod-runtime-event-id').value;
$('prod-place-id').value = result.data.placeId || $('prod-place-id').value;
$('prod-map-asset-id').value = result.data.mapAssetId || $('prod-map-asset-id').value;
$('prod-tile-release-id').value = result.data.tileReleaseId || $('prod-tile-release-id').value;
$('prod-course-source-id').value = result.data.courseSourceId || $('prod-course-source-id').value;
$('prod-course-set-id').value = result.data.courseSetId || $('prod-course-set-id').value;
$('prod-course-variant-id').value = result.data.courseVariantId || $('prod-course-variant-id').value;
$('prod-runtime-binding-id').value = result.data.runtimeBindingId || $('prod-runtime-binding-id').value;
return result;
});
$('btn-use-variant-manual-demo').onclick = () => run('use-variant-manual-demo', async () => {
const result = await request('POST', '/dev/bootstrap-demo');
$('entry-channel-code').value = 'mini-demo';
$('entry-channel-type').value = 'wechat_mini';
$('event-id').value = result.data.variantManualEventId || 'evt_demo_variant_manual_001';
$('event-release-id').value = result.data.variantManualReleaseId || 'rel_demo_variant_manual_001';
$('event-variant-id').value = 'variant_b';
localStorage.setItem(MODE_KEY, 'frontend');
syncWorkbenchMode();
writeLog('variant-manual-demo-ready', {
eventId: $('event-id').value,
releaseId: $('event-release-id').value,
variantId: $('event-variant-id').value
});
setStatus('ok: manual variant demo loaded');
return result;
});
$('btn-flow-launch').onclick = () => run('flow-launch', async () => {
await request('POST', '/dev/bootstrap-demo');
const smsSend = await request('POST', '/auth/sms/send', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value,
scene: 'login'
});
if (smsSend.data && smsSend.data.devCode) {
$('sms-code').value = smsSend.data.devCode;
}
const login = await request('POST', '/auth/login/sms', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
code: $('sms-code').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value
});
state.accessToken = login.data.tokens.accessToken;
state.refreshToken = login.data.tokens.refreshToken;
const launch = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
releaseId: $('event-release-id').value,
variantId: trimmedOrUndefined($('event-variant-id').value),
clientType: $('sms-client-type').value,
deviceKey: $('event-device').value
}, true);
state.sessionId = launch.data.launch.business.sessionId;
state.sessionToken = launch.data.launch.business.sessionToken;
return await request('POST', '/sessions/' + encodeURIComponent(state.sessionId) + '/start', {
sessionToken: state.sessionToken
});
});
$('btn-flow-finish').onclick = () => run('flow-finish', async () => {
return await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
sessionToken: $('session-token').value,
status: $('finish-status').value,
summary: buildFinishSummary()
});
});
$('btn-flow-result').onclick = () => run('flow-result', async () => {
await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
sessionToken: $('session-token').value,
status: $('finish-status').value,
summary: buildFinishSummary()
});
return await request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true);
});
$('btn-flow-admin-default-publish').onclick = () => run('flow-admin-default-publish', async () => {
return await runAdminDefaultPublishFlow({ ensureRuntime: false });
});
$('btn-flow-admin-runtime-publish').onclick = () => run('flow-admin-runtime-publish', async () => {
return await runAdminDefaultPublishFlow({ ensureRuntime: true });
});
[
'sms-client-type', 'sms-scene', 'sms-mobile', 'sms-device', 'sms-country', 'sms-code',
'wechat-code', 'wechat-device', 'local-config-file', 'config-event-id', 'config-runtime-binding-id', 'config-presentation-id', 'config-content-bundle-id', 'entry-channel-code', 'entry-channel-type',
'event-id', 'event-release-id', 'event-device', 'finish-status', 'finish-duration', 'finish-score',
'finish-controls-done', 'finish-controls-total', 'finish-distance', 'finish-speed',
'finish-heart-rate', 'prod-place-code', 'prod-place-name', 'prod-place-id', 'prod-place-status',
'prod-place-region', 'prod-place-cover-url', 'prod-map-asset-code', 'prod-map-asset-name',
'prod-map-asset-id', 'prod-map-asset-legacy-map-id', 'prod-map-asset-type', 'prod-map-asset-status',
'prod-tile-release-id', 'prod-tile-legacy-version-id', 'prod-tile-version-code', 'prod-tile-status',
'prod-tile-base-url', 'prod-tile-meta-url', 'prod-course-source-id',
'prod-course-source-legacy-playfield-id', 'prod-course-source-legacy-version-id',
'prod-course-source-type', 'prod-course-source-file-url', 'prod-course-source-status',
'prod-course-set-code', 'prod-course-set-name', 'prod-course-set-id', 'prod-course-mode',
'prod-course-set-status', 'prod-course-variant-id', 'prod-course-variant-name',
'prod-course-variant-route-code', 'prod-course-variant-status', 'prod-course-variant-control-count',
'prod-runtime-binding-id', 'prod-runtime-event-id', 'prod-runtime-binding-status', 'prod-runtime-notes',
'admin-map-code', 'admin-map-name', 'admin-map-id', 'admin-map-version-id',
'admin-map-version-code', 'admin-map-status', 'admin-mapmeta-url', 'admin-tiles-root-url',
'admin-playfield-code', 'admin-playfield-name', 'admin-playfield-id', 'admin-playfield-version-id',
'admin-playfield-kind', 'admin-playfield-status', 'admin-playfield-version-code', 'admin-playfield-source-type',
'admin-playfield-source-url', 'admin-playfield-control-count', 'admin-pack-code', 'admin-pack-name',
'admin-pack-id', 'admin-pack-version-id', 'admin-pack-version-code', 'admin-pack-status',
'admin-pack-content-url', 'admin-pack-audio-url', 'admin-pack-theme-code', 'admin-published-asset-root',
'admin-tenant-code', 'admin-event-status', 'admin-event-ref-id', 'admin-event-slug', 'admin-event-name',
'admin-event-summary', 'admin-game-mode-code', 'admin-route-code', 'admin-source-notes',
'admin-overrides-json', 'admin-presentation-id', 'admin-presentation-code', 'admin-presentation-name',
'admin-presentation-type', 'admin-presentation-schema-json', 'admin-presentation-import-title',
'admin-presentation-import-template-key', 'admin-presentation-import-source-type',
'admin-presentation-import-version', 'admin-presentation-import-schema-url', 'admin-content-bundle-id',
'admin-content-bundle-code', 'admin-content-bundle-name', 'admin-content-entry-url',
'admin-content-asset-root-url', 'admin-content-metadata-json', 'admin-content-import-title',
'admin-content-import-bundle-type', 'admin-content-import-source-type', 'admin-content-import-version',
'admin-content-import-manifest-url', 'admin-content-import-asset-manifest-json', 'admin-pipeline-source-id',
'admin-pipeline-build-id', 'admin-pipeline-release-id', 'admin-release-runtime-binding-id',
'admin-release-presentation-id', 'admin-release-content-bundle-id', 'admin-rollback-release-id'
].forEach(function(id) {
$(id).addEventListener('change', persistState);
$(id).addEventListener('input', persistState);
});
$('api-filter').addEventListener('input', applyAPIFilter);
restoreState();
syncWorkbenchMode();
syncState();
renderHistory();
renderScenarioOptions();
applyAPIFilter();
syncAPICounts();
writeLog('workbench-ready', { ok: true, hint: 'Use Bootstrap Demo first on a fresh database.' });
</script>
</body>
</html>`