ops_workbench_handler.go 85 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681
  1. package handlers
  2. import "net/http"
  3. type OpsWorkbenchHandler struct{}
  4. func NewOpsWorkbenchHandler() *OpsWorkbenchHandler {
  5. return &OpsWorkbenchHandler{}
  6. }
  7. func (h *OpsWorkbenchHandler) Get(w http.ResponseWriter, r *http.Request) {
  8. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  9. _, _ = w.Write([]byte(opsWorkbenchHTML))
  10. }
  11. const opsWorkbenchHTML = `<!doctype html>
  12. <html lang="zh-CN">
  13. <head>
  14. <meta charset="utf-8">
  15. <meta name="viewport" content="width=device-width, initial-scale=1">
  16. <title>CMR 运维后台</title>
  17. <style>
  18. :root {
  19. --bg: #071218;
  20. --panel: #12212c;
  21. --panel-2: #172a36;
  22. --line: #284355;
  23. --text: #ecf6ff;
  24. --muted: #92acbf;
  25. --brand: #52d6b3;
  26. --warn: #ffd36a;
  27. --danger: #ff7b7b;
  28. --accent: #4fa3ff;
  29. }
  30. * { box-sizing: border-box; }
  31. html { scroll-behavior: smooth; }
  32. body {
  33. margin: 0;
  34. color: var(--text);
  35. font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
  36. background:
  37. radial-gradient(circle at top left, rgba(82,214,179,.12), transparent 28%),
  38. radial-gradient(circle at top right, rgba(79,163,255,.12), transparent 24%),
  39. var(--bg);
  40. }
  41. .shell {
  42. width: 100%;
  43. max-width: none;
  44. margin: 0;
  45. padding: 24px 28px 32px;
  46. display: grid;
  47. grid-template-columns: 280px minmax(0,1fr) 400px;
  48. gap: 20px;
  49. align-items: start;
  50. }
  51. .sidebar, .aside, .panel {
  52. background: rgba(18,33,44,.92);
  53. border: 1px solid var(--line);
  54. border-radius: 24px;
  55. box-shadow: 0 16px 60px rgba(0,0,0,.22);
  56. }
  57. .sidebar, .aside {
  58. position: sticky;
  59. top: 20px;
  60. padding: 18px;
  61. display: grid;
  62. gap: 18px;
  63. align-content: start;
  64. }
  65. .sidebar { background: linear-gradient(180deg, rgba(13,27,36,.96), rgba(18,33,44,.94)); }
  66. .aside { background: linear-gradient(180deg, rgba(18,33,44,.94), rgba(13,23,31,.96)); }
  67. .content { display: grid; gap: 20px; min-width: 0; }
  68. .panel { padding: 20px; display: grid; gap: 16px; align-content: start; }
  69. .hero {
  70. background: linear-gradient(135deg, rgba(82,214,179,.15), rgba(79,163,255,.10));
  71. padding: 22px 24px;
  72. }
  73. .brand h1, .hero h2, .panel h3 { margin: 0; line-height: 1.2; }
  74. .brand h1 { font-size: 26px; }
  75. .hero h2 { font-size: 30px; }
  76. .panel h3 { font-size: 24px; }
  77. .brand p, .hero p, .panel p, .hint { margin: 0; color: var(--muted); line-height: 1.7; }
  78. .tag, .eyebrow {
  79. display: inline-flex;
  80. width: fit-content;
  81. padding: 4px 10px;
  82. border-radius: 999px;
  83. font-size: 12px;
  84. font-weight: 700;
  85. }
  86. .tag { background: rgba(82,214,179,.18); color: var(--brand); }
  87. .eyebrow { background: rgba(79,163,255,.16); color: var(--accent); }
  88. .nav-group { display: grid; gap: 10px; }
  89. .nav-title {
  90. color: var(--muted);
  91. font-size: 12px;
  92. letter-spacing: .08em;
  93. text-transform: uppercase;
  94. padding: 0 4px;
  95. }
  96. .nav-link {
  97. display: grid;
  98. gap: 4px;
  99. width: 100%;
  100. text-align: left;
  101. color: var(--text);
  102. padding: 13px 14px;
  103. border-radius: 18px;
  104. border: 1px solid var(--line);
  105. background: rgba(255,255,255,.02);
  106. cursor: pointer;
  107. }
  108. .nav-link.active {
  109. border-color: rgba(82,214,179,.45);
  110. background: rgba(82,214,179,.10);
  111. box-shadow: inset 0 0 0 1px rgba(82,214,179,.16);
  112. }
  113. .nav-link span { color: var(--muted); font-size: 13px; line-height: 1.45; }
  114. .hero-points, .metrics, .grid-2, .grid-3, .split { display: grid; gap: 14px; }
  115. .toolbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: end; justify-content: space-between; }
  116. .toolbar .actions { justify-content: flex-end; }
  117. .hero-points, .metrics { grid-template-columns: repeat(3, minmax(0,1fr)); }
  118. .metrics { grid-template-columns: repeat(4, minmax(0,1fr)); }
  119. .grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
  120. .grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
  121. .split { grid-template-columns: 1.05fr .95fr; align-items: start; }
  122. .hero-card, .metric, .item, .token-box, .statusbox, .logbox, .list {
  123. border-radius: 18px;
  124. border: 1px solid var(--line);
  125. background: rgba(255,255,255,.03);
  126. }
  127. .hero-card, .metric, .item { padding: 14px; display: grid; gap: 6px; }
  128. .item.selectable { cursor: pointer; transition: border-color .18s ease, background .18s ease, transform .18s ease; }
  129. .item.selectable:hover { border-color: rgba(82,214,179,.35); background: rgba(82,214,179,.08); transform: translateY(-1px); }
  130. .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); }
  131. .metric strong { font-size: 28px; line-height: 1; }
  132. .metric span, .item .meta { color: var(--muted); font-size: 13px; line-height: 1.5; }
  133. .field { display: grid; gap: 8px; min-width: 0; }
  134. .field label { color: var(--muted); font-size: 14px; }
  135. input, textarea, select {
  136. width: 100%;
  137. border-radius: 16px;
  138. border: 1px solid var(--line);
  139. background: var(--panel-2);
  140. color: var(--text);
  141. padding: 13px 14px;
  142. font: inherit;
  143. }
  144. textarea { min-height: 110px; resize: vertical; line-height: 1.55; }
  145. .tall textarea { min-height: 190px; }
  146. .actions { display: flex; gap: 12px; flex-wrap: wrap; }
  147. button {
  148. border: 0;
  149. border-radius: 16px;
  150. padding: 13px 18px;
  151. font: inherit;
  152. font-weight: 700;
  153. cursor: pointer;
  154. background: var(--brand);
  155. color: #03231d;
  156. }
  157. button.nav-link {
  158. background: rgba(255,255,255,.02);
  159. color: var(--text);
  160. border: 1px solid var(--line);
  161. padding: 13px 14px;
  162. }
  163. button.nav-link.active {
  164. border-color: rgba(82,214,179,.45);
  165. background: rgba(82,214,179,.10);
  166. color: var(--text);
  167. }
  168. button.secondary { background: var(--warn); color: #3f2d00; }
  169. button.ghost { background: transparent; border: 1px solid var(--line); color: var(--text); }
  170. .token-box, .statusbox, .logbox {
  171. padding: 14px;
  172. white-space: pre-wrap;
  173. word-break: break-word;
  174. font-family: "Consolas", "SFMono-Regular", monospace;
  175. }
  176. .statusbox { min-height: 84px; }
  177. .logbox { min-height: 220px; max-height: 420px; overflow: auto; }
  178. .list { padding: 10px; display: grid; gap: 10px; max-height: 320px; overflow: auto; }
  179. .ops-view { display: none; }
  180. .ops-view.active { display: grid; }
  181. [hidden] { display: none !important; }
  182. .modal-backdrop {
  183. position: fixed;
  184. inset: 0;
  185. background: rgba(3, 10, 14, .68);
  186. backdrop-filter: blur(4px);
  187. display: grid;
  188. place-items: center;
  189. padding: 24px;
  190. z-index: 30;
  191. }
  192. .modal-card {
  193. width: min(1180px, calc(100vw - 48px));
  194. max-height: calc(100vh - 48px);
  195. overflow: auto;
  196. background: linear-gradient(180deg, rgba(18,33,44,.98), rgba(12,23,31,.98));
  197. border: 1px solid var(--line);
  198. border-radius: 24px;
  199. box-shadow: 0 20px 80px rgba(0,0,0,.35);
  200. padding: 22px;
  201. display: grid;
  202. gap: 16px;
  203. }
  204. .status-ok { color: var(--brand); }
  205. .status-error { color: var(--danger); }
  206. @media (max-width: 1560px) {
  207. .shell { grid-template-columns: 250px minmax(0,1fr) 360px; padding: 22px 24px 28px; }
  208. }
  209. @media (max-width: 1360px) {
  210. .shell { grid-template-columns: 220px minmax(0,1fr); }
  211. .aside { grid-column: 2; position: static; }
  212. }
  213. @media (max-width: 1120px) {
  214. .shell { grid-template-columns: 1fr; }
  215. .sidebar, .aside { position: static; }
  216. .hero-points, .metrics, .grid-2, .grid-3, .split { grid-template-columns: 1fr; }
  217. }
  218. </style>
  219. </head>
  220. <body>
  221. <div class="shell">
  222. <aside class="sidebar">
  223. <div class="brand">
  224. <span class="tag">运维后台第一期</span>
  225. <h1>资源录入与发布中心</h1>
  226. <p>这里不做联调回归,只做资源纳管、活动绑定和发布回滚。调试问题继续去 <code>/dev/workbench</code>。</p>
  227. </div>
  228. <div class="nav-group">
  229. <div class="nav-title">主要流程</div>
  230. <button class="nav-link active" type="button" data-view="overview"><strong>资源总览</strong><span>先看已有资源、活动和当前发布状态。</span></button>
  231. <button class="nav-link" type="button" data-view="maps"><strong>地图 / 地点管理</strong><span>先管地图列表、地点、当前瓦片版本和地图预览。</span></button>
  232. <button class="nav-link" type="button" data-view="courses"><strong>路线资源管理</strong><span>围绕地图管理 KML、路线组、默认路线和预览。</span></button>
  233. <button class="nav-link" type="button" data-view="events"><strong>活动管理</strong><span>活动列表、基础信息、状态和当前发布概况。</span></button>
  234. <button class="nav-link" type="button" data-view="compose"><strong>活动编排</strong><span>绑定 runtime、展示定义、内容包,准备发布。</span></button>
  235. <button class="nav-link" type="button" data-view="publish"><strong>发布中心</strong><span>查看 pipeline、build、publish、rollback。</span></button>
  236. </div>
  237. <div class="nav-group">
  238. <div class="nav-title">辅助入口</div>
  239. <button class="nav-link" type="button" data-view="ingest"><strong>资源录入</strong><span>上传文件或登记正式链接,统一纳管资源对象。</span></button>
  240. </div>
  241. <div class="nav-group">
  242. <div class="nav-title">调试工具</div>
  243. <a class="nav-link" href="/dev/workbench"><strong>返回调试工作台</strong><span>去一键回归、配置摘要、前端日志面板。</span></a>
  244. </div>
  245. </aside>
  246. <main class="content">
  247. <section class="panel hero">
  248. <h2>先录资源,再绑活动,最后发布</h2>
  249. <p>运维平台第一版的目标很单一:把正式资源稳定录入系统,沉淀成资源对象,再通过统一发布链形成可追溯的 release。这里不混调试按钮,也不直接暴露玩家链路。</p>
  250. <div class="hero-points">
  251. <div class="hero-card"><strong>资源录入</strong><span>文件上传和外链登记统一收口,不再依赖手工改代码或散脚本。</span></div>
  252. <div class="hero-card"><strong>活动绑定</strong><span>活动侧只绑定默认 runtime、presentation、content bundle 三元组。</span></div>
  253. <div class="hero-card"><strong>发布中心</strong><span>继续走统一 build / publish / rollback,不开第二套发布逻辑。</span></div>
  254. </div>
  255. </section>
  256. <section class="panel ops-view active" id="overview">
  257. <div class="eyebrow">资源总览</div>
  258. <h3>先看关键统计,再看当前运行时信息</h3>
  259. <p>资源总览先回答两个问题:系统里现在有多少正式对象;你当前选中的活动此刻绑定了哪套 release / runtime / 展示定义 / 内容包。</p>
  260. <input id="managed-place-id" type="hidden" value="">
  261. <div class="split">
  262. <div class="panel" style="padding:18px;">
  263. <div class="eyebrow">资源与路线统计</div>
  264. <div class="metrics">
  265. <div class="metric"><strong id="metric-places">0</strong><span>地点</span></div>
  266. <div class="metric"><strong id="metric-map-assets">0</strong><span>地图</span></div>
  267. <div class="metric"><strong id="metric-tile-releases">0</strong><span>瓦片版本</span></div>
  268. <div class="metric"><strong id="metric-assets">0</strong><span>受管资源</span></div>
  269. <div class="metric"><strong id="metric-course-sets">0</strong><span>路线组</span></div>
  270. <div class="metric"><strong id="metric-course-variants">0</strong><span>路线变体</span></div>
  271. <div class="metric"><strong id="metric-runtime-bindings">0</strong><span>运行绑定</span></div>
  272. <div class="metric"><strong id="metric-config-sources">0</strong><span>配置源</span></div>
  273. </div>
  274. </div>
  275. <div class="panel" style="padding:18px;">
  276. <div class="eyebrow">活动与发布统计</div>
  277. <div class="metrics">
  278. <div class="metric"><strong id="metric-events">0</strong><span>活动数</span></div>
  279. <div class="metric"><strong id="metric-default-events">0</strong><span>默认体验活动</span></div>
  280. <div class="metric"><strong id="metric-published-events">0</strong><span>已发布活动</span></div>
  281. <div class="metric"><strong id="metric-pipeline-releases">0</strong><span>发布版本</span></div>
  282. <div class="metric"><strong id="metric-presentations">0</strong><span>展示定义</span></div>
  283. <div class="metric"><strong id="metric-content-bundles">0</strong><span>内容包</span></div>
  284. <div class="metric"><strong id="metric-ops-users">0</strong><span>运维账号</span></div>
  285. </div>
  286. </div>
  287. </div>
  288. <div class="split">
  289. <div class="panel" style="padding:18px;">
  290. <div class="eyebrow">当前运行时信息</div>
  291. <div class="field"><label>当前活动</label><div class="token-box" id="overview-current-event">-</div></div>
  292. <div class="field"><label>当前发布版本</label><div class="token-box" id="overview-current-release">-</div></div>
  293. <div class="field"><label>当前 runtime</label><div class="token-box" id="overview-current-runtime">-</div></div>
  294. <div class="field"><label>当前展示定义</label><div class="token-box" id="overview-current-presentation">-</div></div>
  295. <div class="field"><label>当前内容包</label><div class="token-box" id="overview-current-content-bundle">-</div></div>
  296. </div>
  297. <div class="panel" style="padding:18px;">
  298. <div class="eyebrow">操作提示</div>
  299. <div class="item"><strong>1. 先看资源总览</strong><div class="meta">确认地点、地图、路线组、活动、已发布数量是否符合预期。</div></div>
  300. <div class="item"><strong>2. 再选主流程</strong><div class="meta">地图 / 地点管理 -> 路线资源管理 -> 活动管理 / 活动编排 -> 发布中心。</div></div>
  301. <div class="item"><strong>3. 关联活动只看摘要</strong><div class="meta">地图页只看数量和跳转,活动详情统一放到“活动管理”。</div></div>
  302. </div>
  303. </div>
  304. <div class="grid-3">
  305. <div class="field"><label>运维手机号</label><input id="ops-mobile" value="13800138000"></div>
  306. <div class="field"><label>短信验证码</label><input id="ops-code" value=""></div>
  307. <div class="field"><label>运维显示名</label><input id="ops-display-name" value="开发运维"></div>
  308. <div class="field"><label>国家区号</label><input id="ops-country-code" value="86"></div>
  309. <div class="field"><label>设备标识</label><input id="ops-device-key" value="ops-console-001"></div>
  310. <div class="field"><label>当前角色</label><input id="ops-role-code" value="开发态自动放行" readonly></div>
  311. <div class="field"><label>活动 ID(总览 / pipeline)</label><input id="event-id" value="evt_demo_variant_manual_001"></div>
  312. <div class="field"><label>当前发布版本 ID</label><input id="release-id" value=""></div>
  313. <div class="field"><label>当前构建 ID</label><input id="build-id" value=""></div>
  314. </div>
  315. <div class="actions">
  316. <button id="btn-send-ops-code">发送验证码</button>
  317. <button id="btn-register-ops" class="secondary">注册运维账号</button>
  318. <button id="btn-login-ops" class="secondary">手机号登录</button>
  319. <button class="secondary" id="btn-refresh-overview">刷新总览</button>
  320. <button class="ghost" id="btn-clear-token">清空令牌</button>
  321. </div>
  322. <div class="hint">开发环境默认免登录放行,生产环境请使用手机号验证码注册/登录的独立运维账号。运维账号和前端玩家账号完全分离。</div>
  323. </section>
  324. <section class="panel ops-view" id="maps">
  325. <div class="eyebrow">地图 / 地点管理</div>
  326. <h3>先进入地图列表,再做新增、编辑和预览</h3>
  327. <p>地点是地图的归属容器,不是主入口。一个地点可挂多张地图,一张地图只属于一个地点。关联活动在这里先只看数量和摘要,详情统一去“活动管理”。</p>
  328. <div class="toolbar">
  329. <div class="grid-2" style="min-width:min(100%,720px);">
  330. <div class="field"><label>地图关键字</label><input id="map-search" value="" placeholder="按地图名称、编码、地点筛选"></div>
  331. <div class="field"><label>地点关键字</label><input id="place-search" value="" placeholder="按地点名称、省市、编码筛选"></div>
  332. </div>
  333. <div class="actions">
  334. <button id="btn-open-create-map">添加地图</button>
  335. <button class="ghost" id="btn-open-create-place">添加地点</button>
  336. <button class="secondary" id="btn-refresh-map-area">刷新地图区</button>
  337. </div>
  338. </div>
  339. <div class="panel" style="padding:18px;">
  340. <div class="eyebrow">地图列表</div>
  341. <div class="hint">默认只看地图列表。点一张地图,再进入地图详情;新增地图和新增地点都走独立弹出层。</div>
  342. <div class="list" id="map-library-list"><div class="item"><strong>暂无地图</strong><div class="meta">打开页面后会自动拉取地图列表,也可以手动点“刷新地图区”。</div></div></div>
  343. </div>
  344. <div hidden>
  345. <div id="place-list"></div>
  346. <div id="map-list"></div>
  347. </div>
  348. <div class="modal-backdrop" id="map-detail-modal" hidden>
  349. <div class="modal-card">
  350. <div class="eyebrow">地图详情</div>
  351. <h3 style="margin:0;">当前地图详情 / 预览</h3>
  352. <div class="split">
  353. <div class="panel" style="padding:18px;">
  354. <div class="field"><label>当前地点</label><div class="token-box" id="map-preview-place">-</div></div>
  355. <div class="field"><label>当前地图</label><div class="token-box" id="map-preview-map">-</div></div>
  356. <div class="field"><label>当前瓦片版本</label><div class="token-box" id="map-preview-tile-version">-</div></div>
  357. <div class="field"><label>Tile Base URL</label><div class="token-box" id="map-preview-tile-base">-</div></div>
  358. <div class="field"><label>Meta URL</label><div class="token-box" id="map-preview-meta">-</div></div>
  359. </div>
  360. <div class="panel" style="padding:18px;">
  361. <div class="field"><label>默认活动数量</label><div class="token-box" id="map-preview-default-count">0</div></div>
  362. <div class="field"><label>默认活动摘要</label><div class="token-box" id="map-preview-default-events">-</div></div>
  363. <div class="field"><label>关联活动数量</label><div class="token-box" id="map-preview-linked-count">0</div></div>
  364. <div class="field"><label>关联活动摘要</label><div class="token-box" id="map-preview-linked-summary">当前未关联活动</div></div>
  365. </div>
  366. </div>
  367. <div class="actions">
  368. <button id="btn-open-map-editor-from-detail">编辑地图</button>
  369. <button class="secondary" id="btn-open-map-tile-from-detail">导入瓦片版本</button>
  370. <button class="ghost" id="btn-open-events-view">前往活动管理</button>
  371. <button class="ghost" id="btn-close-map-detail">关闭详情</button>
  372. </div>
  373. </div>
  374. </div>
  375. <div class="modal-backdrop" id="map-editor-panel" hidden>
  376. <div class="modal-card">
  377. <div class="eyebrow">添加 / 编辑地图</div>
  378. <div class="grid-2">
  379. <div class="field"><label>管理地图 ID</label><input id="managed-map-id" value=""></div>
  380. <div class="field"><label>所属地点</label><select id="map-place-select"><option value="">请先录入地点</option></select></div>
  381. <div class="field"><label>地图编码</label><input id="map-code" value="lxcb-map-001"></div>
  382. <div class="field"><label>地图名称</label><input id="map-name" value="领秀城公园底图"></div>
  383. <div class="field"><label>地图类型</label><input id="map-type" value="raster"></div>
  384. <div class="field"><label>地图封面 URL</label><input id="map-cover-url" value=""></div>
  385. <div class="field"><label>地图摘要</label><input id="map-summary" value="领秀城公园地图资源,包含默认体验活动与当前瓦片版本。"></div>
  386. </div>
  387. <div class="panel" style="padding:18px;">
  388. <div class="eyebrow">上传 / 导入首个瓦片版本</div>
  389. <div class="grid-3">
  390. <div class="field"><label>瓦片版本号</label><input id="tile-version" value="2026-04-07"></div>
  391. <div class="field"><label>瓦片根地址</label><input id="tile-base-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/"></div>
  392. <div class="field"><label>元数据地址</label><input id="tile-meta-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json"></div>
  393. </div>
  394. </div>
  395. <div class="actions">
  396. <button id="btn-create-map-asset">新增地图</button>
  397. <button class="secondary" id="btn-update-map-asset">保存地图修改</button>
  398. <button class="secondary" id="btn-import-tile">导入瓦片版本</button>
  399. <button class="ghost" id="btn-get-map-asset">重新读取地图详情</button>
  400. <button class="ghost" id="btn-close-map-editor">关闭</button>
  401. </div>
  402. </div>
  403. </div>
  404. <div class="modal-backdrop" id="place-editor-panel" hidden>
  405. <div class="modal-card">
  406. <div class="eyebrow">添加 / 编辑地点</div>
  407. <div class="grid-2">
  408. <div class="field"><label>管理地点 ID</label><input id="place-manage-id" value=""></div>
  409. <div class="field"><label>地点编码</label><input id="place-code" value="lxcb-001"></div>
  410. <div class="field"><label>地点名称</label><input id="place-name" value="领秀城公园"></div>
  411. <div class="field"><label>省份</label><select id="place-province"><option value="">加载中...</option></select></div>
  412. <div class="field"><label>城市</label><select id="place-city"><option value="">请先选择省份</option></select></div>
  413. <div class="field"><label>地点区域</label><input id="place-region" value="" readonly></div>
  414. </div>
  415. <div class="actions">
  416. <button id="btn-create-place">保存地点</button>
  417. <button class="ghost" id="btn-get-place">重新读取地点详情</button>
  418. <button class="ghost" id="btn-close-place-editor">关闭</button>
  419. </div>
  420. </div>
  421. </div>
  422. </section>
  423. <section class="panel ops-view" id="ingest">
  424. <div class="eyebrow">资源录入</div>
  425. <h3>统一纳管资源</h3>
  426. <p>运维只需要关心“录一个资源”,不需要关心它最终是 OSS 上传还是已有外链。录完之后都落成统一资源对象。</p>
  427. <div class="split">
  428. <div class="panel" style="padding:18px;">
  429. <div class="eyebrow">上传文件</div>
  430. <p>适合 KML、schema、manifest、本地静态包。backend 负责上传 OSS 并纳管。</p>
  431. <div class="grid-2">
  432. <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>
  433. <div class="field"><label>资源编码</label><input id="asset-code" value="lxcb-route-pack-2026-04-07"></div>
  434. <div class="field"><label>版本</label><input id="asset-version" value="2026-04-07"></div>
  435. <div class="field"><label>标题</label><input id="asset-title" value="领秀城多赛道 KML 包"></div>
  436. <div class="field"><label>状态</label><select id="asset-status"><option value="active">active</option><option value="draft">draft</option></select></div>
  437. <div class="field"><label>对象目录(可选)</label><input id="asset-object-dir" value="gotomars/kml/lxcb-001/2026-04-07"></div>
  438. <div class="field"><label>内容类型(可选)</label><input id="asset-content-type" value="application/vnd.google-earth.kml+xml"></div>
  439. <div class="field"><label>上传文件</label><input id="asset-file" type="file"></div>
  440. </div>
  441. <div class="field"><label>Metadata JSON(可选)</label><textarea id="asset-metadata">{}</textarea></div>
  442. <div class="actions"><button id="btn-upload-asset">上传并纳管资源</button></div>
  443. </div>
  444. <div class="panel" style="padding:18px;">
  445. <div class="eyebrow">登记外链</div>
  446. <p>适合正式 OSS / CDN 上已经存在的资源。登记后,活动侧和发布链统一只认受管资源对象。</p>
  447. <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>
  448. <div class="actions">
  449. <button class="secondary" id="btn-register-link">登记外链资源</button>
  450. <button class="ghost" id="btn-list-assets">查看受管资源</button>
  451. </div>
  452. <div class="hint">建议优先纳管:地图瓦片目录、KML、presentation schema、content bundle manifest。</div>
  453. </div>
  454. </div>
  455. </section>
  456. <section class="panel ops-view" id="courses">
  457. <div class="eyebrow">KML / 赛道管理</div>
  458. <h3>围绕当前地图管理 KML、赛道集和默认路线</h3>
  459. <p>KML 不是独立漂在外面的资源。运维上应该先选地图,再导入一批 KML,形成赛道集,然后查看默认路线和预览数据。</p>
  460. <div class="split">
  461. <div class="panel" style="padding:18px;">
  462. <div class="eyebrow">导入瓦片版本</div>
  463. <div class="grid-2">
  464. <div class="field"><label>当前地点编码</label><input value="复用上方“地图资源管理”的地点编码" readonly></div>
  465. <div class="field"><label>当前地图编码</label><input value="复用上方“地图资源管理”的地图编码" readonly></div>
  466. <div class="field"><label>瓦片版本号</label><input id="tile-version" value="2026-04-07"></div>
  467. <div class="field"><label>瓦片根地址</label><input id="tile-base-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/"></div>
  468. <div class="field"><label>元数据地址</label><input id="tile-meta-url" value="https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json"></div>
  469. </div>
  470. <div class="actions"><button id="btn-import-tile">导入瓦片版本</button></div>
  471. </div>
  472. <div class="panel tall" style="padding:18px;">
  473. <div class="eyebrow">批量导入 KML</div>
  474. <div class="grid-2">
  475. <div class="field"><label>Course Set Code</label><input id="course-set-code" value="lxcb-manual-2026-04-07"></div>
  476. <div class="field"><label>Course Set Name</label><input id="course-set-name" value="领秀城公园多赛道 2026-04-07"></div>
  477. <div class="field"><label>Mode</label><input id="course-mode" value="classic-sequential"></div>
  478. <div class="field"><label>Default Route Code</label><input id="default-route-code" value="route-variant-d"></div>
  479. </div>
  480. <div class="field">
  481. <label>KML Batch JSON</label>
  482. <textarea id="routes-json">[
  483. {"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"},
  484. {"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"},
  485. {"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"},
  486. {"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"}
  487. ]</textarea>
  488. </div>
  489. <div class="actions"><button id="btn-import-kml-batch">批量导入 KML</button></div>
  490. </div>
  491. </div>
  492. <div class="split">
  493. <div class="panel" style="padding:18px;">
  494. <div class="eyebrow">当前地图下赛道集</div>
  495. <div class="list" id="course-set-list"><div class="item"><strong>暂无赛道集</strong><div class="meta">读取地图详情或完成一轮 KML 导入后,这里会显示当前地图的赛道集。</div></div></div>
  496. </div>
  497. <div class="panel" style="padding:18px;">
  498. <div class="eyebrow">KML / 变体预览</div>
  499. <div class="field"><label>当前赛道集</label><div class="token-box" id="course-preview-course-set">-</div></div>
  500. <div class="field"><label>默认路线</label><div class="token-box" id="course-preview-default-route">-</div></div>
  501. <div class="field"><label>路线数量</label><div class="token-box" id="course-preview-variant-count">0</div></div>
  502. <div class="field"><label>路线摘要</label><div class="token-box" id="course-preview-variants">-</div></div>
  503. </div>
  504. </div>
  505. </section>
  506. <section class="panel ops-view" id="events">
  507. <div class="eyebrow">活动管理</div>
  508. <h3>先看活动列表和基础信息</h3>
  509. <p>活动管理先处理业务壳:名称、状态、是否默认体验、是否出现在活动列表,以及当前发布概况。资源绑定与发布准备统一去“活动编排”。</p>
  510. <div class="grid-3">
  511. <div class="field"><label>租户编码</label><input id="event-tenant-code" value="tenant_demo"></div>
  512. <div class="field"><label>活动标识 Slug</label><input id="event-slug" value="city-park-manual-variant"></div>
  513. <div class="field"><label>活动名称</label><input id="event-display-name" value="领秀城公园多赛道挑战"></div>
  514. <div class="field"><label>活动摘要</label><input id="event-summary" value="多赛道联调体验活动"></div>
  515. <div class="field"><label>活动状态</label><input id="event-status" value="active"></div>
  516. <div class="field"><label>活动 ID</label><input id="binding-event-id" value="evt_demo_variant_manual_001"></div>
  517. </div>
  518. <div class="actions">
  519. <button class="secondary" id="btn-list-events">读取活动列表</button>
  520. <button class="secondary" id="btn-create-event">新建活动</button>
  521. <button class="secondary" id="btn-update-event">更新活动</button>
  522. <button class="secondary" id="btn-get-event">读取活动</button>
  523. <button class="ghost" id="btn-open-compose-view">前往活动编排</button>
  524. </div>
  525. <div class="split">
  526. <div class="panel" style="padding:18px;">
  527. <div class="eyebrow">活动列表</div>
  528. <div class="list" id="event-list-main"><div class="item"><strong>暂无活动</strong><div class="meta">先点“读取活动列表”。</div></div></div>
  529. </div>
  530. <div class="panel" style="padding:18px;">
  531. <div class="eyebrow">当前活动概况</div>
  532. <div class="field"><label>当前发布版本</label><div class="token-box" id="event-preview-release">-</div></div>
  533. <div class="field"><label>当前 runtime</label><div class="token-box" id="event-preview-runtime">-</div></div>
  534. <div class="field"><label>当前展示定义</label><div class="token-box" id="event-preview-presentation">-</div></div>
  535. <div class="field"><label>当前内容包</label><div class="token-box" id="event-preview-content-bundle">-</div></div>
  536. </div>
  537. </div>
  538. </section>
  539. <section class="panel ops-view" id="compose">
  540. <div class="eyebrow">活动编排</div>
  541. <h3>绑定运行对象、展示定义和内容包</h3>
  542. <p>这里才进入发布前准备:给当前活动绑定 runtime、presentation、content bundle,确认默认 active 三元组,然后交给发布中心 build / publish。</p>
  543. <div class="grid-3">
  544. <div class="field"><label>Presentation ID</label><input id="presentation-id" value=""></div>
  545. <div class="field"><label>Content Bundle ID</label><input id="content-bundle-id" value=""></div>
  546. <div class="field"><label>Runtime Binding ID</label><input id="runtime-binding-id" value=""></div>
  547. <div class="field"><label>Presentation Title</label><input id="presentation-title" value="多赛道详情展示模板"></div>
  548. <div class="field"><label>Presentation Template Key</label><input id="presentation-template-key" value="event.detail.multi-variant"></div>
  549. <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>
  550. <div class="field"><label>Presentation Version</label><input id="presentation-version" value="v2026-04-07"></div>
  551. <div class="field"><label>Bundle Title</label><input id="bundle-title" value="多赛道结果内容包"></div>
  552. <div class="field"><label>Bundle Type</label><input id="bundle-type" value="result_media"></div>
  553. <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>
  554. <div class="field"><label>Bundle Version</label><input id="bundle-version" value="v2026-04-07"></div>
  555. </div>
  556. <div class="actions">
  557. <button id="btn-import-presentation">导入展示定义</button>
  558. <button id="btn-import-bundle">导入内容包</button>
  559. <button class="ghost" id="btn-save-defaults">保存活动默认绑定</button>
  560. <button class="ghost" id="btn-open-publish-view">前往发布中心</button>
  561. </div>
  562. </section>
  563. <section class="panel ops-view" id="publish">
  564. <div class="eyebrow">发布中心</div>
  565. <h3>统一 build / publish / rollback</h3>
  566. <p>运维后台不造第二条发布链,仍然复用现在这套 source、build、release 流程。</p>
  567. <div class="grid-3">
  568. <div class="field"><label>Pipeline Event ID</label><input id="pipeline-event-id" value="evt_demo_variant_manual_001"></div>
  569. <div class="field"><label>配置源 ID</label><input id="source-id" value=""></div>
  570. <div class="field"><label>回滚发布版本 ID</label><input id="rollback-release-id" value=""></div>
  571. </div>
  572. <div class="actions">
  573. <button class="secondary" id="btn-get-pipeline">读取发布链</button>
  574. <button id="btn-build-source">构建配置源</button>
  575. <button id="btn-publish-build">发布构建</button>
  576. <button class="ghost" id="btn-get-release">读取发布版本</button>
  577. <button class="ghost" id="btn-rollback-release">回滚发布</button>
  578. </div>
  579. </section>
  580. </main>
  581. <aside class="aside">
  582. <div class="field">
  583. <label>当前 Bearer Token</label>
  584. <div class="token-box" id="token-box">当前无令牌</div>
  585. </div>
  586. <section class="panel" style="padding:18px;">
  587. <div class="eyebrow">当前状态</div>
  588. <p>当前动作结果会写到这里,方便你快速判断到底卡在资源录入、活动绑定还是发布流程。</p>
  589. <div class="statusbox" id="status-box">待执行</div>
  590. </section>
  591. <section class="panel" style="padding:18px;">
  592. <div class="eyebrow">响应日志</div>
  593. <p>保留最后一次响应。运维排查时先看这里,不用再回调试工作台翻日志。</p>
  594. <div class="logbox" id="log-box">等待操作...</div>
  595. </section>
  596. <section class="panel" style="padding:18px;">
  597. <div class="eyebrow">最近受管资源</div>
  598. <div class="list" id="asset-list"><div class="item"><strong>暂无数据</strong><div class="meta">先执行刷新总览或查看受管资源</div></div></div>
  599. </section>
  600. <section class="panel" style="padding:18px;">
  601. <div class="eyebrow">最近活动</div>
  602. <div class="list" id="event-list"><div class="item"><strong>暂无数据</strong><div class="meta">先执行刷新总览</div></div></div>
  603. </section>
  604. </aside>
  605. </div>
  606. <script>
  607. const STORAGE_KEY = 'cmr-ops-workbench-v5';
  608. const state = {
  609. accessToken: '',
  610. activeView: 'overview',
  611. placeItems: [],
  612. mapItems: [],
  613. placeMapItems: [],
  614. regionOptions: [],
  615. mapEditorMode: 'none'
  616. };
  617. function $(id) { return document.getElementById(id); }
  618. function setActiveView(view) {
  619. state.activeView = view || 'overview';
  620. document.querySelectorAll('.ops-view').forEach(section => {
  621. section.classList.toggle('active', section.id === state.activeView);
  622. });
  623. document.querySelectorAll('.nav-link[data-view]').forEach(button => {
  624. button.classList.toggle('active', button.dataset.view === state.activeView);
  625. });
  626. }
  627. function setStatus(text, isError) {
  628. const el = $('status-box');
  629. el.textContent = text;
  630. el.className = 'statusbox ' + (isError ? 'status-error' : 'status-ok');
  631. }
  632. function writeLog(title, payload) {
  633. $('log-box').textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(payload, null, 2);
  634. }
  635. function syncToken() {
  636. $('token-box').textContent = state.accessToken
  637. ? ('Bearer ' + state.accessToken.slice(0, 36) + '...')
  638. : '开发环境默认免登录,可直接操作;如需验证运维账号链路,再执行手机号登录。';
  639. }
  640. function setMapEditorMode(mode) {
  641. state.mapEditorMode = mode || 'none';
  642. $('map-editor-panel').hidden = state.mapEditorMode !== 'map';
  643. $('place-editor-panel').hidden = state.mapEditorMode !== 'place';
  644. persist();
  645. }
  646. function getFieldMap() {
  647. return {
  648. opsMobile: $('ops-mobile'),
  649. opsCode: $('ops-code'),
  650. opsDisplayName: $('ops-display-name'),
  651. opsCountryCode: $('ops-country-code'),
  652. opsDeviceKey: $('ops-device-key'),
  653. opsRoleCode: $('ops-role-code'),
  654. eventId: $('event-id'),
  655. releaseId: $('release-id'),
  656. buildId: $('build-id'),
  657. managedPlaceId: $('managed-place-id'),
  658. managedMapId: $('managed-map-id'),
  659. mapPlaceSelect: $('map-place-select'),
  660. placeManageId: $('place-manage-id'),
  661. assetType: $('asset-type'),
  662. assetCode: $('asset-code'),
  663. assetVersion: $('asset-version'),
  664. assetTitle: $('asset-title'),
  665. assetStatus: $('asset-status'),
  666. assetObjectDir: $('asset-object-dir'),
  667. assetPublicUrl: $('asset-public-url'),
  668. assetContentType: $('asset-content-type'),
  669. assetMetadata: $('asset-metadata'),
  670. placeCode: $('place-code'),
  671. placeName: $('place-name'),
  672. placeProvince: $('place-province'),
  673. placeCity: $('place-city'),
  674. placeRegion: $('place-region'),
  675. mapCode: $('map-code'),
  676. mapName: $('map-name'),
  677. mapType: $('map-type'),
  678. mapCoverUrl: $('map-cover-url'),
  679. mapSummary: $('map-summary'),
  680. tileVersion: $('tile-version'),
  681. tileBaseUrl: $('tile-base-url'),
  682. tileMetaUrl: $('tile-meta-url'),
  683. courseSetCode: $('course-set-code'),
  684. courseSetName: $('course-set-name'),
  685. courseMode: $('course-mode'),
  686. defaultRouteCode: $('default-route-code'),
  687. routesJson: $('routes-json'),
  688. eventTenantCode: $('event-tenant-code'),
  689. eventSlug: $('event-slug'),
  690. eventDisplayName: $('event-display-name'),
  691. eventSummary: $('event-summary'),
  692. eventStatus: $('event-status'),
  693. bindingEventId: $('binding-event-id'),
  694. presentationId: $('presentation-id'),
  695. contentBundleId: $('content-bundle-id'),
  696. runtimeBindingId: $('runtime-binding-id'),
  697. presentationTitle: $('presentation-title'),
  698. presentationTemplateKey: $('presentation-template-key'),
  699. presentationSchemaUrl: $('presentation-schema-url'),
  700. presentationVersion: $('presentation-version'),
  701. bundleTitle: $('bundle-title'),
  702. bundleType: $('bundle-type'),
  703. bundleManifestUrl: $('bundle-manifest-url'),
  704. bundleVersion: $('bundle-version'),
  705. pipelineEventId: $('pipeline-event-id'),
  706. sourceId: $('source-id'),
  707. rollbackReleaseId: $('rollback-release-id')
  708. };
  709. }
  710. function persist() {
  711. const payload = { accessToken: state.accessToken, activeView: state.activeView };
  712. Object.entries(getFieldMap()).forEach(([key, node]) => payload[key] = node.value);
  713. localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
  714. }
  715. function restore() {
  716. const raw = localStorage.getItem(STORAGE_KEY);
  717. if (!raw) {
  718. syncToken();
  719. return;
  720. }
  721. try {
  722. const p = JSON.parse(raw);
  723. state.accessToken = p.accessToken || '';
  724. state.activeView = p.activeView || 'overview';
  725. Object.entries(getFieldMap()).forEach(([key, node]) => {
  726. if (p[key] !== undefined && p[key] !== null && p[key] !== '') node.value = p[key];
  727. });
  728. } catch (_) {}
  729. syncToken();
  730. setActiveView(state.activeView);
  731. }
  732. function syncPlaceRegion() {
  733. const provinceText = $('place-province').selectedOptions[0] ? $('place-province').selectedOptions[0].textContent : '';
  734. const cityText = $('place-city').selectedOptions[0] ? $('place-city').selectedOptions[0].textContent : '';
  735. $('place-region').value = [provinceText, cityText].filter(Boolean).join(' / ');
  736. persist();
  737. }
  738. function renderCityOptions(provinceCode, preferredCityCode) {
  739. const province = state.regionOptions.find(item => item.code === provinceCode);
  740. const cities = province ? province.cities || [] : [];
  741. $('place-city').innerHTML = cities.length
  742. ? cities.map(item => '<option value="' + item.code + '">' + item.name + '</option>').join('')
  743. : '<option value="">暂无城市</option>';
  744. if (preferredCityCode && cities.some(item => item.code === preferredCityCode)) {
  745. $('place-city').value = preferredCityCode;
  746. } else if (cities[0]) {
  747. $('place-city').value = cities[0].code;
  748. } else {
  749. $('place-city').value = '';
  750. }
  751. syncPlaceRegion();
  752. }
  753. function renderProvinceOptions(preferredProvinceCode, preferredCityCode) {
  754. $('place-province').innerHTML = state.regionOptions.length
  755. ? state.regionOptions.map(item => '<option value="' + item.code + '">' + item.name + '</option>').join('')
  756. : '<option value="">暂无省份</option>';
  757. if (preferredProvinceCode && state.regionOptions.some(item => item.code === preferredProvinceCode)) {
  758. $('place-province').value = preferredProvinceCode;
  759. } else if (state.regionOptions[0]) {
  760. $('place-province').value = state.regionOptions[0].code;
  761. } else {
  762. $('place-province').value = '';
  763. }
  764. renderCityOptions($('place-province').value, preferredCityCode);
  765. }
  766. async function loadRegionOptions() {
  767. const result = await request('GET', '/ops/admin/region-options');
  768. state.regionOptions = result.data || [];
  769. renderProvinceOptions($('place-province').value, $('place-city').value);
  770. return result;
  771. }
  772. function applyRegionText(regionText) {
  773. if (!regionText || !state.regionOptions.length) {
  774. return;
  775. }
  776. const [provinceText, cityText] = String(regionText).split('/').map(item => item.trim()).filter(Boolean);
  777. const province = state.regionOptions.find(item => item.name === provinceText);
  778. if (!province) {
  779. $('place-region').value = regionText;
  780. return;
  781. }
  782. $('place-province').value = province.code;
  783. renderCityOptions(province.code);
  784. const city = (province.cities || []).find(item => item.name === cityText);
  785. if (city) {
  786. $('place-city').value = city.code;
  787. }
  788. syncPlaceRegion();
  789. }
  790. async function request(method, url, body, isJSON = true) {
  791. const headers = {};
  792. let payload = body;
  793. if (state.accessToken) headers['Authorization'] = 'Bearer ' + state.accessToken;
  794. if (isJSON && body !== undefined) {
  795. headers['Content-Type'] = 'application/json';
  796. payload = JSON.stringify(body);
  797. }
  798. const resp = await fetch(url, { method, headers, body: payload });
  799. const text = await resp.text();
  800. let data = {};
  801. try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
  802. if (!resp.ok) throw { status: resp.status, body: data, method, url };
  803. return data;
  804. }
  805. function renderItems(rootId, items, render) {
  806. const root = $(rootId);
  807. if (!Array.isArray(items) || !items.length) {
  808. root.innerHTML = '<div class="item"><strong>暂无数据</strong><div class="meta">当前还没有可展示对象</div></div>';
  809. return;
  810. }
  811. root.innerHTML = items.map(render).join('');
  812. }
  813. function renderAssets(items) {
  814. renderItems('asset-list', items, item =>
  815. '<div class="item"><strong>' + (item.title || item.assetCode || item.id || '-') + '</strong>' +
  816. '<div class="meta">' + (item.assetType || '-') + ' / ' + (item.assetCode || '-') + ' / ' + (item.version || '-') + '</div>' +
  817. '<div class="meta">' + (item.publicUrl || '-') + '</div></div>'
  818. );
  819. }
  820. function renderEvents(items) {
  821. renderItems('event-list', items, item =>
  822. '<div class="item"><strong>' + (item.displayName || item.id || '-') + '</strong>' +
  823. '<div class="meta">' + (item.id || '-') + ' / ' + (item.status || '-') + '</div>' +
  824. '<div class="meta">' + (item.summary || '暂无摘要') + '</div></div>'
  825. );
  826. }
  827. function renderEventListMain(items) {
  828. renderItems('event-list-main', items, item =>
  829. '<div class="item"><strong>' + (item.displayName || item.id || '-') + '</strong>' +
  830. '<div class="meta">' + (item.id || '-') + ' / ' + (item.status || '-') + '</div>' +
  831. '<div class="meta">' + ((item.currentRelease && item.currentRelease.id) || '当前未发布') + '</div></div>'
  832. );
  833. }
  834. function renderPlaces(items) {
  835. const keyword = ($('place-search') && $('place-search').value || '').trim().toLowerCase();
  836. const filtered = !keyword ? items : (items || []).filter(item =>
  837. [item.name, item.code, item.region].filter(Boolean).join(' ').toLowerCase().includes(keyword)
  838. );
  839. renderItems('place-list', items, item =>
  840. '<div class="item selectable' + ((item.id || '') === $('managed-place-id').value ? ' active' : '') + '" data-place-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
  841. '<div class="meta">' + (item.id || '-') + ' / ' + (item.code || '-') + '</div>' +
  842. '<div class="meta">' + (item.region || '区域待补充') + '</div></div>'
  843. );
  844. renderItems('place-list', filtered, item =>
  845. '<div class="item selectable' + ((item.id || '') === $('managed-place-id').value ? ' active' : '') + '" data-place-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
  846. '<div class="meta">' + (item.id || '-') + ' / ' + (item.code || '-') + '</div>' +
  847. '<div class="meta">' + (item.region || '区域待补充') + '</div></div>'
  848. );
  849. }
  850. function renderMapAssets(items) {
  851. const keyword = ($('map-search') && $('map-search').value || '').trim().toLowerCase();
  852. const filtered = !keyword ? items : (items || []).filter(item =>
  853. [item.name, item.code, item.placeName].filter(Boolean).join(' ').toLowerCase().includes(keyword)
  854. );
  855. renderItems('map-list', filtered, item =>
  856. '<div class="item selectable' + ((item.id || '') === $('managed-map-id').value ? ' active' : '') + '" data-map-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
  857. '<div class="meta">' + (item.id || '-') + ' / ' + (item.code || '-') + ' / ' + (item.mapType || '-') + '</div>' +
  858. '<div class="meta">' + ((item.currentTileRelease && item.currentTileRelease.versionCode) || '当前无瓦片版本') + '</div></div>'
  859. );
  860. }
  861. function renderMapLibrary(items) {
  862. const keyword = ($('map-search') && $('map-search').value || '').trim().toLowerCase();
  863. const filtered = !keyword ? items : (items || []).filter(item =>
  864. [item.name, item.code, item.placeName].filter(Boolean).join(' ').toLowerCase().includes(keyword)
  865. );
  866. renderItems('map-library-list', filtered, item =>
  867. '<div class="item selectable' + ((item.id || '') === $('managed-map-id').value ? ' active' : '') + '" data-map-id="' + (item.id || '') + '"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
  868. '<div class="meta">' + (item.id || '-') + ' / ' + (item.placeName || '未关联地点') + '</div>' +
  869. '<div class="meta">' + ((item.currentTileRelease && item.currentTileRelease.versionCode) || '当前无瓦片版本') + '</div></div>'
  870. );
  871. }
  872. function renderMapPlaceSelect() {
  873. const items = Array.isArray(state.placeItems) ? state.placeItems : [];
  874. $('map-place-select').innerHTML = items.length
  875. ? items.map(item => '<option value="' + item.id + '">' + (item.name || item.code || item.id) + ' / ' + (item.region || '未设置区域') + '</option>').join('')
  876. : '<option value="">请先录入地点</option>';
  877. if ($('managed-place-id').value && items.some(item => item.id === $('managed-place-id').value)) {
  878. $('map-place-select').value = $('managed-place-id').value;
  879. } else if (items[0]) {
  880. $('map-place-select').value = items[0].id;
  881. $('managed-place-id').value = items[0].id;
  882. } else {
  883. $('map-place-select').value = '';
  884. }
  885. persist();
  886. }
  887. function renderLinkedEvents(items) {
  888. renderItems('linked-event-list', items, item =>
  889. '<div class="item"><strong>' + (item.title || item.eventId || '-') + '</strong>' +
  890. '<div class="meta">' + (item.eventId || '-') + ' / ' + (item.status || '-') + (item.isDefaultExperience ? ' / 默认体验' : '') + '</div>' +
  891. '<div class="meta">' + (item.currentReleaseId || '当前未发布') + ' / ' + (item.currentPresentation || '无展示定义') + ' / ' + (item.currentContentBundle || '无内容包') + '</div></div>'
  892. );
  893. }
  894. function renderCourseSets(items) {
  895. renderItems('course-set-list', items, item =>
  896. '<div class="item"><strong>' + (item.name || item.code || item.id || '-') + '</strong>' +
  897. '<div class="meta">' + (item.id || '-') + ' / ' + (item.mode || '-') + ' / ' + (item.status || '-') + '</div>' +
  898. '<div class="meta">' + ((item.currentVariant && (item.currentVariant.name || item.currentVariant.routeCode)) || '当前无默认路线') + '</div></div>'
  899. );
  900. }
  901. async function refreshOverviewAndLog(reason) {
  902. try {
  903. const result = await refreshOverview();
  904. writeLog(reason || 'refresh-overview', result);
  905. setStatus('已刷新:资源总览', false);
  906. } catch (error) {
  907. writeLog(reason || 'refresh-overview', { error });
  908. setStatus('失败:资源总览自动刷新', true);
  909. }
  910. }
  911. function setPreviewValue(id, value) {
  912. $(id).textContent = (value !== undefined && value !== null && value !== '') ? String(value) : '-';
  913. }
  914. function applyPipelineSummary(pipeline) {
  915. const sources = Array.isArray(pipeline.sources) ? pipeline.sources : [];
  916. const releases = Array.isArray(pipeline.releases) ? pipeline.releases : [];
  917. if (pipeline.currentRelease && pipeline.currentRelease.id) $('release-id').value = pipeline.currentRelease.id;
  918. if (sources[0] && sources[0].id) $('source-id').value = sources[0].id;
  919. if (releases[0] && releases[0].id) $('rollback-release-id').value = releases[0].id;
  920. setPreviewValue('overview-current-release', pipeline.currentRelease ? (pipeline.currentRelease.id || '-') : '当前未发布');
  921. 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 || '-')) : '当前未绑定');
  922. 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 || '-')) : '当前未绑定');
  923. 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 || '-')) : '当前未绑定');
  924. persist();
  925. }
  926. function applyEventDetail(detail) {
  927. if (!detail) return;
  928. if (detail.event && detail.event.id) {
  929. $('event-id').value = detail.event.id;
  930. $('binding-event-id').value = detail.event.id;
  931. $('pipeline-event-id').value = detail.event.id;
  932. setPreviewValue('overview-current-event', (detail.event.displayName || detail.event.id || '-') + ' / ' + (detail.event.id || '-'));
  933. }
  934. if (detail.event && detail.event.tenantCode) $('event-tenant-code').value = detail.event.tenantCode;
  935. if (detail.event && detail.event.slug) $('event-slug').value = detail.event.slug;
  936. if (detail.event && detail.event.displayName) $('event-display-name').value = detail.event.displayName;
  937. if (detail.event && detail.event.summary) $('event-summary').value = detail.event.summary;
  938. if (detail.event && detail.event.status) $('event-status').value = detail.event.status;
  939. if (detail.event && detail.event.currentRelease && detail.event.currentRelease.id) $('release-id').value = detail.event.currentRelease.id;
  940. if (detail.latestSource && detail.latestSource.id) $('source-id').value = detail.latestSource.id;
  941. if (detail.currentPresentation && detail.currentPresentation.id) $('presentation-id').value = detail.currentPresentation.id;
  942. if (detail.currentContentBundle && detail.currentContentBundle.id) $('content-bundle-id').value = detail.currentContentBundle.id;
  943. if (detail.currentRuntime && detail.currentRuntime.id) $('runtime-binding-id').value = detail.currentRuntime.id;
  944. setPreviewValue('event-preview-runtime', detail.currentRuntime ? (detail.currentRuntime.runtimeBindingId || detail.currentRuntime.id || '-') : '当前未绑定');
  945. setPreviewValue('event-preview-presentation', detail.currentPresentation ? ((detail.currentPresentation.name || '-') + ' / ' + (detail.currentPresentation.presentationId || detail.currentPresentation.id || '-')) : '当前未绑定');
  946. setPreviewValue('event-preview-content-bundle', detail.currentContentBundle ? ((detail.currentContentBundle.name || '-') + ' / ' + (detail.currentContentBundle.contentBundleId || detail.currentContentBundle.id || '-')) : '当前未绑定');
  947. setPreviewValue('event-preview-release', detail.event && detail.event.currentRelease ? (detail.event.currentRelease.id || '-') : '当前未发布');
  948. persist();
  949. }
  950. function applyPlaceDetail(detail) {
  951. if (!detail || !detail.place) return;
  952. $('managed-place-id').value = detail.place.id || '';
  953. $('place-manage-id').value = detail.place.id || '';
  954. $('place-code').value = detail.place.code || $('place-code').value;
  955. $('place-name').value = detail.place.name || $('place-name').value;
  956. $('place-region').value = detail.place.region || $('place-region').value;
  957. applyRegionText(detail.place.region || '');
  958. state.placeMapItems = detail.mapAssets || [];
  959. renderMapAssets(state.placeMapItems);
  960. if (Array.isArray(detail.mapAssets) && detail.mapAssets[0] && detail.mapAssets[0].id) {
  961. $('managed-map-id').value = detail.mapAssets[0].id;
  962. }
  963. setPreviewValue('map-preview-place', (detail.place.name || '-') + ' / ' + (detail.place.code || '-'));
  964. renderMapPlaceSelect();
  965. persist();
  966. }
  967. function applyMapAssetDetail(detail) {
  968. if (!detail || !detail.mapAsset) return;
  969. $('managed-map-id').value = detail.mapAsset.id || '';
  970. $('map-code').value = detail.mapAsset.code || $('map-code').value;
  971. $('map-name').value = detail.mapAsset.name || $('map-name').value;
  972. $('map-type').value = detail.mapAsset.mapType || $('map-type').value;
  973. $('map-cover-url').value = detail.mapAsset.coverUrl || $('map-cover-url').value;
  974. $('map-summary').value = detail.mapAsset.description || $('map-summary').value;
  975. const currentTile = detail.mapAsset.currentTileRelease || (Array.isArray(detail.tileReleases) ? detail.tileReleases[0] : null);
  976. if (currentTile && currentTile.id) {
  977. $('tile-version').value = currentTile.versionCode || $('tile-version').value;
  978. $('tile-base-url').value = currentTile.tileBaseUrl || $('tile-base-url').value;
  979. $('tile-meta-url').value = currentTile.metaUrl || $('tile-meta-url').value;
  980. }
  981. setPreviewValue('map-preview-map', (detail.mapAsset.name || '-') + ' / ' + (detail.mapAsset.code || '-'));
  982. setPreviewValue('map-preview-tile-version', currentTile ? (currentTile.versionCode || '-') : '-');
  983. setPreviewValue('map-preview-tile-base', currentTile ? (currentTile.tileBaseUrl || '-') : '-');
  984. setPreviewValue('map-preview-meta', currentTile ? (currentTile.metaUrl || '-') : '-');
  985. renderCourseSets(detail.courseSets || []);
  986. const linked = Array.isArray(detail.linkedEvents) ? detail.linkedEvents : [];
  987. const defaultCount = linked.filter(item => !!item.isDefaultExperience).length;
  988. setPreviewValue('map-preview-linked-count', linked.length);
  989. setPreviewValue('map-preview-linked-summary', linked.length ? linked.slice(0, 4).map(item => (item.title || item.eventId || '-') + (item.isDefaultExperience ? ' / 默认体验' : '')).join('\n') : '当前未关联活动');
  990. setPreviewValue('map-preview-default-count', defaultCount || $('map-preview-default-count').textContent || 0);
  991. persist();
  992. }
  993. function applyMapExperienceDetail(detail) {
  994. if (!detail) return;
  995. setPreviewValue('map-preview-default-count', detail.defaultExperienceCount || 0);
  996. const items = Array.isArray(detail.defaultExperiences) ? detail.defaultExperiences : [];
  997. setPreviewValue(
  998. 'map-preview-default-events',
  999. items.length
  1000. ? items.map(item => (item.title || item.eventId || '-') + ' / ' + (item.status || item.statusCode || '-')).join('\n')
  1001. : '当前无默认活动'
  1002. );
  1003. }
  1004. function applyCourseImportDetail(result) {
  1005. if (!result) return;
  1006. if (result.courseSet && result.courseSet.id) {
  1007. setPreviewValue('course-preview-course-set', (result.courseSet.name || '-') + ' / ' + (result.courseSet.id || '-'));
  1008. setPreviewValue('course-preview-default-route', (result.courseSet.currentVariant && (result.courseSet.currentVariant.routeCode || result.courseSet.currentVariant.name)) || $('default-route-code').value || '-');
  1009. }
  1010. const variants = Array.isArray(result.variants) ? result.variants : [];
  1011. setPreviewValue('course-preview-variant-count', variants.length);
  1012. setPreviewValue('course-preview-variants', variants.length ? variants.map(item => (item.name || '-') + ' / ' + (item.routeCode || '-') + ' / ' + (item.status || '-')).join('\n') : '暂无路线');
  1013. }
  1014. async function run(title, fn) {
  1015. setStatus('执行中:' + title, false);
  1016. try {
  1017. const result = await fn();
  1018. writeLog(title, result);
  1019. setStatus('完成:' + title, false);
  1020. persist();
  1021. return result;
  1022. } catch (error) {
  1023. const serializedError = (error && typeof error === 'object')
  1024. ? {
  1025. name: error.name || 'Error',
  1026. message: error.message || '',
  1027. stack: error.stack || '',
  1028. status: error.status,
  1029. body: error.body,
  1030. method: error.method,
  1031. url: error.url
  1032. }
  1033. : { message: String(error) };
  1034. writeLog(title, { error: serializedError });
  1035. setStatus('失败:' + title, true);
  1036. return null;
  1037. }
  1038. }
  1039. async function refreshOverview() {
  1040. const [summaryResp, assetsResp, eventsResp, pipelineResp] = await Promise.all([
  1041. request('GET', '/ops/admin/summary'),
  1042. request('GET', '/ops/admin/assets'),
  1043. request('GET', '/ops/admin/events?limit=12'),
  1044. request('GET', '/ops/admin/events/' + encodeURIComponent($('event-id').value) + '/pipeline')
  1045. ]);
  1046. const summary = summaryResp.data || {};
  1047. $('metric-assets').textContent = String(summary.managedAssets || 0);
  1048. $('metric-places').textContent = String(summary.places || 0);
  1049. $('metric-map-assets').textContent = String(summary.mapAssets || 0);
  1050. $('metric-tile-releases').textContent = String(summary.tileReleases || 0);
  1051. $('metric-course-sets').textContent = String(summary.courseSets || 0);
  1052. $('metric-course-variants').textContent = String(summary.courseVariants || 0);
  1053. $('metric-events').textContent = String(summary.events || 0);
  1054. $('metric-default-events').textContent = String(summary.defaultEvents || 0);
  1055. $('metric-published-events').textContent = String(summary.publishedEvents || 0);
  1056. $('metric-config-sources').textContent = String(summary.configSources || 0);
  1057. $('metric-runtime-bindings').textContent = String(summary.runtimeBindings || 0);
  1058. $('metric-pipeline-releases').textContent = String(summary.releases || 0);
  1059. $('metric-presentations').textContent = String(summary.presentations || 0);
  1060. $('metric-content-bundles').textContent = String(summary.contentBundles || 0);
  1061. $('metric-ops-users').textContent = String(summary.opsUsers || 0);
  1062. renderAssets(assetsResp.data || []);
  1063. renderEvents(eventsResp.data || []);
  1064. applyPipelineSummary(pipelineResp.data || {});
  1065. return {
  1066. summary,
  1067. assets: (assetsResp.data || []).length,
  1068. events: (eventsResp.data || []).length,
  1069. pipeline: pipelineResp.data || {}
  1070. };
  1071. }
  1072. async function loadPlaces() {
  1073. const result = await request('GET', '/ops/admin/places?limit=20');
  1074. state.placeItems = result.data || [];
  1075. renderPlaces(state.placeItems);
  1076. renderMapPlaceSelect();
  1077. if (Array.isArray(state.placeItems) && state.placeItems[0] && !($('managed-place-id').value)) {
  1078. $('managed-place-id').value = state.placeItems[0].id;
  1079. }
  1080. persist();
  1081. return result;
  1082. }
  1083. async function loadMapAssets() {
  1084. const result = await request('GET', '/ops/admin/map-assets?limit=30');
  1085. state.mapItems = result.data || [];
  1086. renderMapLibrary(state.mapItems);
  1087. if (Array.isArray(state.mapItems) && state.mapItems[0] && !($('managed-map-id').value)) {
  1088. $('managed-map-id').value = state.mapItems[0].id;
  1089. }
  1090. persist();
  1091. return result;
  1092. }
  1093. async function loadPlaceDetail(placeID) {
  1094. const id = placeID || $('place-manage-id').value || $('managed-place-id').value;
  1095. if (!id) throw new Error('请先提供管理地点 ID');
  1096. $('place-manage-id').value = id;
  1097. $('managed-place-id').value = id;
  1098. const result = await request('GET', '/ops/admin/places/' + encodeURIComponent(id));
  1099. applyPlaceDetail(result.data || {});
  1100. renderPlaces(state.placeItems);
  1101. renderMapAssets(state.placeMapItems);
  1102. persist();
  1103. return result;
  1104. }
  1105. async function loadMapAssetDetail(mapID) {
  1106. const id = mapID || $('managed-map-id').value;
  1107. if (!id) throw new Error('请先提供管理地图 ID');
  1108. $('managed-map-id').value = id;
  1109. setMapEditorMode('none');
  1110. const [mapResult, experienceResult] = await Promise.all([
  1111. request('GET', '/ops/admin/map-assets/' + encodeURIComponent(id)),
  1112. request('GET', '/experience-maps/' + encodeURIComponent(id))
  1113. ]);
  1114. applyMapAssetDetail(mapResult.data || {});
  1115. applyMapExperienceDetail(experienceResult.data || {});
  1116. renderMapLibrary(state.mapItems);
  1117. renderMapAssets(state.placeMapItems);
  1118. $('map-detail-modal').hidden = false;
  1119. persist();
  1120. return { map: mapResult.data || {}, experience: experienceResult.data || {} };
  1121. }
  1122. $('btn-open-create-map').onclick = () => {
  1123. $('map-detail-modal').hidden = true;
  1124. $('managed-map-id').value = '';
  1125. $('map-code').value = 'map-' + Date.now();
  1126. if ($('map-place-select').value) {
  1127. $('managed-place-id').value = $('map-place-select').value;
  1128. }
  1129. setMapEditorMode('map');
  1130. persist();
  1131. setStatus('已打开:添加地图', false);
  1132. };
  1133. $('btn-open-create-place').onclick = () => {
  1134. $('map-detail-modal').hidden = true;
  1135. $('place-manage-id').value = '';
  1136. $('managed-place-id').value = '';
  1137. setMapEditorMode('place');
  1138. persist();
  1139. setStatus('已打开:添加地点', false);
  1140. };
  1141. $('btn-close-map-editor').onclick = () => {
  1142. setMapEditorMode('none');
  1143. setStatus('已收起:地图编辑区', false);
  1144. };
  1145. $('btn-close-place-editor').onclick = () => {
  1146. setMapEditorMode('none');
  1147. setStatus('已收起:地点编辑区', false);
  1148. };
  1149. $('btn-close-map-detail').onclick = () => {
  1150. $('map-detail-modal').hidden = true;
  1151. setStatus('已关闭:地图详情', false);
  1152. };
  1153. $('btn-open-map-editor-from-detail').onclick = () => {
  1154. $('map-detail-modal').hidden = true;
  1155. setMapEditorMode('map');
  1156. setStatus('已打开:地图编辑', false);
  1157. };
  1158. $('btn-open-map-tile-from-detail').onclick = () => {
  1159. $('map-detail-modal').hidden = true;
  1160. setMapEditorMode('map');
  1161. setStatus('已打开:瓦片版本导入', false);
  1162. };
  1163. $('btn-create-place').onclick = () => run('create-place', async () => {
  1164. const result = await request('POST', '/ops/admin/places', {
  1165. code: $('place-code').value,
  1166. name: $('place-name').value,
  1167. region: $('place-region').value || undefined,
  1168. status: 'active'
  1169. });
  1170. if (result && result.data && result.data.id) {
  1171. $('managed-place-id').value = result.data.id;
  1172. $('place-manage-id').value = result.data.id;
  1173. }
  1174. await loadPlaces();
  1175. await refreshOverview();
  1176. setMapEditorMode('none');
  1177. persist();
  1178. return result;
  1179. });
  1180. $('btn-get-place').onclick = () => run('get-place', () => loadPlaceDetail());
  1181. $('btn-create-map-asset').onclick = () => run('create-map-asset', async () => {
  1182. const placeId = $('map-place-select').value || $('managed-place-id').value;
  1183. if (!placeId) throw new Error('请先选择地点');
  1184. $('managed-place-id').value = placeId;
  1185. const result = await request('POST', '/ops/admin/places/' + encodeURIComponent(placeId) + '/map-assets', {
  1186. code: $('map-code').value,
  1187. name: $('map-name').value,
  1188. mapType: $('map-type').value,
  1189. coverUrl: $('map-cover-url').value || undefined,
  1190. description: $('map-summary').value || undefined,
  1191. status: 'active'
  1192. });
  1193. if (result && result.data && result.data.id) {
  1194. $('managed-map-id').value = result.data.id;
  1195. }
  1196. await loadMapAssets();
  1197. if ($('managed-map-id').value) {
  1198. await loadMapAssetDetail($('managed-map-id').value);
  1199. }
  1200. setMapEditorMode('none');
  1201. persist();
  1202. return result;
  1203. });
  1204. $('btn-update-map-asset').onclick = () => run('update-map-asset', async () => {
  1205. if (!$('managed-map-id').value) throw new Error('请先读取地图详情,拿到管理地图 ID');
  1206. const result = await request('PUT', '/ops/admin/map-assets/' + encodeURIComponent($('managed-map-id').value), {
  1207. code: $('map-code').value,
  1208. name: $('map-name').value,
  1209. mapType: $('map-type').value,
  1210. coverUrl: $('map-cover-url').value || undefined,
  1211. description: $('map-summary').value || undefined,
  1212. status: 'active'
  1213. });
  1214. await refreshOverview();
  1215. await loadMapAssetDetail($('managed-map-id').value);
  1216. setMapEditorMode('none');
  1217. return result;
  1218. });
  1219. $('btn-get-map-asset').onclick = () => run('get-map-asset', () => loadMapAssetDetail());
  1220. $('btn-refresh-map-area').onclick = () => run('refresh-map-area', async () => {
  1221. const [regionsResult, placesResult, mapsResult] = await Promise.all([loadRegionOptions(), loadPlaces(), loadMapAssets()]);
  1222. $('map-detail-modal').hidden = true;
  1223. if ($('place-manage-id').value || $('managed-place-id').value) {
  1224. await loadPlaceDetail($('managed-place-id').value);
  1225. }
  1226. if ($('managed-map-id').value) {
  1227. await loadMapAssetDetail($('managed-map-id').value);
  1228. }
  1229. return {
  1230. regions: ((regionsResult && regionsResult.data) || []).length,
  1231. places: ((placesResult && placesResult.data) || []).length,
  1232. maps: ((mapsResult && mapsResult.data) || []).length,
  1233. };
  1234. });
  1235. $('btn-send-ops-code').onclick = () => run('ops-send-sms-code', async () => {
  1236. return await request('POST', '/ops/auth/sms/send', {
  1237. countryCode: $('ops-country-code').value,
  1238. mobile: $('ops-mobile').value,
  1239. deviceKey: $('ops-device-key').value,
  1240. scene: 'ops_login'
  1241. });
  1242. });
  1243. $('btn-register-ops').onclick = () => run('ops-register', async () => {
  1244. const result = await request('POST', '/ops/auth/register', {
  1245. countryCode: $('ops-country-code').value,
  1246. mobile: $('ops-mobile').value,
  1247. code: $('ops-code').value,
  1248. deviceKey: $('ops-device-key').value,
  1249. displayName: $('ops-display-name').value
  1250. });
  1251. state.accessToken = result.data.tokens.accessToken;
  1252. $('ops-role-code').value = result.data.user.roleCode || '';
  1253. syncToken();
  1254. return result;
  1255. });
  1256. $('btn-open-events-view').onclick = () => {
  1257. setActiveView('events');
  1258. persist();
  1259. setStatus('已切到:活动管理', false);
  1260. };
  1261. $('btn-open-compose-view').onclick = () => {
  1262. setActiveView('compose');
  1263. persist();
  1264. setStatus('已切到:活动编排', false);
  1265. };
  1266. $('btn-open-publish-view').onclick = () => {
  1267. setActiveView('publish');
  1268. persist();
  1269. setStatus('已切到:发布中心', false);
  1270. };
  1271. $('btn-login-ops').onclick = () => run('ops-login-sms', async () => {
  1272. const result = await request('POST', '/ops/auth/login/sms', {
  1273. countryCode: $('ops-country-code').value,
  1274. mobile: $('ops-mobile').value,
  1275. code: $('ops-code').value,
  1276. deviceKey: $('ops-device-key').value
  1277. });
  1278. state.accessToken = result.data.tokens.accessToken;
  1279. $('ops-role-code').value = result.data.user.roleCode || '';
  1280. syncToken();
  1281. return result;
  1282. });
  1283. $('btn-clear-token').onclick = () => {
  1284. state.accessToken = '';
  1285. syncToken();
  1286. persist();
  1287. setStatus('已清空 token', false);
  1288. };
  1289. $('btn-refresh-overview').onclick = () => run('refresh-overview', refreshOverview);
  1290. $('btn-upload-asset').onclick = () => run('upload-asset', async () => {
  1291. const file = $('asset-file').files[0];
  1292. if (!file) throw new Error('请选择文件');
  1293. const form = new FormData();
  1294. form.append('assetType', $('asset-type').value);
  1295. form.append('assetCode', $('asset-code').value);
  1296. form.append('version', $('asset-version').value);
  1297. form.append('title', $('asset-title').value);
  1298. form.append('objectDir', $('asset-object-dir').value);
  1299. form.append('status', $('asset-status').value);
  1300. form.append('metadataJson', $('asset-metadata').value || '{}');
  1301. form.append('file', file);
  1302. const result = await request('POST', '/ops/admin/assets/upload', form, false);
  1303. await refreshOverview();
  1304. return result;
  1305. });
  1306. $('btn-register-link').onclick = () => run('register-link', async () => {
  1307. const result = await request('POST', '/ops/admin/assets/register-link', {
  1308. assetType: $('asset-type').value,
  1309. assetCode: $('asset-code').value,
  1310. version: $('asset-version').value,
  1311. title: $('asset-title').value,
  1312. publicUrl: $('asset-public-url').value,
  1313. contentType: $('asset-content-type').value || undefined,
  1314. status: $('asset-status').value,
  1315. metadata: JSON.parse($('asset-metadata').value || '{}')
  1316. });
  1317. await refreshOverview();
  1318. return result;
  1319. });
  1320. $('btn-list-assets').onclick = () => run('list-assets', async () => {
  1321. const result = await request('GET', '/ops/admin/assets');
  1322. renderAssets(result.data || []);
  1323. return result;
  1324. });
  1325. $('btn-import-tile').onclick = () => run('import-tile-release', async () => {
  1326. const result = await request('POST', '/ops/admin/ops/tile-releases/import', {
  1327. placeCode: $('place-code').value,
  1328. placeName: $('place-name').value,
  1329. mapAssetCode: $('map-code').value,
  1330. mapAssetName: $('map-name').value,
  1331. mapType: $('map-type').value,
  1332. versionCode: $('tile-version').value,
  1333. status: 'active',
  1334. tileBaseUrl: $('tile-base-url').value,
  1335. metaUrl: $('tile-meta-url').value,
  1336. setAsCurrent: true
  1337. });
  1338. if (result && result.data) {
  1339. if (result.data.place && result.data.place.id) $('managed-place-id').value = result.data.place.id;
  1340. if (result.data.mapAsset && result.data.mapAsset.id) $('managed-map-id').value = result.data.mapAsset.id;
  1341. applyMapAssetDetail({
  1342. mapAsset: result.data.mapAsset,
  1343. tileReleases: result.data.tileRelease ? [result.data.tileRelease] : []
  1344. });
  1345. }
  1346. $('map-detail-modal').hidden = false;
  1347. setMapEditorMode('none');
  1348. persist();
  1349. return result;
  1350. });
  1351. $('btn-import-kml-batch').onclick = () => run('import-kml-batch', async () => {
  1352. const result = await request('POST', '/ops/admin/ops/course-sets/import-kml-batch', {
  1353. placeCode: $('place-code').value,
  1354. placeName: $('place-name').value,
  1355. mapAssetCode: $('map-code').value,
  1356. mapAssetName: $('map-name').value,
  1357. mapType: $('map-type').value,
  1358. courseSetCode: $('course-set-code').value,
  1359. courseSetName: $('course-set-name').value,
  1360. mode: $('course-mode').value,
  1361. status: 'active',
  1362. defaultRouteCode: $('default-route-code').value,
  1363. routes: JSON.parse($('routes-json').value || '[]')
  1364. });
  1365. applyCourseImportDetail(result.data || {});
  1366. return result;
  1367. });
  1368. $('btn-list-events').onclick = () => run('list-events', async () => {
  1369. const result = await request('GET', '/ops/admin/events?limit=30');
  1370. renderEvents(result.data || []);
  1371. renderEventListMain(result.data || []);
  1372. if (Array.isArray(result.data) && result.data[0] && result.data[0].id) {
  1373. $('binding-event-id').value = result.data[0].id;
  1374. $('pipeline-event-id').value = result.data[0].id;
  1375. }
  1376. persist();
  1377. return result;
  1378. });
  1379. $('btn-create-event').onclick = () => run('create-event', async () => {
  1380. const result = await request('POST', '/ops/admin/events', {
  1381. tenantCode: $('event-tenant-code').value || null,
  1382. slug: $('event-slug').value,
  1383. displayName: $('event-display-name').value,
  1384. summary: $('event-summary').value || null,
  1385. status: $('event-status').value
  1386. });
  1387. if (result && result.data) {
  1388. applyEventDetail({ event: result.data });
  1389. }
  1390. persist();
  1391. await refreshOverview();
  1392. return result;
  1393. });
  1394. $('btn-update-event').onclick = () => run('update-event', async () => {
  1395. const eventId = $('binding-event-id').value.trim();
  1396. if (!eventId) throw new Error('请先填写活动 ID');
  1397. const result = await request('PUT', '/ops/admin/events/' + encodeURIComponent(eventId), {
  1398. tenantCode: $('event-tenant-code').value || null,
  1399. slug: $('event-slug').value,
  1400. displayName: $('event-display-name').value,
  1401. summary: $('event-summary').value || null,
  1402. status: $('event-status').value
  1403. });
  1404. if (result && result.data) {
  1405. applyEventDetail({ event: result.data });
  1406. }
  1407. persist();
  1408. await refreshOverview();
  1409. return result;
  1410. });
  1411. $('btn-get-event').onclick = () => run('get-event', async () => {
  1412. const result = await request('GET', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value));
  1413. applyEventDetail(result.data || {});
  1414. return result;
  1415. });
  1416. $('btn-import-presentation').onclick = () => run('import-presentation', async () => {
  1417. const result = await request('POST', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value) + '/presentations/import', {
  1418. title: $('presentation-title').value,
  1419. templateKey: $('presentation-template-key').value,
  1420. sourceType: 'schema',
  1421. schemaUrl: $('presentation-schema-url').value,
  1422. version: $('presentation-version').value,
  1423. status: 'active',
  1424. isDefault: true
  1425. });
  1426. if (result && result.data && result.data.id) $('presentation-id').value = result.data.id;
  1427. persist();
  1428. return result;
  1429. });
  1430. $('btn-import-bundle').onclick = () => run('import-content-bundle', async () => {
  1431. const manifestUrl = $('bundle-manifest-url').value;
  1432. const result = await request('POST', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value) + '/content-bundles/import', {
  1433. title: $('bundle-title').value,
  1434. bundleType: $('bundle-type').value,
  1435. sourceType: 'manifest',
  1436. manifestUrl: manifestUrl,
  1437. version: $('bundle-version').value,
  1438. status: 'active',
  1439. isDefault: true,
  1440. assetManifest: { manifestUrl }
  1441. });
  1442. if (result && result.data && result.data.id) $('content-bundle-id').value = result.data.id;
  1443. persist();
  1444. return result;
  1445. });
  1446. $('btn-save-defaults').onclick = () => run('save-event-defaults', async () => {
  1447. return await request('POST', '/ops/admin/events/' + encodeURIComponent($('binding-event-id').value) + '/defaults', {
  1448. presentationId: $('presentation-id').value || undefined,
  1449. contentBundleId: $('content-bundle-id').value || undefined,
  1450. runtimeBindingId: $('runtime-binding-id').value || undefined
  1451. });
  1452. });
  1453. $('btn-get-pipeline').onclick = () => run('get-pipeline', async () => {
  1454. const result = await request('GET', '/ops/admin/events/' + encodeURIComponent($('pipeline-event-id').value) + '/pipeline');
  1455. applyPipelineSummary(result.data || {});
  1456. return result;
  1457. });
  1458. $('btn-build-source').onclick = () => run('build-source', async () => {
  1459. if (!$('source-id').value) throw new Error('请先提供配置源 ID');
  1460. const result = await request('POST', '/ops/admin/sources/' + encodeURIComponent($('source-id').value) + '/build');
  1461. if (result && result.data && result.data.id) $('build-id').value = result.data.id;
  1462. persist();
  1463. return result;
  1464. });
  1465. $('btn-publish-build').onclick = () => run('publish-build', async () => {
  1466. if (!$('build-id').value) throw new Error('请先提供构建 ID');
  1467. const result = await request('POST', '/ops/admin/builds/' + encodeURIComponent($('build-id').value) + '/publish', {
  1468. runtimeBindingId: $('runtime-binding-id').value || undefined,
  1469. presentationId: $('presentation-id').value || undefined,
  1470. contentBundleId: $('content-bundle-id').value || undefined
  1471. });
  1472. if (result && result.data && result.data.id) {
  1473. $('release-id').value = result.data.id;
  1474. $('rollback-release-id').value = result.data.id;
  1475. }
  1476. persist();
  1477. return result;
  1478. });
  1479. $('btn-get-release').onclick = () => run('get-release', async () => {
  1480. if (!$('release-id').value) throw new Error('请先提供发布版本 ID');
  1481. return await request('GET', '/ops/admin/releases/' + encodeURIComponent($('release-id').value));
  1482. });
  1483. $('btn-rollback-release').onclick = () => run('rollback-release', async () => {
  1484. if (!$('rollback-release-id').value) throw new Error('请先提供待回滚的发布版本 ID');
  1485. return await request('POST', '/ops/admin/events/' + encodeURIComponent($('pipeline-event-id').value) + '/rollback', {
  1486. releaseId: $('rollback-release-id').value
  1487. });
  1488. });
  1489. document.querySelectorAll('input, textarea, select').forEach(node => {
  1490. node.addEventListener('change', persist);
  1491. node.addEventListener('input', persist);
  1492. });
  1493. ['place-search', 'map-search'].forEach(id => {
  1494. $(id).addEventListener('input', () => {
  1495. renderPlaces(state.placeItems);
  1496. renderMapLibrary(state.mapItems);
  1497. renderMapAssets(state.placeMapItems);
  1498. });
  1499. });
  1500. $('place-province').addEventListener('change', () => {
  1501. renderCityOptions($('place-province').value);
  1502. });
  1503. $('place-city').addEventListener('change', syncPlaceRegion);
  1504. $('map-place-select').addEventListener('change', () => {
  1505. $('managed-place-id').value = $('map-place-select').value || '';
  1506. persist();
  1507. });
  1508. $('place-list').addEventListener('click', event => {
  1509. const item = event.target.closest('[data-place-id]');
  1510. if (!item) return;
  1511. void run('get-place', () => loadPlaceDetail(item.dataset.placeId));
  1512. });
  1513. ['map-list', 'map-library-list'].forEach(id => {
  1514. $(id).addEventListener('click', event => {
  1515. const item = event.target.closest('[data-map-id]');
  1516. if (!item) return;
  1517. void run('get-map-asset', () => loadMapAssetDetail(item.dataset.mapId));
  1518. });
  1519. });
  1520. ['map-detail-modal', 'map-editor-panel', 'place-editor-panel'].forEach(id => {
  1521. $(id).addEventListener('click', event => {
  1522. if (event.target !== $(id)) return;
  1523. if (id === 'map-detail-modal') {
  1524. $('map-detail-modal').hidden = true;
  1525. return;
  1526. }
  1527. setMapEditorMode('none');
  1528. });
  1529. });
  1530. document.querySelectorAll('.nav-link[data-view]').forEach(button => {
  1531. button.addEventListener('click', () => {
  1532. setActiveView(button.dataset.view);
  1533. persist();
  1534. if (button.dataset.view === 'overview') {
  1535. void refreshOverviewAndLog('refresh-overview');
  1536. } else if (button.dataset.view === 'maps') {
  1537. void run('refresh-map-area', async () => {
  1538. const [regionsResult, placesResult, mapsResult] = await Promise.all([loadRegionOptions(), loadPlaces(), loadMapAssets()]);
  1539. return {
  1540. regions: ((regionsResult && regionsResult.data) || []).length,
  1541. places: ((placesResult && placesResult.data) || []).length,
  1542. maps: ((mapsResult && mapsResult.data) || []).length,
  1543. };
  1544. });
  1545. }
  1546. });
  1547. });
  1548. restore();
  1549. setActiveView(state.activeView);
  1550. setMapEditorMode('none');
  1551. renderPlaces([]);
  1552. renderMapAssets([]);
  1553. renderMapLibrary([]);
  1554. renderCourseSets([]);
  1555. renderAssets([]);
  1556. renderEvents([]);
  1557. renderEventListMain([]);
  1558. writeLog('ops-workbench-ready', {
  1559. ok: true,
  1560. hint: '开发环境默认免登录。建议顺序:先看资源总览 -> 地图/地点管理 -> 路线资源管理 -> 活动管理 / 活动编排 -> 发布中心。'
  1561. });
  1562. void refreshOverviewAndLog('refresh-overview');
  1563. void loadRegionOptions();
  1564. if (state.activeView === 'maps') {
  1565. void run('refresh-map-area', async () => {
  1566. const [regionsResult, placesResult, mapsResult] = await Promise.all([loadRegionOptions(), loadPlaces(), loadMapAssets()]);
  1567. return {
  1568. regions: ((regionsResult && regionsResult.data) || []).length,
  1569. places: ((placesResult && placesResult.data) || []).length,
  1570. maps: ((mapsResult && mapsResult.data) || []).length,
  1571. };
  1572. });
  1573. }
  1574. </script>
  1575. </body>
  1576. </html>`