1682 lines
85 KiB
Go
1682 lines
85 KiB
Go
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>`
|