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

1682 lines
85 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"
type OpsWorkbenchHandler struct{}
func NewOpsWorkbenchHandler() *OpsWorkbenchHandler {
return &OpsWorkbenchHandler{}
}
func (h *OpsWorkbenchHandler) Get(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(opsWorkbenchHTML))
}
const opsWorkbenchHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CMR 运维后台</title>
<style>
:root {
--bg: #071218;
--panel: #12212c;
--panel-2: #172a36;
--line: #284355;
--text: #ecf6ff;
--muted: #92acbf;
--brand: #52d6b3;
--warn: #ffd36a;
--danger: #ff7b7b;
--accent: #4fa3ff;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(82,214,179,.12), transparent 28%),
radial-gradient(circle at top right, rgba(79,163,255,.12), transparent 24%),
var(--bg);
}
.shell {
width: 100%;
max-width: none;
margin: 0;
padding: 24px 28px 32px;
display: grid;
grid-template-columns: 280px minmax(0,1fr) 400px;
gap: 20px;
align-items: start;
}
.sidebar, .aside, .panel {
background: rgba(18,33,44,.92);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 16px 60px rgba(0,0,0,.22);
}
.sidebar, .aside {
position: sticky;
top: 20px;
padding: 18px;
display: grid;
gap: 18px;
align-content: start;
}
.sidebar { background: linear-gradient(180deg, rgba(13,27,36,.96), rgba(18,33,44,.94)); }
.aside { background: linear-gradient(180deg, rgba(18,33,44,.94), rgba(13,23,31,.96)); }
.content { display: grid; gap: 20px; min-width: 0; }
.panel { padding: 20px; display: grid; gap: 16px; align-content: start; }
.hero {
background: linear-gradient(135deg, rgba(82,214,179,.15), rgba(79,163,255,.10));
padding: 22px 24px;
}
.brand h1, .hero h2, .panel h3 { margin: 0; line-height: 1.2; }
.brand h1 { font-size: 26px; }
.hero h2 { font-size: 30px; }
.panel h3 { font-size: 24px; }
.brand p, .hero p, .panel p, .hint { margin: 0; color: var(--muted); line-height: 1.7; }
.tag, .eyebrow {
display: inline-flex;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.tag { background: rgba(82,214,179,.18); color: var(--brand); }
.eyebrow { background: rgba(79,163,255,.16); color: var(--accent); }
.nav-group { display: grid; gap: 10px; }
.nav-title {
color: var(--muted);
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
padding: 0 4px;
}
.nav-link {
display: grid;
gap: 4px;
width: 100%;
text-align: left;
color: var(--text);
padding: 13px 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,.02);
cursor: pointer;
}
.nav-link.active {
border-color: rgba(82,214,179,.45);
background: rgba(82,214,179,.10);
box-shadow: inset 0 0 0 1px rgba(82,214,179,.16);
}
.nav-link span { color: var(--muted); font-size: 13px; line-height: 1.45; }
.hero-points, .metrics, .grid-2, .grid-3, .split { display: grid; gap: 14px; }
.toolbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: end; justify-content: space-between; }
.toolbar .actions { justify-content: flex-end; }
.hero-points, .metrics { grid-template-columns: repeat(3, minmax(0,1fr)); }
.metrics { grid-template-columns: repeat(4, minmax(0,1fr)); }
.grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.split { grid-template-columns: 1.05fr .95fr; align-items: start; }
.hero-card, .metric, .item, .token-box, .statusbox, .logbox, .list {
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,.03);
}
.hero-card, .metric, .item { padding: 14px; display: grid; gap: 6px; }
.item.selectable { cursor: pointer; transition: border-color .18s ease, background .18s ease, transform .18s ease; }
.item.selectable:hover { border-color: rgba(82,214,179,.35); background: rgba(82,214,179,.08); transform: translateY(-1px); }
.item.active { border-color: rgba(82,214,179,.45); background: rgba(82,214,179,.10); box-shadow: inset 0 0 0 1px rgba(82,214,179,.14); }
.metric strong { font-size: 28px; line-height: 1; }
.metric span, .item .meta { color: var(--muted); font-size: 13px; line-height: 1.5; }
.field { display: grid; gap: 8px; min-width: 0; }
.field label { color: var(--muted); font-size: 14px; }
input, textarea, select {
width: 100%;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
padding: 13px 14px;
font: inherit;
}
textarea { min-height: 110px; resize: vertical; line-height: 1.55; }
.tall textarea { min-height: 190px; }
.actions { display: flex; gap: 12px; flex-wrap: wrap; }
button {
border: 0;
border-radius: 16px;
padding: 13px 18px;
font: inherit;
font-weight: 700;
cursor: pointer;
background: var(--brand);
color: #03231d;
}
button.nav-link {
background: rgba(255,255,255,.02);
color: var(--text);
border: 1px solid var(--line);
padding: 13px 14px;
}
button.nav-link.active {
border-color: rgba(82,214,179,.45);
background: rgba(82,214,179,.10);
color: var(--text);
}
button.secondary { background: var(--warn); color: #3f2d00; }
button.ghost { background: transparent; border: 1px solid var(--line); color: var(--text); }
.token-box, .statusbox, .logbox {
padding: 14px;
white-space: pre-wrap;
word-break: break-word;
font-family: "Consolas", "SFMono-Regular", monospace;
}
.statusbox { min-height: 84px; }
.logbox { min-height: 220px; max-height: 420px; overflow: auto; }
.list { padding: 10px; display: grid; gap: 10px; max-height: 320px; overflow: auto; }
.ops-view { display: none; }
.ops-view.active { display: grid; }
[hidden] { display: none !important; }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(3, 10, 14, .68);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
padding: 24px;
z-index: 30;
}
.modal-card {
width: min(1180px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
background: linear-gradient(180deg, rgba(18,33,44,.98), rgba(12,23,31,.98));
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 20px 80px rgba(0,0,0,.35);
padding: 22px;
display: grid;
gap: 16px;
}
.status-ok { color: var(--brand); }
.status-error { color: var(--danger); }
@media (max-width: 1560px) {
.shell { grid-template-columns: 250px minmax(0,1fr) 360px; padding: 22px 24px 28px; }
}
@media (max-width: 1360px) {
.shell { grid-template-columns: 220px minmax(0,1fr); }
.aside { grid-column: 2; position: static; }
}
@media (max-width: 1120px) {
.shell { grid-template-columns: 1fr; }
.sidebar, .aside { position: static; }
.hero-points, .metrics, .grid-2, .grid-3, .split { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<span class="tag">运维后台第一期</span>
<h1>资源录入与发布中心</h1>
<p>这里不做联调回归,只做资源纳管、活动绑定和发布回滚。调试问题继续去 <code>/dev/workbench</code>。</p>
</div>
<div class="nav-group">
<div class="nav-title">主要流程</div>
<button class="nav-link active" type="button" data-view="overview"><strong>资源总览</strong><span>先看已有资源、活动和当前发布状态。</span></button>
<button class="nav-link" type="button" data-view="maps"><strong>地图 / 地点管理</strong><span>先管地图列表、地点、当前瓦片版本和地图预览。</span></button>
<button class="nav-link" type="button" data-view="courses"><strong>路线资源管理</strong><span>围绕地图管理 KML、路线组、默认路线和预览。</span></button>
<button class="nav-link" type="button" data-view="events"><strong>活动管理</strong><span>活动列表、基础信息、状态和当前发布概况。</span></button>
<button class="nav-link" type="button" data-view="compose"><strong>活动编排</strong><span>绑定 runtime、展示定义、内容包准备发布。</span></button>
<button class="nav-link" type="button" data-view="publish"><strong>发布中心</strong><span>查看 pipeline、build、publish、rollback。</span></button>
</div>
<div class="nav-group">
<div class="nav-title">辅助入口</div>
<button class="nav-link" type="button" data-view="ingest"><strong>资源录入</strong><span>上传文件或登记正式链接,统一纳管资源对象。</span></button>
</div>
<div class="nav-group">
<div class="nav-title">调试工具</div>
<a class="nav-link" href="/dev/workbench"><strong>返回调试工作台</strong><span>去一键回归、配置摘要、前端日志面板。</span></a>
</div>
</aside>
<main class="content">
<section class="panel hero">
<h2>先录资源,再绑活动,最后发布</h2>
<p>运维平台第一版的目标很单一:把正式资源稳定录入系统,沉淀成资源对象,再通过统一发布链形成可追溯的 release。这里不混调试按钮也不直接暴露玩家链路。</p>
<div class="hero-points">
<div class="hero-card"><strong>资源录入</strong><span>文件上传和外链登记统一收口,不再依赖手工改代码或散脚本。</span></div>
<div class="hero-card"><strong>活动绑定</strong><span>活动侧只绑定默认 runtime、presentation、content bundle 三元组。</span></div>
<div class="hero-card"><strong>发布中心</strong><span>继续走统一 build / publish / rollback不开第二套发布逻辑。</span></div>
</div>
</section>
<section class="panel ops-view active" id="overview">
<div class="eyebrow">资源总览</div>
<h3>先看关键统计,再看当前运行时信息</h3>
<p>资源总览先回答两个问题:系统里现在有多少正式对象;你当前选中的活动此刻绑定了哪套 release / runtime / 展示定义 / 内容包。</p>
<input id="managed-place-id" type="hidden" value="">
<div class="split">
<div class="panel" style="padding:18px;">
<div class="eyebrow">资源与路线统计</div>
<div class="metrics">
<div class="metric"><strong id="metric-places">0</strong><span>地点</span></div>
<div class="metric"><strong id="metric-map-assets">0</strong><span>地图</span></div>
<div class="metric"><strong id="metric-tile-releases">0</strong><span>瓦片版本</span></div>
<div class="metric"><strong id="metric-assets">0</strong><span>受管资源</span></div>
<div class="metric"><strong id="metric-course-sets">0</strong><span>路线组</span></div>
<div class="metric"><strong id="metric-course-variants">0</strong><span>路线变体</span></div>
<div class="metric"><strong id="metric-runtime-bindings">0</strong><span>运行绑定</span></div>
<div class="metric"><strong id="metric-config-sources">0</strong><span>配置源</span></div>
</div>
</div>
<div class="panel" style="padding:18px;">
<div class="eyebrow">活动与发布统计</div>
<div class="metrics">
<div class="metric"><strong id="metric-events">0</strong><span>活动数</span></div>
<div class="metric"><strong id="metric-default-events">0</strong><span>默认体验活动</span></div>
<div class="metric"><strong id="metric-published-events">0</strong><span>已发布活动</span></div>
<div class="metric"><strong id="metric-pipeline-releases">0</strong><span>发布版本</span></div>
<div class="metric"><strong id="metric-presentations">0</strong><span>展示定义</span></div>
<div class="metric"><strong id="metric-content-bundles">0</strong><span>内容包</span></div>
<div class="metric"><strong id="metric-ops-users">0</strong><span>运维账号</span></div>
</div>
</div>
</div>
<div class="split">
<div class="panel" style="padding:18px;">
<div class="eyebrow">当前运行时信息</div>
<div class="field"><label>当前活动</label><div class="token-box" id="overview-current-event">-</div></div>
<div class="field"><label>当前发布版本</label><div class="token-box" id="overview-current-release">-</div></div>
<div class="field"><label>当前 runtime</label><div class="token-box" id="overview-current-runtime">-</div></div>
<div class="field"><label>当前展示定义</label><div class="token-box" id="overview-current-presentation">-</div></div>
<div class="field"><label>当前内容包</label><div class="token-box" id="overview-current-content-bundle">-</div></div>
</div>
<div class="panel" style="padding:18px;">
<div class="eyebrow">操作提示</div>
<div class="item"><strong>1. 先看资源总览</strong><div class="meta">确认地点、地图、路线组、活动、已发布数量是否符合预期。</div></div>
<div class="item"><strong>2. 再选主流程</strong><div class="meta">地图 / 地点管理 -> 路线资源管理 -> 活动管理 / 活动编排 -> 发布中心。</div></div>
<div class="item"><strong>3. 关联活动只看摘要</strong><div class="meta">地图页只看数量和跳转,活动详情统一放到“活动管理”。</div></div>
</div>
</div>
<div class="grid-3">
<div class="field"><label>运维手机号</label><input id="ops-mobile" value="13800138000"></div>
<div class="field"><label>短信验证码</label><input id="ops-code" value=""></div>
<div class="field"><label>运维显示名</label><input id="ops-display-name" value="开发运维"></div>
<div class="field"><label>国家区号</label><input id="ops-country-code" value="86"></div>
<div class="field"><label>设备标识</label><input id="ops-device-key" value="ops-console-001"></div>
<div class="field"><label>当前角色</label><input id="ops-role-code" value="开发态自动放行" readonly></div>
<div class="field"><label>活动 ID总览 / pipeline</label><input id="event-id" value="evt_demo_variant_manual_001"></div>
<div class="field"><label>当前发布版本 ID</label><input id="release-id" value=""></div>
<div class="field"><label>当前构建 ID</label><input id="build-id" value=""></div>
</div>
<div class="actions">
<button id="btn-send-ops-code">发送验证码</button>
<button id="btn-register-ops" class="secondary">注册运维账号</button>
<button id="btn-login-ops" class="secondary">手机号登录</button>
<button class="secondary" id="btn-refresh-overview">刷新总览</button>
<button class="ghost" id="btn-clear-token">清空令牌</button>
</div>
<div class="hint">开发环境默认免登录放行,生产环境请使用手机号验证码注册/登录的独立运维账号。运维账号和前端玩家账号完全分离。</div>
</section>
<section class="panel ops-view" id="maps">
<div class="eyebrow">地图 / 地点管理</div>
<h3>先进入地图列表,再做新增、编辑和预览</h3>
<p>地点是地图的归属容器,不是主入口。一个地点可挂多张地图,一张地图只属于一个地点。关联活动在这里先只看数量和摘要,详情统一去“活动管理”。</p>
<div class="toolbar">
<div class="grid-2" style="min-width:min(100%,720px);">
<div class="field"><label>地图关键字</label><input id="map-search" value="" placeholder="按地图名称、编码、地点筛选"></div>
<div class="field"><label>地点关键字</label><input id="place-search" value="" placeholder="按地点名称、省市、编码筛选"></div>
</div>
<div class="actions">
<button id="btn-open-create-map">添加地图</button>
<button class="ghost" id="btn-open-create-place">添加地点</button>
<button class="secondary" id="btn-refresh-map-area">刷新地图区</button>
</div>
</div>
<div class="panel" style="padding:18px;">
<div class="eyebrow">地图列表</div>
<div class="hint">默认只看地图列表。点一张地图,再进入地图详情;新增地图和新增地点都走独立弹出层。</div>
<div class="list" id="map-library-list"><div class="item"><strong>暂无地图</strong><div class="meta">打开页面后会自动拉取地图列表,也可以手动点“刷新地图区”。</div></div></div>
</div>
<div hidden>
<div id="place-list"></div>
<div id="map-list"></div>
</div>
<div class="modal-backdrop" id="map-detail-modal" hidden>
<div class="modal-card">
<div class="eyebrow">地图详情</div>
<h3 style="margin:0;">当前地图详情 / 预览</h3>
<div class="split">
<div class="panel" style="padding:18px;">
<div class="field"><label>当前地点</label><div class="token-box" id="map-preview-place">-</div></div>
<div class="field"><label>当前地图</label><div class="token-box" id="map-preview-map">-</div></div>
<div class="field"><label>当前瓦片版本</label><div class="token-box" id="map-preview-tile-version">-</div></div>
<div class="field"><label>Tile Base URL</label><div class="token-box" id="map-preview-tile-base">-</div></div>
<div class="field"><label>Meta URL</label><div class="token-box" id="map-preview-meta">-</div></div>
</div>
<div class="panel" style="padding:18px;">
<div class="field"><label>默认活动数量</label><div class="token-box" id="map-preview-default-count">0</div></div>
<div class="field"><label>默认活动摘要</label><div class="token-box" id="map-preview-default-events">-</div></div>
<div class="field"><label>关联活动数量</label><div class="token-box" id="map-preview-linked-count">0</div></div>
<div class="field"><label>关联活动摘要</label><div class="token-box" id="map-preview-linked-summary">当前未关联活动</div></div>
</div>
</div>
<div class="actions">
<button id="btn-open-map-editor-from-detail">编辑地图</button>
<button class="secondary" id="btn-open-map-tile-from-detail">导入瓦片版本</button>
<button class="ghost" id="btn-open-events-view">前往活动管理</button>
<button class="ghost" id="btn-close-map-detail">关闭详情</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="map-editor-panel" hidden>
<div class="modal-card">
<div class="eyebrow">添加 / 编辑地图</div>
<div class="grid-2">
<div class="field"><label>管理地图 ID</label><input id="managed-map-id" value=""></div>
<div class="field"><label>所属地点</label><select id="map-place-select"><option value="">请先录入地点</option></select></div>
<div class="field"><label>地图编码</label><input id="map-code" value="lxcb-map-001"></div>
<div class="field"><label>地图名称</label><input id="map-name" value="领秀城公园底图"></div>
<div class="field"><label>地图类型</label><input id="map-type" value="raster"></div>
<div class="field"><label>地图封面 URL</label><input id="map-cover-url" value=""></div>
<div class="field"><label>地图摘要</label><input id="map-summary" value="领秀城公园地图资源,包含默认体验活动与当前瓦片版本。"></div>
</div>
<div class="panel" style="padding:18px;">
<div class="eyebrow">上传 / 导入首个瓦片版本</div>
<div class="grid-3">
<div class="field"><label>瓦片版本号</label><input id="tile-version" value="2026-04-07"></div>
<div class="field"><label>瓦片根地址</label><input id="tile-base-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/"></div>
<div class="field"><label>元数据地址</label><input id="tile-meta-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json"></div>
</div>
</div>
<div class="actions">
<button id="btn-create-map-asset">新增地图</button>
<button class="secondary" id="btn-update-map-asset">保存地图修改</button>
<button class="secondary" id="btn-import-tile">导入瓦片版本</button>
<button class="ghost" id="btn-get-map-asset">重新读取地图详情</button>
<button class="ghost" id="btn-close-map-editor">关闭</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="place-editor-panel" hidden>
<div class="modal-card">
<div class="eyebrow">添加 / 编辑地点</div>
<div class="grid-2">
<div class="field"><label>管理地点 ID</label><input id="place-manage-id" value=""></div>
<div class="field"><label>地点编码</label><input id="place-code" value="lxcb-001"></div>
<div class="field"><label>地点名称</label><input id="place-name" value="领秀城公园"></div>
<div class="field"><label>省份</label><select id="place-province"><option value="">加载中...</option></select></div>
<div class="field"><label>城市</label><select id="place-city"><option value="">请先选择省份</option></select></div>
<div class="field"><label>地点区域</label><input id="place-region" value="" readonly></div>
</div>
<div class="actions">
<button id="btn-create-place">保存地点</button>
<button class="ghost" id="btn-get-place">重新读取地点详情</button>
<button class="ghost" id="btn-close-place-editor">关闭</button>
</div>
</div>
</div>
</section>
<section class="panel ops-view" id="ingest">
<div class="eyebrow">资源录入</div>
<h3>统一纳管资源</h3>
<p>运维只需要关心“录一个资源”,不需要关心它最终是 OSS 上传还是已有外链。录完之后都落成统一资源对象。</p>
<div class="split">
<div class="panel" style="padding:18px;">
<div class="eyebrow">上传文件</div>
<p>适合 KML、schema、manifest、本地静态包。backend 负责上传 OSS 并纳管。</p>
<div class="grid-2">
<div class="field"><label>资源类型</label><select id="asset-type"><option value="kml">kml</option><option value="tiles">tiles</option><option value="presentation">presentation</option><option value="content_bundle">content_bundle</option><option value="static_bundle">static_bundle</option></select></div>
<div class="field"><label>资源编码</label><input id="asset-code" value="lxcb-route-pack-2026-04-07"></div>
<div class="field"><label>版本</label><input id="asset-version" value="2026-04-07"></div>
<div class="field"><label>标题</label><input id="asset-title" value="领秀城多赛道 KML 包"></div>
<div class="field"><label>状态</label><select id="asset-status"><option value="active">active</option><option value="draft">draft</option></select></div>
<div class="field"><label>对象目录(可选)</label><input id="asset-object-dir" value="gotomars/kml/lxcb-001/2026-04-07"></div>
<div class="field"><label>内容类型(可选)</label><input id="asset-content-type" value="application/vnd.google-earth.kml+xml"></div>
<div class="field"><label>上传文件</label><input id="asset-file" type="file"></div>
</div>
<div class="field"><label>Metadata JSON可选</label><textarea id="asset-metadata">{}</textarea></div>
<div class="actions"><button id="btn-upload-asset">上传并纳管资源</button></div>
</div>
<div class="panel" style="padding:18px;">
<div class="eyebrow">登记外链</div>
<p>适合正式 OSS / CDN 上已经存在的资源。登记后,活动侧和发布链统一只认受管资源对象。</p>
<div class="field"><label>外链 URL</label><input id="asset-public-url" value="https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route01.kml"></div>
<div class="actions">
<button class="secondary" id="btn-register-link">登记外链资源</button>
<button class="ghost" id="btn-list-assets">查看受管资源</button>
</div>
<div class="hint">建议优先纳管地图瓦片目录、KML、presentation schema、content bundle manifest。</div>
</div>
</div>
</section>
<section class="panel ops-view" id="courses">
<div class="eyebrow">KML / 赛道管理</div>
<h3>围绕当前地图管理 KML、赛道集和默认路线</h3>
<p>KML 不是独立漂在外面的资源。运维上应该先选地图,再导入一批 KML形成赛道集然后查看默认路线和预览数据。</p>
<div class="split">
<div class="panel" style="padding:18px;">
<div class="eyebrow">导入瓦片版本</div>
<div class="grid-2">
<div class="field"><label>当前地点编码</label><input value="复用上方“地图资源管理”的地点编码" readonly></div>
<div class="field"><label>当前地图编码</label><input value="复用上方“地图资源管理”的地图编码" readonly></div>
<div class="field"><label>瓦片版本号</label><input id="tile-version" value="2026-04-07"></div>
<div class="field"><label>瓦片根地址</label><input id="tile-base-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/"></div>
<div class="field"><label>元数据地址</label><input id="tile-meta-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json"></div>
</div>
<div class="actions"><button id="btn-import-tile">导入瓦片版本</button></div>
</div>
<div class="panel tall" style="padding:18px;">
<div class="eyebrow">批量导入 KML</div>
<div class="grid-2">
<div class="field"><label>Course Set Code</label><input id="course-set-code" value="lxcb-manual-2026-04-07"></div>
<div class="field"><label>Course Set Name</label><input id="course-set-name" value="领秀城公园多赛道 2026-04-07"></div>
<div class="field"><label>Mode</label><input id="course-mode" value="classic-sequential"></div>
<div class="field"><label>Default Route Code</label><input id="default-route-code" value="route-variant-d"></div>
</div>
<div class="field">
<label>KML Batch JSON</label>
<textarea id="routes-json">[
{"name":"路线 01","routeCode":"route-variant-a","fileUrl":"https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route01.kml","sourceType":"kml","controlCount":10,"status":"active"},
{"name":"路线 02","routeCode":"route-variant-b","fileUrl":"https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route02.kml","sourceType":"kml","controlCount":10,"status":"active"},
{"name":"路线 03","routeCode":"route-variant-c","fileUrl":"https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route03.kml","sourceType":"kml","controlCount":10,"status":"active"},
{"name":"路线 04","routeCode":"route-variant-d","fileUrl":"https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route04.kml","sourceType":"kml","controlCount":10,"status":"active"}
]</textarea>
</div>
<div class="actions"><button id="btn-import-kml-batch">批量导入 KML</button></div>
</div>
</div>
<div class="split">
<div class="panel" style="padding:18px;">
<div class="eyebrow">当前地图下赛道集</div>
<div class="list" id="course-set-list"><div class="item"><strong>暂无赛道集</strong><div class="meta">读取地图详情或完成一轮 KML 导入后,这里会显示当前地图的赛道集。</div></div></div>
</div>
<div class="panel" style="padding:18px;">
<div class="eyebrow">KML / 变体预览</div>
<div class="field"><label>当前赛道集</label><div class="token-box" id="course-preview-course-set">-</div></div>
<div class="field"><label>默认路线</label><div class="token-box" id="course-preview-default-route">-</div></div>
<div class="field"><label>路线数量</label><div class="token-box" id="course-preview-variant-count">0</div></div>
<div class="field"><label>路线摘要</label><div class="token-box" id="course-preview-variants">-</div></div>
</div>
</div>
</section>
<section class="panel ops-view" id="events">
<div class="eyebrow">活动管理</div>
<h3>先看活动列表和基础信息</h3>
<p>活动管理先处理业务壳:名称、状态、是否默认体验、是否出现在活动列表,以及当前发布概况。资源绑定与发布准备统一去“活动编排”。</p>
<div class="grid-3">
<div class="field"><label>租户编码</label><input id="event-tenant-code" value="tenant_demo"></div>
<div class="field"><label>活动标识 Slug</label><input id="event-slug" value="city-park-manual-variant"></div>
<div class="field"><label>活动名称</label><input id="event-display-name" value="领秀城公园多赛道挑战"></div>
<div class="field"><label>活动摘要</label><input id="event-summary" value="多赛道联调体验活动"></div>
<div class="field"><label>活动状态</label><input id="event-status" value="active"></div>
<div class="field"><label>活动 ID</label><input id="binding-event-id" value="evt_demo_variant_manual_001"></div>
</div>
<div class="actions">
<button class="secondary" id="btn-list-events">读取活动列表</button>
<button class="secondary" id="btn-create-event">新建活动</button>
<button class="secondary" id="btn-update-event">更新活动</button>
<button class="secondary" id="btn-get-event">读取活动</button>
<button class="ghost" id="btn-open-compose-view">前往活动编排</button>
</div>
<div class="split">
<div class="panel" style="padding:18px;">
<div class="eyebrow">活动列表</div>
<div class="list" id="event-list-main"><div class="item"><strong>暂无活动</strong><div class="meta">先点“读取活动列表”。</div></div></div>
</div>
<div class="panel" style="padding:18px;">
<div class="eyebrow">当前活动概况</div>
<div class="field"><label>当前发布版本</label><div class="token-box" id="event-preview-release">-</div></div>
<div class="field"><label>当前 runtime</label><div class="token-box" id="event-preview-runtime">-</div></div>
<div class="field"><label>当前展示定义</label><div class="token-box" id="event-preview-presentation">-</div></div>
<div class="field"><label>当前内容包</label><div class="token-box" id="event-preview-content-bundle">-</div></div>
</div>
</div>
</section>
<section class="panel ops-view" id="compose">
<div class="eyebrow">活动编排</div>
<h3>绑定运行对象、展示定义和内容包</h3>
<p>这里才进入发布前准备:给当前活动绑定 runtime、presentation、content bundle确认默认 active 三元组,然后交给发布中心 build / publish。</p>
<div class="grid-3">
<div class="field"><label>Presentation ID</label><input id="presentation-id" value=""></div>
<div class="field"><label>Content Bundle ID</label><input id="content-bundle-id" value=""></div>
<div class="field"><label>Runtime Binding ID</label><input id="runtime-binding-id" value=""></div>
<div class="field"><label>Presentation Title</label><input id="presentation-title" value="多赛道详情展示模板"></div>
<div class="field"><label>Presentation Template Key</label><input id="presentation-template-key" value="event.detail.multi-variant"></div>
<div class="field"><label>Presentation Schema URL</label><input id="presentation-schema-url" value="https://oss-mbh5.colormaprun.com/gotomars/presentations/event-detail-standard/v2026-04-07/schema.json"></div>
<div class="field"><label>Presentation Version</label><input id="presentation-version" value="v2026-04-07"></div>
<div class="field"><label>Bundle Title</label><input id="bundle-title" value="多赛道结果内容包"></div>
<div class="field"><label>Bundle Type</label><input id="bundle-type" value="result_media"></div>
<div class="field"><label>Bundle Manifest URL</label><input id="bundle-manifest-url" value="https://oss-mbh5.colormaprun.com/gotomars/content-bundles/result-media-manual/v2026-04-07/manifest.json"></div>
<div class="field"><label>Bundle Version</label><input id="bundle-version" value="v2026-04-07"></div>
</div>
<div class="actions">
<button id="btn-import-presentation">导入展示定义</button>
<button id="btn-import-bundle">导入内容包</button>
<button class="ghost" id="btn-save-defaults">保存活动默认绑定</button>
<button class="ghost" id="btn-open-publish-view">前往发布中心</button>
</div>
</section>
<section class="panel ops-view" id="publish">
<div class="eyebrow">发布中心</div>
<h3>统一 build / publish / rollback</h3>
<p>运维后台不造第二条发布链,仍然复用现在这套 source、build、release 流程。</p>
<div class="grid-3">
<div class="field"><label>Pipeline Event ID</label><input id="pipeline-event-id" value="evt_demo_variant_manual_001"></div>
<div class="field"><label>配置源 ID</label><input id="source-id" value=""></div>
<div class="field"><label>回滚发布版本 ID</label><input id="rollback-release-id" value=""></div>
</div>
<div class="actions">
<button class="secondary" id="btn-get-pipeline">读取发布链</button>
<button id="btn-build-source">构建配置源</button>
<button id="btn-publish-build">发布构建</button>
<button class="ghost" id="btn-get-release">读取发布版本</button>
<button class="ghost" id="btn-rollback-release">回滚发布</button>
</div>
</section>
</main>
<aside class="aside">
<div class="field">
<label>当前 Bearer Token</label>
<div class="token-box" id="token-box">当前无令牌</div>
</div>
<section class="panel" style="padding:18px;">
<div class="eyebrow">当前状态</div>
<p>当前动作结果会写到这里,方便你快速判断到底卡在资源录入、活动绑定还是发布流程。</p>
<div class="statusbox" id="status-box">待执行</div>
</section>
<section class="panel" style="padding:18px;">
<div class="eyebrow">响应日志</div>
<p>保留最后一次响应。运维排查时先看这里,不用再回调试工作台翻日志。</p>
<div class="logbox" id="log-box">等待操作...</div>
</section>
<section class="panel" style="padding:18px;">
<div class="eyebrow">最近受管资源</div>
<div class="list" id="asset-list"><div class="item"><strong>暂无数据</strong><div class="meta">先执行刷新总览或查看受管资源</div></div></div>
</section>
<section class="panel" style="padding:18px;">
<div class="eyebrow">最近活动</div>
<div class="list" id="event-list"><div class="item"><strong>暂无数据</strong><div class="meta">先执行刷新总览</div></div></div>
</section>
</aside>
</div>
<script>
const STORAGE_KEY = 'cmr-ops-workbench-v5';
const state = {
accessToken: '',
activeView: 'overview',
placeItems: [],
mapItems: [],
placeMapItems: [],
regionOptions: [],
mapEditorMode: 'none'
};
function $(id) { return document.getElementById(id); }
function setActiveView(view) {
state.activeView = view || 'overview';
document.querySelectorAll('.ops-view').forEach(section => {
section.classList.toggle('active', section.id === state.activeView);
});
document.querySelectorAll('.nav-link[data-view]').forEach(button => {
button.classList.toggle('active', button.dataset.view === state.activeView);
});
}
function setStatus(text, isError) {
const el = $('status-box');
el.textContent = text;
el.className = 'statusbox ' + (isError ? 'status-error' : 'status-ok');
}
function writeLog(title, payload) {
$('log-box').textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(payload, null, 2);
}
function syncToken() {
$('token-box').textContent = state.accessToken
? ('Bearer ' + state.accessToken.slice(0, 36) + '...')
: '开发环境默认免登录,可直接操作;如需验证运维账号链路,再执行手机号登录。';
}
function setMapEditorMode(mode) {
state.mapEditorMode = mode || 'none';
$('map-editor-panel').hidden = state.mapEditorMode !== 'map';
$('place-editor-panel').hidden = state.mapEditorMode !== 'place';
persist();
}
function getFieldMap() {
return {
opsMobile: $('ops-mobile'),
opsCode: $('ops-code'),
opsDisplayName: $('ops-display-name'),
opsCountryCode: $('ops-country-code'),
opsDeviceKey: $('ops-device-key'),
opsRoleCode: $('ops-role-code'),
eventId: $('event-id'),
releaseId: $('release-id'),
buildId: $('build-id'),
managedPlaceId: $('managed-place-id'),
managedMapId: $('managed-map-id'),
mapPlaceSelect: $('map-place-select'),
placeManageId: $('place-manage-id'),
assetType: $('asset-type'),
assetCode: $('asset-code'),
assetVersion: $('asset-version'),
assetTitle: $('asset-title'),
assetStatus: $('asset-status'),
assetObjectDir: $('asset-object-dir'),
assetPublicUrl: $('asset-public-url'),
assetContentType: $('asset-content-type'),
assetMetadata: $('asset-metadata'),
placeCode: $('place-code'),
placeName: $('place-name'),
placeProvince: $('place-province'),
placeCity: $('place-city'),
placeRegion: $('place-region'),
mapCode: $('map-code'),
mapName: $('map-name'),
mapType: $('map-type'),
mapCoverUrl: $('map-cover-url'),
mapSummary: $('map-summary'),
tileVersion: $('tile-version'),
tileBaseUrl: $('tile-base-url'),
tileMetaUrl: $('tile-meta-url'),
courseSetCode: $('course-set-code'),
courseSetName: $('course-set-name'),
courseMode: $('course-mode'),
defaultRouteCode: $('default-route-code'),
routesJson: $('routes-json'),
eventTenantCode: $('event-tenant-code'),
eventSlug: $('event-slug'),
eventDisplayName: $('event-display-name'),
eventSummary: $('event-summary'),
eventStatus: $('event-status'),
bindingEventId: $('binding-event-id'),
presentationId: $('presentation-id'),
contentBundleId: $('content-bundle-id'),
runtimeBindingId: $('runtime-binding-id'),
presentationTitle: $('presentation-title'),
presentationTemplateKey: $('presentation-template-key'),
presentationSchemaUrl: $('presentation-schema-url'),
presentationVersion: $('presentation-version'),
bundleTitle: $('bundle-title'),
bundleType: $('bundle-type'),
bundleManifestUrl: $('bundle-manifest-url'),
bundleVersion: $('bundle-version'),
pipelineEventId: $('pipeline-event-id'),
sourceId: $('source-id'),
rollbackReleaseId: $('rollback-release-id')
};
}
function persist() {
const payload = { accessToken: state.accessToken, activeView: state.activeView };
Object.entries(getFieldMap()).forEach(([key, node]) => payload[key] = node.value);
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function restore() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
syncToken();
return;
}
try {
const p = JSON.parse(raw);
state.accessToken = p.accessToken || '';
state.activeView = p.activeView || 'overview';
Object.entries(getFieldMap()).forEach(([key, node]) => {
if (p[key] !== undefined && p[key] !== null && p[key] !== '') node.value = p[key];
});
} catch (_) {}
syncToken();
setActiveView(state.activeView);
}
function syncPlaceRegion() {
const provinceText = $('place-province').selectedOptions[0] ? $('place-province').selectedOptions[0].textContent : '';
const cityText = $('place-city').selectedOptions[0] ? $('place-city').selectedOptions[0].textContent : '';
$('place-region').value = [provinceText, cityText].filter(Boolean).join(' / ');
persist();
}
function renderCityOptions(provinceCode, preferredCityCode) {
const province = state.regionOptions.find(item => item.code === provinceCode);
const cities = province ? province.cities || [] : [];
$('place-city').innerHTML = cities.length
? cities.map(item => '<option value="' + item.code + '">' + item.name + '</option>').join('')
: '<option value="">暂无城市</option>';
if (preferredCityCode && cities.some(item => item.code === preferredCityCode)) {
$('place-city').value = preferredCityCode;
} else if (cities[0]) {
$('place-city').value = cities[0].code;
} else {
$('place-city').value = '';
}
syncPlaceRegion();
}
function renderProvinceOptions(preferredProvinceCode, preferredCityCode) {
$('place-province').innerHTML = state.regionOptions.length
? state.regionOptions.map(item => '<option value="' + item.code + '">' + item.name + '</option>').join('')
: '<option value="">暂无省份</option>';
if (preferredProvinceCode && state.regionOptions.some(item => item.code === preferredProvinceCode)) {
$('place-province').value = preferredProvinceCode;
} else if (state.regionOptions[0]) {
$('place-province').value = state.regionOptions[0].code;
} else {
$('place-province').value = '';
}
renderCityOptions($('place-province').value, preferredCityCode);
}
async function loadRegionOptions() {
const result = await request('GET', '/ops/admin/region-options');
state.regionOptions = result.data || [];
renderProvinceOptions($('place-province').value, $('place-city').value);
return result;
}
function applyRegionText(regionText) {
if (!regionText || !state.regionOptions.length) {
return;
}
const [provinceText, cityText] = String(regionText).split('/').map(item => item.trim()).filter(Boolean);
const province = state.regionOptions.find(item => item.name === provinceText);
if (!province) {
$('place-region').value = regionText;
return;
}
$('place-province').value = province.code;
renderCityOptions(province.code);
const city = (province.cities || []).find(item => item.name === cityText);
if (city) {
$('place-city').value = city.code;
}
syncPlaceRegion();
}
async function request(method, url, body, isJSON = true) {
const headers = {};
let payload = body;
if (state.accessToken) headers['Authorization'] = 'Bearer ' + state.accessToken;
if (isJSON && body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const resp = await fetch(url, { method, headers, body: payload });
const text = await resp.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
if (!resp.ok) throw { status: resp.status, body: data, method, url };
return data;
}
function renderItems(rootId, items, render) {
const root = $(rootId);
if (!Array.isArray(items) || !items.length) {
root.innerHTML = '<div class="item"><strong>暂无数据</strong><div class="meta">当前还没有可展示对象</div></div>';
return;
}
root.innerHTML = items.map(render).join('');
}
function renderAssets(items) {
renderItems('asset-list', items, item =>
'<div class="item"><strong>' + (item.title || item.assetCode || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.assetType || '-') + ' / ' + (item.assetCode || '-') + ' / ' + (item.version || '-') + '</div>' +
'<div class="meta">' + (item.publicUrl || '-') + '</div></div>'
);
}
function renderEvents(items) {
renderItems('event-list', items, item =>
'<div class="item"><strong>' + (item.displayName || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.id || '-') + ' / ' + (item.status || '-') + '</div>' +
'<div class="meta">' + (item.summary || '暂无摘要') + '</div></div>'
);
}
function renderEventListMain(items) {
renderItems('event-list-main', items, item =>
'<div class="item"><strong>' + (item.displayName || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.id || '-') + ' / ' + (item.status || '-') + '</div>' +
'<div class="meta">' + ((item.currentRelease && item.currentRelease.id) || '当前未发布') + '</div></div>'
);
}
function renderPlaces(items) {
const keyword = ($('place-search') && $('place-search').value || '').trim().toLowerCase();
const filtered = !keyword ? items : (items || []).filter(item =>
[item.name, item.code, item.region].filter(Boolean).join(' ').toLowerCase().includes(keyword)
);
renderItems('place-list', items, item =>
'<div class="item selectable' + ((item.id || '') === $('managed-place-id').value ? ' active' : '') + '" data-place-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.id || '-') + ' / ' + (item.code || '-') + '</div>' +
'<div class="meta">' + (item.region || '区域待补充') + '</div></div>'
);
renderItems('place-list', filtered, item =>
'<div class="item selectable' + ((item.id || '') === $('managed-place-id').value ? ' active' : '') + '" data-place-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.id || '-') + ' / ' + (item.code || '-') + '</div>' +
'<div class="meta">' + (item.region || '区域待补充') + '</div></div>'
);
}
function renderMapAssets(items) {
const keyword = ($('map-search') && $('map-search').value || '').trim().toLowerCase();
const filtered = !keyword ? items : (items || []).filter(item =>
[item.name, item.code, item.placeName].filter(Boolean).join(' ').toLowerCase().includes(keyword)
);
renderItems('map-list', filtered, item =>
'<div class="item selectable' + ((item.id || '') === $('managed-map-id').value ? ' active' : '') + '" data-map-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.id || '-') + ' / ' + (item.code || '-') + ' / ' + (item.mapType || '-') + '</div>' +
'<div class="meta">' + ((item.currentTileRelease && item.currentTileRelease.versionCode) || '当前无瓦片版本') + '</div></div>'
);
}
function renderMapLibrary(items) {
const keyword = ($('map-search') && $('map-search').value || '').trim().toLowerCase();
const filtered = !keyword ? items : (items || []).filter(item =>
[item.name, item.code, item.placeName].filter(Boolean).join(' ').toLowerCase().includes(keyword)
);
renderItems('map-library-list', filtered, item =>
'<div class="item selectable' + ((item.id || '') === $('managed-map-id').value ? ' active' : '') + '" data-map-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.id || '-') + ' / ' + (item.placeName || '未关联地点') + '</div>' +
'<div class="meta">' + ((item.currentTileRelease && item.currentTileRelease.versionCode) || '当前无瓦片版本') + '</div></div>'
);
}
function renderMapPlaceSelect() {
const items = Array.isArray(state.placeItems) ? state.placeItems : [];
$('map-place-select').innerHTML = items.length
? items.map(item => '<option value="' + item.id + '">' + (item.name || item.code || item.id) + ' / ' + (item.region || '未设置区域') + '</option>').join('')
: '<option value="">请先录入地点</option>';
if ($('managed-place-id').value && items.some(item => item.id === $('managed-place-id').value)) {
$('map-place-select').value = $('managed-place-id').value;
} else if (items[0]) {
$('map-place-select').value = items[0].id;
$('managed-place-id').value = items[0].id;
} else {
$('map-place-select').value = '';
}
persist();
}
function renderLinkedEvents(items) {
renderItems('linked-event-list', items, item =>
'<div class="item"><strong>' + (item.title || item.eventId || '-') + '</strong>' +
'<div class="meta">' + (item.eventId || '-') + ' / ' + (item.status || '-') + (item.isDefaultExperience ? ' / 默认体验' : '') + '</div>' +
'<div class="meta">' + (item.currentReleaseId || '当前未发布') + ' / ' + (item.currentPresentation || '无展示定义') + ' / ' + (item.currentContentBundle || '无内容包') + '</div></div>'
);
}
function renderCourseSets(items) {
renderItems('course-set-list', items, item =>
'<div class="item"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
'<div class="meta">' + (item.id || '-') + ' / ' + (item.mode || '-') + ' / ' + (item.status || '-') + '</div>' +
'<div class="meta">' + ((item.currentVariant && (item.currentVariant.name || item.currentVariant.routeCode)) || '当前无默认路线') + '</div></div>'
);
}
async function refreshOverviewAndLog(reason) {
try {
const result = await refreshOverview();
writeLog(reason || 'refresh-overview', result);
setStatus('已刷新:资源总览', false);
} catch (error) {
writeLog(reason || 'refresh-overview', { error });
setStatus('失败:资源总览自动刷新', true);
}
}
function setPreviewValue(id, value) {
$(id).textContent = (value !== undefined && value !== null && value !== '') ? String(value) : '-';
}
function applyPipelineSummary(pipeline) {
const sources = Array.isArray(pipeline.sources) ? pipeline.sources : [];
const releases = Array.isArray(pipeline.releases) ? pipeline.releases : [];
if (pipeline.currentRelease && pipeline.currentRelease.id) $('release-id').value = pipeline.currentRelease.id;
if (sources[0] && sources[0].id) $('source-id').value = sources[0].id;
if (releases[0] && releases[0].id) $('rollback-release-id').value = releases[0].id;
setPreviewValue('overview-current-release', pipeline.currentRelease ? (pipeline.currentRelease.id || '-') : '当前未发布');
setPreviewValue('overview-current-runtime', pipeline.currentRelease && pipeline.currentRelease.runtime ? ((pipeline.currentRelease.runtime.name || pipeline.currentRelease.runtime.runtimeBindingId || pipeline.currentRelease.runtime.id || '-') + ' / ' + (pipeline.currentRelease.runtime.runtimeBindingId || pipeline.currentRelease.runtime.id || '-')) : '当前未绑定');
setPreviewValue('overview-current-presentation', pipeline.currentRelease && pipeline.currentRelease.presentation ? ((pipeline.currentRelease.presentation.name || pipeline.currentRelease.presentation.templateKey || '-') + ' / ' + (pipeline.currentRelease.presentation.presentationId || pipeline.currentRelease.presentation.id || '-')) : '当前未绑定');
setPreviewValue('overview-current-content-bundle', pipeline.currentRelease && pipeline.currentRelease.contentBundle ? ((pipeline.currentRelease.contentBundle.name || pipeline.currentRelease.contentBundle.bundleType || '-') + ' / ' + (pipeline.currentRelease.contentBundle.contentBundleId || pipeline.currentRelease.contentBundle.id || '-')) : '当前未绑定');
persist();
}
function applyEventDetail(detail) {
if (!detail) return;
if (detail.event && detail.event.id) {
$('event-id').value = detail.event.id;
$('binding-event-id').value = detail.event.id;
$('pipeline-event-id').value = detail.event.id;
setPreviewValue('overview-current-event', (detail.event.displayName || detail.event.id || '-') + ' / ' + (detail.event.id || '-'));
}
if (detail.event && detail.event.tenantCode) $('event-tenant-code').value = detail.event.tenantCode;
if (detail.event && detail.event.slug) $('event-slug').value = detail.event.slug;
if (detail.event && detail.event.displayName) $('event-display-name').value = detail.event.displayName;
if (detail.event && detail.event.summary) $('event-summary').value = detail.event.summary;
if (detail.event && detail.event.status) $('event-status').value = detail.event.status;
if (detail.event && detail.event.currentRelease && detail.event.currentRelease.id) $('release-id').value = detail.event.currentRelease.id;
if (detail.latestSource && detail.latestSource.id) $('source-id').value = detail.latestSource.id;
if (detail.currentPresentation && detail.currentPresentation.id) $('presentation-id').value = detail.currentPresentation.id;
if (detail.currentContentBundle && detail.currentContentBundle.id) $('content-bundle-id').value = detail.currentContentBundle.id;
if (detail.currentRuntime && detail.currentRuntime.id) $('runtime-binding-id').value = detail.currentRuntime.id;
setPreviewValue('event-preview-runtime', detail.currentRuntime ? (detail.currentRuntime.runtimeBindingId || detail.currentRuntime.id || '-') : '当前未绑定');
setPreviewValue('event-preview-presentation', detail.currentPresentation ? ((detail.currentPresentation.name || '-') + ' / ' + (detail.currentPresentation.presentationId || detail.currentPresentation.id || '-')) : '当前未绑定');
setPreviewValue('event-preview-content-bundle', detail.currentContentBundle ? ((detail.currentContentBundle.name || '-') + ' / ' + (detail.currentContentBundle.contentBundleId || detail.currentContentBundle.id || '-')) : '当前未绑定');
setPreviewValue('event-preview-release', detail.event && detail.event.currentRelease ? (detail.event.currentRelease.id || '-') : '当前未发布');
persist();
}
function applyPlaceDetail(detail) {
if (!detail || !detail.place) return;
$('managed-place-id').value = detail.place.id || '';
$('place-manage-id').value = detail.place.id || '';
$('place-code').value = detail.place.code || $('place-code').value;
$('place-name').value = detail.place.name || $('place-name').value;
$('place-region').value = detail.place.region || $('place-region').value;
applyRegionText(detail.place.region || '');
state.placeMapItems = detail.mapAssets || [];
renderMapAssets(state.placeMapItems);
if (Array.isArray(detail.mapAssets) && detail.mapAssets[0] && detail.mapAssets[0].id) {
$('managed-map-id').value = detail.mapAssets[0].id;
}
setPreviewValue('map-preview-place', (detail.place.name || '-') + ' / ' + (detail.place.code || '-'));
renderMapPlaceSelect();
persist();
}
function applyMapAssetDetail(detail) {
if (!detail || !detail.mapAsset) return;
$('managed-map-id').value = detail.mapAsset.id || '';
$('map-code').value = detail.mapAsset.code || $('map-code').value;
$('map-name').value = detail.mapAsset.name || $('map-name').value;
$('map-type').value = detail.mapAsset.mapType || $('map-type').value;
$('map-cover-url').value = detail.mapAsset.coverUrl || $('map-cover-url').value;
$('map-summary').value = detail.mapAsset.description || $('map-summary').value;
const currentTile = detail.mapAsset.currentTileRelease || (Array.isArray(detail.tileReleases) ? detail.tileReleases[0] : null);
if (currentTile && currentTile.id) {
$('tile-version').value = currentTile.versionCode || $('tile-version').value;
$('tile-base-url').value = currentTile.tileBaseUrl || $('tile-base-url').value;
$('tile-meta-url').value = currentTile.metaUrl || $('tile-meta-url').value;
}
setPreviewValue('map-preview-map', (detail.mapAsset.name || '-') + ' / ' + (detail.mapAsset.code || '-'));
setPreviewValue('map-preview-tile-version', currentTile ? (currentTile.versionCode || '-') : '-');
setPreviewValue('map-preview-tile-base', currentTile ? (currentTile.tileBaseUrl || '-') : '-');
setPreviewValue('map-preview-meta', currentTile ? (currentTile.metaUrl || '-') : '-');
renderCourseSets(detail.courseSets || []);
const linked = Array.isArray(detail.linkedEvents) ? detail.linkedEvents : [];
const defaultCount = linked.filter(item => !!item.isDefaultExperience).length;
setPreviewValue('map-preview-linked-count', linked.length);
setPreviewValue('map-preview-linked-summary', linked.length ? linked.slice(0, 4).map(item => (item.title || item.eventId || '-') + (item.isDefaultExperience ? ' / 默认体验' : '')).join('\n') : '当前未关联活动');
setPreviewValue('map-preview-default-count', defaultCount || $('map-preview-default-count').textContent || 0);
persist();
}
function applyMapExperienceDetail(detail) {
if (!detail) return;
setPreviewValue('map-preview-default-count', detail.defaultExperienceCount || 0);
const items = Array.isArray(detail.defaultExperiences) ? detail.defaultExperiences : [];
setPreviewValue(
'map-preview-default-events',
items.length
? items.map(item => (item.title || item.eventId || '-') + ' / ' + (item.status || item.statusCode || '-')).join('\n')
: '当前无默认活动'
);
}
function applyCourseImportDetail(result) {
if (!result) return;
if (result.courseSet && result.courseSet.id) {
setPreviewValue('course-preview-course-set', (result.courseSet.name || '-') + ' / ' + (result.courseSet.id || '-'));
setPreviewValue('course-preview-default-route', (result.courseSet.currentVariant && (result.courseSet.currentVariant.routeCode || result.courseSet.currentVariant.name)) || $('default-route-code').value || '-');
}
const variants = Array.isArray(result.variants) ? result.variants : [];
setPreviewValue('course-preview-variant-count', variants.length);
setPreviewValue('course-preview-variants', variants.length ? variants.map(item => (item.name || '-') + ' / ' + (item.routeCode || '-') + ' / ' + (item.status || '-')).join('\n') : '暂无路线');
}
async function run(title, fn) {
setStatus('执行中:' + title, false);
try {
const result = await fn();
writeLog(title, result);
setStatus('完成:' + title, false);
persist();
return result;
} catch (error) {
const serializedError = (error && typeof error === 'object')
? {
name: error.name || 'Error',
message: error.message || '',
stack: error.stack || '',
status: error.status,
body: error.body,
method: error.method,
url: error.url
}
: { message: String(error) };
writeLog(title, { error: serializedError });
setStatus('失败:' + title, true);
return null;
}
}
async function refreshOverview() {
const [summaryResp, assetsResp, eventsResp, pipelineResp] = await Promise.all([
request('GET', '/ops/admin/summary'),
request('GET', '/ops/admin/assets'),
request('GET', '/ops/admin/events?limit=12'),
request('GET', '/ops/admin/events/' + encodeURIComponent($('event-id').value) + '/pipeline')
]);
const summary = summaryResp.data || {};
$('metric-assets').textContent = String(summary.managedAssets || 0);
$('metric-places').textContent = String(summary.places || 0);
$('metric-map-assets').textContent = String(summary.mapAssets || 0);
$('metric-tile-releases').textContent = String(summary.tileReleases || 0);
$('metric-course-sets').textContent = String(summary.courseSets || 0);
$('metric-course-variants').textContent = String(summary.courseVariants || 0);
$('metric-events').textContent = String(summary.events || 0);
$('metric-default-events').textContent = String(summary.defaultEvents || 0);
$('metric-published-events').textContent = String(summary.publishedEvents || 0);
$('metric-config-sources').textContent = String(summary.configSources || 0);
$('metric-runtime-bindings').textContent = String(summary.runtimeBindings || 0);
$('metric-pipeline-releases').textContent = String(summary.releases || 0);
$('metric-presentations').textContent = String(summary.presentations || 0);
$('metric-content-bundles').textContent = String(summary.contentBundles || 0);
$('metric-ops-users').textContent = String(summary.opsUsers || 0);
renderAssets(assetsResp.data || []);
renderEvents(eventsResp.data || []);
applyPipelineSummary(pipelineResp.data || {});
return {
summary,
assets: (assetsResp.data || []).length,
events: (eventsResp.data || []).length,
pipeline: pipelineResp.data || {}
};
}
async function loadPlaces() {
const result = await request('GET', '/ops/admin/places?limit=20');
state.placeItems = result.data || [];
renderPlaces(state.placeItems);
renderMapPlaceSelect();
if (Array.isArray(state.placeItems) && state.placeItems[0] && !($('managed-place-id').value)) {
$('managed-place-id').value = state.placeItems[0].id;
}
persist();
return result;
}
async function loadMapAssets() {
const result = await request('GET', '/ops/admin/map-assets?limit=30');
state.mapItems = result.data || [];
renderMapLibrary(state.mapItems);
if (Array.isArray(state.mapItems) && state.mapItems[0] && !($('managed-map-id').value)) {
$('managed-map-id').value = state.mapItems[0].id;
}
persist();
return result;
}
async function loadPlaceDetail(placeID) {
const id = placeID || $('place-manage-id').value || $('managed-place-id').value;
if (!id) throw new Error('请先提供管理地点 ID');
$('place-manage-id').value = id;
$('managed-place-id').value = id;
const result = await request('GET', '/ops/admin/places/' + encodeURIComponent(id));
applyPlaceDetail(result.data || {});
renderPlaces(state.placeItems);
renderMapAssets(state.placeMapItems);
persist();
return result;
}
async function loadMapAssetDetail(mapID) {
const id = mapID || $('managed-map-id').value;
if (!id) throw new Error('请先提供管理地图 ID');
$('managed-map-id').value = id;
setMapEditorMode('none');
const [mapResult, experienceResult] = await Promise.all([
request('GET', '/ops/admin/map-assets/' + encodeURIComponent(id)),
request('GET', '/experience-maps/' + encodeURIComponent(id))
]);
applyMapAssetDetail(mapResult.data || {});
applyMapExperienceDetail(experienceResult.data || {});
renderMapLibrary(state.mapItems);
renderMapAssets(state.placeMapItems);
$('map-detail-modal').hidden = false;
persist();
return { map: mapResult.data || {}, experience: experienceResult.data || {} };
}
$('btn-open-create-map').onclick = () => {
$('map-detail-modal').hidden = true;
$('managed-map-id').value = '';
$('map-code').value = 'map-' + Date.now();
if ($('map-place-select').value) {
$('managed-place-id').value = $('map-place-select').value;
}
setMapEditorMode('map');
persist();
setStatus('已打开:添加地图', false);
};
$('btn-open-create-place').onclick = () => {
$('map-detail-modal').hidden = true;
$('place-manage-id').value = '';
$('managed-place-id').value = '';
setMapEditorMode('place');
persist();
setStatus('已打开:添加地点', false);
};
$('btn-close-map-editor').onclick = () => {
setMapEditorMode('none');
setStatus('已收起:地图编辑区', false);
};
$('btn-close-place-editor').onclick = () => {
setMapEditorMode('none');
setStatus('已收起:地点编辑区', false);
};
$('btn-close-map-detail').onclick = () => {
$('map-detail-modal').hidden = true;
setStatus('已关闭:地图详情', false);
};
$('btn-open-map-editor-from-detail').onclick = () => {
$('map-detail-modal').hidden = true;
setMapEditorMode('map');
setStatus('已打开:地图编辑', false);
};
$('btn-open-map-tile-from-detail').onclick = () => {
$('map-detail-modal').hidden = true;
setMapEditorMode('map');
setStatus('已打开:瓦片版本导入', false);
};
$('btn-create-place').onclick = () => run('create-place', async () => {
const result = await request('POST', '/ops/admin/places', {
code: $('place-code').value,
name: $('place-name').value,
region: $('place-region').value || undefined,
status: 'active'
});
if (result && result.data && result.data.id) {
$('managed-place-id').value = result.data.id;
$('place-manage-id').value = result.data.id;
}
await loadPlaces();
await refreshOverview();
setMapEditorMode('none');
persist();
return result;
});
$('btn-get-place').onclick = () => run('get-place', () => loadPlaceDetail());
$('btn-create-map-asset').onclick = () => run('create-map-asset', async () => {
const placeId = $('map-place-select').value || $('managed-place-id').value;
if (!placeId) throw new Error('请先选择地点');
$('managed-place-id').value = placeId;
const result = await request('POST', '/ops/admin/places/' + encodeURIComponent(placeId) + '/map-assets', {
code: $('map-code').value,
name: $('map-name').value,
mapType: $('map-type').value,
coverUrl: $('map-cover-url').value || undefined,
description: $('map-summary').value || undefined,
status: 'active'
});
if (result && result.data && result.data.id) {
$('managed-map-id').value = result.data.id;
}
await loadMapAssets();
if ($('managed-map-id').value) {
await loadMapAssetDetail($('managed-map-id').value);
}
setMapEditorMode('none');
persist();
return result;
});
$('btn-update-map-asset').onclick = () => run('update-map-asset', async () => {
if (!$('managed-map-id').value) throw new Error('请先读取地图详情,拿到管理地图 ID');
const result = await request('PUT', '/ops/admin/map-assets/' + encodeURIComponent($('managed-map-id').value), {
code: $('map-code').value,
name: $('map-name').value,
mapType: $('map-type').value,
coverUrl: $('map-cover-url').value || undefined,
description: $('map-summary').value || undefined,
status: 'active'
});
await refreshOverview();
await loadMapAssetDetail($('managed-map-id').value);
setMapEditorMode('none');
return result;
});
$('btn-get-map-asset').onclick = () => run('get-map-asset', () => loadMapAssetDetail());
$('btn-refresh-map-area').onclick = () => run('refresh-map-area', async () => {
const [regionsResult, placesResult, mapsResult] = await Promise.all([loadRegionOptions(), loadPlaces(), loadMapAssets()]);
$('map-detail-modal').hidden = true;
if ($('place-manage-id').value || $('managed-place-id').value) {
await loadPlaceDetail($('managed-place-id').value);
}
if ($('managed-map-id').value) {
await loadMapAssetDetail($('managed-map-id').value);
}
return {
regions: ((regionsResult && regionsResult.data) || []).length,
places: ((placesResult && placesResult.data) || []).length,
maps: ((mapsResult && mapsResult.data) || []).length,
};
});
$('btn-send-ops-code').onclick = () => run('ops-send-sms-code', async () => {
return await request('POST', '/ops/auth/sms/send', {
countryCode: $('ops-country-code').value,
mobile: $('ops-mobile').value,
deviceKey: $('ops-device-key').value,
scene: 'ops_login'
});
});
$('btn-register-ops').onclick = () => run('ops-register', async () => {
const result = await request('POST', '/ops/auth/register', {
countryCode: $('ops-country-code').value,
mobile: $('ops-mobile').value,
code: $('ops-code').value,
deviceKey: $('ops-device-key').value,
displayName: $('ops-display-name').value
});
state.accessToken = result.data.tokens.accessToken;
$('ops-role-code').value = result.data.user.roleCode || '';
syncToken();
return result;
});
$('btn-open-events-view').onclick = () => {
setActiveView('events');
persist();
setStatus('已切到:活动管理', false);
};
$('btn-open-compose-view').onclick = () => {
setActiveView('compose');
persist();
setStatus('已切到:活动编排', false);
};
$('btn-open-publish-view').onclick = () => {
setActiveView('publish');
persist();
setStatus('已切到:发布中心', false);
};
$('btn-login-ops').onclick = () => run('ops-login-sms', async () => {
const result = await request('POST', '/ops/auth/login/sms', {
countryCode: $('ops-country-code').value,
mobile: $('ops-mobile').value,
code: $('ops-code').value,
deviceKey: $('ops-device-key').value
});
state.accessToken = result.data.tokens.accessToken;
$('ops-role-code').value = result.data.user.roleCode || '';
syncToken();
return result;
});
$('btn-clear-token').onclick = () => {
state.accessToken = '';
syncToken();
persist();
setStatus('已清空 token', false);
};
$('btn-refresh-overview').onclick = () => run('refresh-overview', refreshOverview);
$('btn-upload-asset').onclick = () => run('upload-asset', async () => {
const file = $('asset-file').files[0];
if (!file) throw new Error('请选择文件');
const form = new FormData();
form.append('assetType', $('asset-type').value);
form.append('assetCode', $('asset-code').value);
form.append('version', $('asset-version').value);
form.append('title', $('asset-title').value);
form.append('objectDir', $('asset-object-dir').value);
form.append('status', $('asset-status').value);
form.append('metadataJson', $('asset-metadata').value || '{}');
form.append('file', file);
const result = await request('POST', '/ops/admin/assets/upload', form, false);
await refreshOverview();
return result;
});
$('btn-register-link').onclick = () => run('register-link', async () => {
const result = await request('POST', '/ops/admin/assets/register-link', {
assetType: $('asset-type').value,
assetCode: $('asset-code').value,
version: $('asset-version').value,
title: $('asset-title').value,
publicUrl: $('asset-public-url').value,
contentType: $('asset-content-type').value || undefined,
status: $('asset-status').value,
metadata: JSON.parse($('asset-metadata').value || '{}')
});
await refreshOverview();
return result;
});
$('btn-list-assets').onclick = () => run('list-assets', async () => {
const result = await request('GET', '/ops/admin/assets');
renderAssets(result.data || []);
return result;
});
$('btn-import-tile').onclick = () => run('import-tile-release', async () => {
const result = await request('POST', '/ops/admin/ops/tile-releases/import', {
placeCode: $('place-code').value,
placeName: $('place-name').value,
mapAssetCode: $('map-code').value,
mapAssetName: $('map-name').value,
mapType: $('map-type').value,
versionCode: $('tile-version').value,
status: 'active',
tileBaseUrl: $('tile-base-url').value,
metaUrl: $('tile-meta-url').value,
setAsCurrent: true
});
if (result && result.data) {
if (result.data.place && result.data.place.id) $('managed-place-id').value = result.data.place.id;
if (result.data.mapAsset && result.data.mapAsset.id) $('managed-map-id').value = result.data.mapAsset.id;
applyMapAssetDetail({
mapAsset: result.data.mapAsset,
tileReleases: result.data.tileRelease ? [result.data.tileRelease] : []
});
}
$('map-detail-modal').hidden = false;
setMapEditorMode('none');
persist();
return result;
});
$('btn-import-kml-batch').onclick = () => run('import-kml-batch', async () => {
const result = await request('POST', '/ops/admin/ops/course-sets/import-kml-batch', {
placeCode: $('place-code').value,
placeName: $('place-name').value,
mapAssetCode: $('map-code').value,
mapAssetName: $('map-name').value,
mapType: $('map-type').value,
courseSetCode: $('course-set-code').value,
courseSetName: $('course-set-name').value,
mode: $('course-mode').value,
status: 'active',
defaultRouteCode: $('default-route-code').value,
routes: JSON.parse($('routes-json').value || '[]')
});
applyCourseImportDetail(result.data || {});
return result;
});
$('btn-list-events').onclick = () => run('list-events', async () => {
const result = await request('GET', '/ops/admin/events?limit=30');
renderEvents(result.data || []);
renderEventListMain(result.data || []);
if (Array.isArray(result.data) && result.data[0] && result.data[0].id) {
$('binding-event-id').value = result.data[0].id;
$('pipeline-event-id').value = result.data[0].id;
}
persist();
return result;
});
$('btn-create-event').onclick = () => run('create-event', async () => {
const result = await request('POST', '/ops/admin/events', {
tenantCode: $('event-tenant-code').value || null,
slug: $('event-slug').value,
displayName: $('event-display-name').value,
summary: $('event-summary').value || null,
status: $('event-status').value
});
if (result && result.data) {
applyEventDetail({ event: result.data });
}
persist();
await refreshOverview();
return result;
});
$('btn-update-event').onclick = () => run('update-event', async () => {
const eventId = $('binding-event-id').value.trim();
if (!eventId) throw new Error('请先填写活动 ID');
const result = await request('PUT', '/ops/admin/events/' + encodeURIComponent(eventId), {
tenantCode: $('event-tenant-code').value || null,
slug: $('event-slug').value,
displayName: $('event-display-name').value,
summary: $('event-summary').value || null,
status: $('event-status').value
});
if (result && result.data) {
applyEventDetail({ event: result.data });
}
persist();
await refreshOverview();
return result;
});
$('btn-get-event').onclick = () => run('get-event', async () => {
const result = await request('GET', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value));
applyEventDetail(result.data || {});
return result;
});
$('btn-import-presentation').onclick = () => run('import-presentation', async () => {
const result = await request('POST', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value) + '/presentations/import', {
title: $('presentation-title').value,
templateKey: $('presentation-template-key').value,
sourceType: 'schema',
schemaUrl: $('presentation-schema-url').value,
version: $('presentation-version').value,
status: 'active',
isDefault: true
});
if (result && result.data && result.data.id) $('presentation-id').value = result.data.id;
persist();
return result;
});
$('btn-import-bundle').onclick = () => run('import-content-bundle', async () => {
const manifestUrl = $('bundle-manifest-url').value;
const result = await request('POST', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value) + '/content-bundles/import', {
title: $('bundle-title').value,
bundleType: $('bundle-type').value,
sourceType: 'manifest',
manifestUrl: manifestUrl,
version: $('bundle-version').value,
status: 'active',
isDefault: true,
assetManifest: { manifestUrl }
});
if (result && result.data && result.data.id) $('content-bundle-id').value = result.data.id;
persist();
return result;
});
$('btn-save-defaults').onclick = () => run('save-event-defaults', async () => {
return await request('POST', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value) + '/defaults', {
presentationId: $('presentation-id').value || undefined,
contentBundleId: $('content-bundle-id').value || undefined,
runtimeBindingId: $('runtime-binding-id').value || undefined
});
});
$('btn-get-pipeline').onclick = () => run('get-pipeline', async () => {
const result = await request('GET', '/ops/admin/events/' + encodeURIComponent($('pipeline-event-id').value) + '/pipeline');
applyPipelineSummary(result.data || {});
return result;
});
$('btn-build-source').onclick = () => run('build-source', async () => {
if (!$('source-id').value) throw new Error('请先提供配置源 ID');
const result = await request('POST', '/ops/admin/sources/' + encodeURIComponent($('source-id').value) + '/build');
if (result && result.data && result.data.id) $('build-id').value = result.data.id;
persist();
return result;
});
$('btn-publish-build').onclick = () => run('publish-build', async () => {
if (!$('build-id').value) throw new Error('请先提供构建 ID');
const result = await request('POST', '/ops/admin/builds/' + encodeURIComponent($('build-id').value) + '/publish', {
runtimeBindingId: $('runtime-binding-id').value || undefined,
presentationId: $('presentation-id').value || undefined,
contentBundleId: $('content-bundle-id').value || undefined
});
if (result && result.data && result.data.id) {
$('release-id').value = result.data.id;
$('rollback-release-id').value = result.data.id;
}
persist();
return result;
});
$('btn-get-release').onclick = () => run('get-release', async () => {
if (!$('release-id').value) throw new Error('请先提供发布版本 ID');
return await request('GET', '/ops/admin/releases/' + encodeURIComponent($('release-id').value));
});
$('btn-rollback-release').onclick = () => run('rollback-release', async () => {
if (!$('rollback-release-id').value) throw new Error('请先提供待回滚的发布版本 ID');
return await request('POST', '/ops/admin/events/' + encodeURIComponent($('pipeline-event-id').value) + '/rollback', {
releaseId: $('rollback-release-id').value
});
});
document.querySelectorAll('input, textarea, select').forEach(node => {
node.addEventListener('change', persist);
node.addEventListener('input', persist);
});
['place-search', 'map-search'].forEach(id => {
$(id).addEventListener('input', () => {
renderPlaces(state.placeItems);
renderMapLibrary(state.mapItems);
renderMapAssets(state.placeMapItems);
});
});
$('place-province').addEventListener('change', () => {
renderCityOptions($('place-province').value);
});
$('place-city').addEventListener('change', syncPlaceRegion);
$('map-place-select').addEventListener('change', () => {
$('managed-place-id').value = $('map-place-select').value || '';
persist();
});
$('place-list').addEventListener('click', event => {
const item = event.target.closest('[data-place-id]');
if (!item) return;
void run('get-place', () => loadPlaceDetail(item.dataset.placeId));
});
['map-list', 'map-library-list'].forEach(id => {
$(id).addEventListener('click', event => {
const item = event.target.closest('[data-map-id]');
if (!item) return;
void run('get-map-asset', () => loadMapAssetDetail(item.dataset.mapId));
});
});
['map-detail-modal', 'map-editor-panel', 'place-editor-panel'].forEach(id => {
$(id).addEventListener('click', event => {
if (event.target !== $(id)) return;
if (id === 'map-detail-modal') {
$('map-detail-modal').hidden = true;
return;
}
setMapEditorMode('none');
});
});
document.querySelectorAll('.nav-link[data-view]').forEach(button => {
button.addEventListener('click', () => {
setActiveView(button.dataset.view);
persist();
if (button.dataset.view === 'overview') {
void refreshOverviewAndLog('refresh-overview');
} else if (button.dataset.view === 'maps') {
void run('refresh-map-area', async () => {
const [regionsResult, placesResult, mapsResult] = await Promise.all([loadRegionOptions(), loadPlaces(), loadMapAssets()]);
return {
regions: ((regionsResult && regionsResult.data) || []).length,
places: ((placesResult && placesResult.data) || []).length,
maps: ((mapsResult && mapsResult.data) || []).length,
};
});
}
});
});
restore();
setActiveView(state.activeView);
setMapEditorMode('none');
renderPlaces([]);
renderMapAssets([]);
renderMapLibrary([]);
renderCourseSets([]);
renderAssets([]);
renderEvents([]);
renderEventListMain([]);
writeLog('ops-workbench-ready', {
ok: true,
hint: '开发环境默认免登录。建议顺序:先看资源总览 -> 地图/地点管理 -> 路线资源管理 -> 活动管理 / 活动编排 -> 发布中心。'
});
void refreshOverviewAndLog('refresh-overview');
void loadRegionOptions();
if (state.activeView === 'maps') {
void run('refresh-map-area', async () => {
const [regionsResult, placesResult, mapsResult] = await Promise.all([loadRegionOptions(), loadPlaces(), loadMapAssets()]);
return {
regions: ((regionsResult && regionsResult.data) || []).length,
places: ((placesResult && placesResult.data) || []).length,
maps: ((mapsResult && mapsResult.data) || []).length,
};
});
}
</script>
</body>
</html>`