dev_handler.go 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588
  1. package handlers
  2. import (
  3. "net/http"
  4. "cmr-backend/internal/httpx"
  5. "cmr-backend/internal/service"
  6. )
  7. type DevHandler struct {
  8. devService *service.DevService
  9. }
  10. func NewDevHandler(devService *service.DevService) *DevHandler {
  11. return &DevHandler{devService: devService}
  12. }
  13. func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) {
  14. result, err := h.devService.BootstrapDemo(r.Context())
  15. if err != nil {
  16. httpx.WriteError(w, err)
  17. return
  18. }
  19. httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
  20. }
  21. func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) {
  22. if !h.devService.Enabled() {
  23. http.NotFound(w, r)
  24. return
  25. }
  26. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  27. _, _ = w.Write([]byte(devWorkbenchHTML))
  28. }
  29. const devWorkbenchHTML = `<!doctype html>
  30. <html lang="zh-CN">
  31. <head>
  32. <meta charset="utf-8">
  33. <meta name="viewport" content="width=device-width, initial-scale=1">
  34. <title>CMR Backend Workbench</title>
  35. <style>
  36. :root {
  37. --bg: #0d1418;
  38. --panel: #132129;
  39. --panel-alt: #182b34;
  40. --text: #e9f1f5;
  41. --muted: #8ea3ad;
  42. --line: #29424d;
  43. --accent: #4fd1a5;
  44. --accent-2: #ffd166;
  45. --danger: #ff6b6b;
  46. --mono: "Consolas", "SFMono-Regular", monospace;
  47. --sans: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
  48. }
  49. * { box-sizing: border-box; }
  50. body {
  51. margin: 0;
  52. background:
  53. radial-gradient(circle at top right, rgba(79, 209, 165, 0.12), transparent 24%),
  54. radial-gradient(circle at bottom left, rgba(255, 209, 102, 0.10), transparent 28%),
  55. var(--bg);
  56. color: var(--text);
  57. font-family: var(--sans);
  58. }
  59. .shell {
  60. max-width: 1400px;
  61. margin: 0 auto;
  62. padding: 28px 24px 40px;
  63. }
  64. .hero {
  65. display: grid;
  66. gap: 8px;
  67. margin-bottom: 22px;
  68. }
  69. .eyebrow {
  70. color: var(--accent);
  71. text-transform: uppercase;
  72. letter-spacing: 0.14em;
  73. font-size: 12px;
  74. font-weight: 700;
  75. }
  76. h1 {
  77. margin: 0;
  78. font-size: 34px;
  79. line-height: 1.1;
  80. }
  81. .hero p {
  82. margin: 0;
  83. max-width: 920px;
  84. color: var(--muted);
  85. line-height: 1.6;
  86. }
  87. .grid {
  88. display: grid;
  89. gap: 16px;
  90. grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  91. }
  92. .stack {
  93. display: grid;
  94. gap: 16px;
  95. }
  96. .panel {
  97. background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), var(--panel);
  98. border: 1px solid var(--line);
  99. border-radius: 18px;
  100. padding: 16px;
  101. display: grid;
  102. gap: 12px;
  103. box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
  104. }
  105. .panel h2 {
  106. margin: 0;
  107. font-size: 18px;
  108. }
  109. .panel p {
  110. margin: 0;
  111. color: var(--muted);
  112. font-size: 13px;
  113. line-height: 1.5;
  114. }
  115. .row {
  116. display: grid;
  117. gap: 8px;
  118. }
  119. .row.two {
  120. grid-template-columns: repeat(2, minmax(0, 1fr));
  121. }
  122. label {
  123. display: grid;
  124. gap: 6px;
  125. font-size: 12px;
  126. color: var(--muted);
  127. }
  128. input, textarea, select {
  129. width: 100%;
  130. border: 1px solid var(--line);
  131. border-radius: 12px;
  132. background: var(--panel-alt);
  133. color: var(--text);
  134. padding: 10px 12px;
  135. font: inherit;
  136. }
  137. textarea {
  138. min-height: 90px;
  139. resize: vertical;
  140. font-family: var(--mono);
  141. font-size: 12px;
  142. }
  143. button {
  144. border: 0;
  145. border-radius: 12px;
  146. padding: 10px 14px;
  147. background: var(--accent);
  148. color: #062419;
  149. font-weight: 700;
  150. cursor: pointer;
  151. }
  152. button.secondary {
  153. background: var(--accent-2);
  154. color: #312200;
  155. }
  156. button.ghost {
  157. background: transparent;
  158. color: var(--text);
  159. border: 1px solid var(--line);
  160. }
  161. .actions {
  162. display: flex;
  163. flex-wrap: wrap;
  164. gap: 8px;
  165. }
  166. .kv {
  167. display: grid;
  168. gap: 6px;
  169. font-size: 12px;
  170. color: var(--muted);
  171. }
  172. .kv code {
  173. display: block;
  174. padding: 8px 10px;
  175. border-radius: 10px;
  176. background: rgba(255,255,255,0.04);
  177. color: var(--text);
  178. font-family: var(--mono);
  179. word-break: break-all;
  180. }
  181. .log {
  182. min-height: 220px;
  183. max-height: 520px;
  184. overflow: auto;
  185. white-space: pre-wrap;
  186. word-break: break-word;
  187. font-family: var(--mono);
  188. font-size: 12px;
  189. line-height: 1.55;
  190. background: #0a1013;
  191. border: 1px solid var(--line);
  192. border-radius: 16px;
  193. padding: 14px;
  194. }
  195. .subpanel {
  196. display: grid;
  197. gap: 8px;
  198. padding: 12px;
  199. border-radius: 14px;
  200. background: rgba(255,255,255,0.03);
  201. border: 1px solid rgba(255,255,255,0.05);
  202. }
  203. .history {
  204. display: grid;
  205. gap: 8px;
  206. max-height: 280px;
  207. overflow: auto;
  208. }
  209. .history-item {
  210. padding: 10px 12px;
  211. border-radius: 12px;
  212. background: rgba(255,255,255,0.04);
  213. border: 1px solid rgba(255,255,255,0.05);
  214. font-family: var(--mono);
  215. font-size: 12px;
  216. line-height: 1.5;
  217. }
  218. .history-item strong {
  219. color: var(--accent);
  220. }
  221. .muted-note {
  222. color: var(--muted);
  223. font-size: 12px;
  224. line-height: 1.5;
  225. }
  226. .api-toolbar {
  227. display: flex;
  228. flex-wrap: wrap;
  229. gap: 10px;
  230. align-items: center;
  231. }
  232. .api-toolbar input {
  233. max-width: 360px;
  234. }
  235. .api-catalog {
  236. display: grid;
  237. gap: 12px;
  238. }
  239. .api-item {
  240. display: grid;
  241. gap: 8px;
  242. padding: 14px;
  243. border-radius: 14px;
  244. background: rgba(255,255,255,0.03);
  245. border: 1px solid rgba(255,255,255,0.06);
  246. }
  247. .api-item.hidden {
  248. display: none;
  249. }
  250. .api-head {
  251. display: flex;
  252. flex-wrap: wrap;
  253. gap: 8px 12px;
  254. align-items: center;
  255. }
  256. .api-method {
  257. padding: 4px 8px;
  258. border-radius: 999px;
  259. background: rgba(79, 209, 165, 0.14);
  260. color: var(--accent);
  261. font-family: var(--mono);
  262. font-size: 12px;
  263. font-weight: 700;
  264. }
  265. .api-path {
  266. font-family: var(--mono);
  267. font-size: 13px;
  268. color: var(--text);
  269. word-break: break-all;
  270. }
  271. .api-desc {
  272. color: var(--muted);
  273. font-size: 13px;
  274. line-height: 1.6;
  275. }
  276. .api-meta {
  277. display: grid;
  278. gap: 6px;
  279. font-size: 12px;
  280. color: var(--muted);
  281. }
  282. .api-meta strong {
  283. color: var(--text);
  284. font-weight: 600;
  285. }
  286. .status {
  287. color: var(--accent);
  288. font-weight: 700;
  289. }
  290. .status.error {
  291. color: var(--danger);
  292. }
  293. @media (max-width: 900px) {
  294. .row.two { grid-template-columns: 1fr; }
  295. .shell { padding: 20px 16px 32px; }
  296. }
  297. </style>
  298. </head>
  299. <body>
  300. <div class="shell">
  301. <div class="hero">
  302. <div class="eyebrow">Developer Workbench</div>
  303. <h1>CMR Backend API Flow Panel</h1>
  304. <p>把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。</p>
  305. </div>
  306. <div class="grid">
  307. <section class="panel">
  308. <h2>1. Bootstrap</h2>
  309. <p>初始化 demo tenant / channel / event / card。</p>
  310. <div class="actions">
  311. <button id="btn-bootstrap">Bootstrap Demo</button>
  312. </div>
  313. <div class="kv">
  314. <div>默认入口 <code id="bootstrap-entry">tenant_demo / mini-demo / evt_demo_001</code></div>
  315. </div>
  316. </section>
  317. <section class="panel">
  318. <h2>2. Config Pipeline</h2>
  319. <p>从本地 event 目录导入 source config,生成 preview build,并可直接发布成当前 release。</p>
  320. <div class="row two">
  321. <label>Local Config File
  322. <input id="local-config-file" value="classic-sequential.json">
  323. </label>
  324. <label>Event ID
  325. <input id="config-event-id" value="evt_demo_001">
  326. </label>
  327. </div>
  328. <div class="row two">
  329. <label>Source ID
  330. <input id="config-source-id" placeholder="import 后自动填充">
  331. </label>
  332. <label>Build ID
  333. <input id="config-build-id" placeholder="preview 后自动填充">
  334. </label>
  335. </div>
  336. <div class="actions">
  337. <button id="btn-config-files">List Local Files</button>
  338. <button id="btn-config-import">Import Local</button>
  339. <button class="secondary" id="btn-config-preview">Build Preview</button>
  340. <button class="secondary" id="btn-config-publish">Publish Build</button>
  341. <button class="ghost" id="btn-config-source">Get Source</button>
  342. <button class="ghost" id="btn-config-build">Get Build</button>
  343. </div>
  344. </section>
  345. <section class="panel">
  346. <h2>3. Session State</h2>
  347. <p>当前调试上下文,所有按钮共享这一组状态。</p>
  348. <div class="kv">
  349. <div>Access Token <code id="state-access">-</code></div>
  350. <div>Refresh Token <code id="state-refresh">-</code></div>
  351. <div>Source ID <code id="state-source">-</code></div>
  352. <div>Build ID <code id="state-build">-</code></div>
  353. <div>Release ID <code id="state-release">-</code></div>
  354. <div>Session ID <code id="state-session">-</code></div>
  355. <div>Session Token <code id="state-session-token">-</code></div>
  356. </div>
  357. <div class="actions">
  358. <button class="ghost" id="btn-clear-state">Clear State</button>
  359. </div>
  360. </section>
  361. <section class="panel">
  362. <h2>4. SMS Auth</h2>
  363. <div class="row two">
  364. <label>Client Type
  365. <select id="sms-client-type">
  366. <option value="app">app</option>
  367. <option value="wechat">wechat</option>
  368. </select>
  369. </label>
  370. <label>Scene
  371. <select id="sms-scene">
  372. <option value="login">login</option>
  373. <option value="bind_mobile">bind_mobile</option>
  374. </select>
  375. </label>
  376. </div>
  377. <div class="row two">
  378. <label>Mobile
  379. <input id="sms-mobile" value="13800138000">
  380. </label>
  381. <label>Device Key
  382. <input id="sms-device" value="workbench-device-001">
  383. </label>
  384. </div>
  385. <div class="row two">
  386. <label>Country Code
  387. <input id="sms-country" value="86">
  388. </label>
  389. <label>Code
  390. <input id="sms-code" placeholder="send 后自动填充 devCode">
  391. </label>
  392. </div>
  393. <div class="actions">
  394. <button id="btn-send-sms">Send SMS</button>
  395. <button class="secondary" id="btn-login-sms">Login SMS</button>
  396. <button class="ghost" id="btn-bind-mobile">Bind Mobile</button>
  397. </div>
  398. </section>
  399. <section class="panel">
  400. <h2>5. WeChat Mini</h2>
  401. <p>开发环境可直接使用 dev-xxx code。</p>
  402. <div class="row two">
  403. <label>Code
  404. <input id="wechat-code" value="dev-workbench-user">
  405. </label>
  406. <label>Device Key
  407. <input id="wechat-device" value="wechat-device-001">
  408. </label>
  409. </div>
  410. <div class="actions">
  411. <button id="btn-login-wechat">Login WeChat Mini</button>
  412. </div>
  413. </section>
  414. <section class="panel">
  415. <h2>6. Entry / Home</h2>
  416. <div class="row two">
  417. <label>Channel Code
  418. <input id="entry-channel-code" value="mini-demo">
  419. </label>
  420. <label>Channel Type
  421. <input id="entry-channel-type" value="wechat_mini">
  422. </label>
  423. </div>
  424. <div class="actions">
  425. <button id="btn-resolve-entry">Resolve Entry</button>
  426. <button id="btn-home">Home</button>
  427. <button class="secondary" id="btn-entry-home">My Entry Home</button>
  428. </div>
  429. </section>
  430. <section class="panel">
  431. <h2>7. Event</h2>
  432. <div class="row two">
  433. <label>Event ID
  434. <input id="event-id" value="evt_demo_001">
  435. </label>
  436. <label>Release ID
  437. <input id="event-release-id" value="rel_demo_001">
  438. </label>
  439. </div>
  440. <div class="row two">
  441. <label>Launch Device
  442. <input id="event-device" value="workbench-device-001">
  443. </label>
  444. <div></div>
  445. </div>
  446. <div class="actions">
  447. <button id="btn-event-detail">Event Detail</button>
  448. <button id="btn-event-play">Event Play</button>
  449. <button class="secondary" id="btn-launch">Launch</button>
  450. </div>
  451. </section>
  452. <section class="panel">
  453. <h2>8. Session</h2>
  454. <div class="row two">
  455. <label>Session ID
  456. <input id="session-id" placeholder="launch 后自动填充">
  457. </label>
  458. <label>Session Token
  459. <input id="session-token" placeholder="launch 后自动填充">
  460. </label>
  461. </div>
  462. <div class="row two">
  463. <label>Finish Status
  464. <select id="finish-status">
  465. <option value="finished">finished</option>
  466. <option value="failed">failed</option>
  467. <option value="cancelled">cancelled</option>
  468. </select>
  469. </label>
  470. <div></div>
  471. </div>
  472. <div class="row two">
  473. <label>Duration Sec
  474. <input id="finish-duration" type="number" value="960">
  475. </label>
  476. <label>Score
  477. <input id="finish-score" type="number" value="88">
  478. </label>
  479. </div>
  480. <div class="row two">
  481. <label>Completed Controls
  482. <input id="finish-controls-done" type="number" value="7">
  483. </label>
  484. <label>Total Controls
  485. <input id="finish-controls-total" type="number" value="8">
  486. </label>
  487. </div>
  488. <div class="row two">
  489. <label>Distance Meters
  490. <input id="finish-distance" type="number" step="0.01" value="5230">
  491. </label>
  492. <label>Average Speed KM/H
  493. <input id="finish-speed" type="number" step="0.001" value="6.45">
  494. </label>
  495. </div>
  496. <div class="row two">
  497. <label>Max Heart Rate BPM
  498. <input id="finish-heart-rate" type="number" value="168">
  499. </label>
  500. <div></div>
  501. </div>
  502. <div class="actions">
  503. <button id="btn-session-detail">Session Detail</button>
  504. <button id="btn-session-start">Start Session</button>
  505. <button class="secondary" id="btn-session-finish">Finish Session</button>
  506. <button class="ghost" id="btn-my-sessions">My Sessions</button>
  507. </div>
  508. </section>
  509. <section class="panel">
  510. <h2>9. Results</h2>
  511. <div class="actions">
  512. <button id="btn-session-result">Session Result</button>
  513. <button id="btn-my-results">My Results</button>
  514. </div>
  515. </section>
  516. <section class="panel">
  517. <h2>10. Profile</h2>
  518. <div class="actions">
  519. <button id="btn-me">/me</button>
  520. <button id="btn-profile">/me/profile</button>
  521. </div>
  522. </section>
  523. </div>
  524. <div class="grid" style="margin-top:16px;">
  525. <section class="panel">
  526. <h2>11. Quick Flows</h2>
  527. <p>把常用接口串成一键工作流,减少重复点击。</p>
  528. <div class="actions">
  529. <button id="btn-flow-home">Bootstrap + WeChat + Entry Home</button>
  530. <button class="secondary" id="btn-flow-launch">Login + Launch + Start</button>
  531. <button class="ghost" id="btn-flow-finish">Finish Current Session</button>
  532. <button class="ghost" id="btn-flow-result">Finish + Result</button>
  533. </div>
  534. <div class="muted-note">这些流程会复用当前表单里的手机号、设备、event、channel 等输入。</div>
  535. </section>
  536. <section class="panel">
  537. <h2>12. Request Export</h2>
  538. <p>最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。</p>
  539. <div class="actions">
  540. <button id="btn-copy-curl">Copy Last Curl</button>
  541. <button class="ghost" id="btn-clear-history">Clear History</button>
  542. </div>
  543. <div class="subpanel">
  544. <div class="muted-note">Last Curl</div>
  545. <div id="curl" class="log" style="min-height:120px; max-height:200px;"></div>
  546. </div>
  547. </section>
  548. </div>
  549. <div class="grid" style="margin-top:16px;">
  550. <section class="panel">
  551. <h2>13. Scenarios</h2>
  552. <p>保存当前表单状态为可复用场景,也支持导入导出 JSON,适合后续切换不同俱乐部、入口和 event。</p>
  553. <div class="row two">
  554. <label>Scenario Name
  555. <input id="scenario-name" placeholder="例如:俱乐部A-小程序-Launch流">
  556. </label>
  557. <label>Saved / Preset
  558. <select id="scenario-select"></select>
  559. </label>
  560. </div>
  561. <div class="actions">
  562. <button id="btn-scenario-save">Save Current</button>
  563. <button class="secondary" id="btn-scenario-load">Load Selected</button>
  564. <button class="ghost" id="btn-scenario-delete">Delete Selected</button>
  565. </div>
  566. <div class="subpanel">
  567. <div class="muted-note">Scenario JSON</div>
  568. <textarea id="scenario-json" placeholder="导出后可复制,导入时贴回这里"></textarea>
  569. <div class="actions">
  570. <button id="btn-scenario-export">Export Selected</button>
  571. <button class="secondary" id="btn-scenario-import">Import JSON</button>
  572. </div>
  573. </div>
  574. </section>
  575. <section class="panel">
  576. <h2>14. Response Log</h2>
  577. <p>最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。</p>
  578. <div id="status" class="status">ready</div>
  579. <div id="log" class="log"></div>
  580. </section>
  581. </div>
  582. <div class="grid" style="margin-top:16px;">
  583. <section class="panel">
  584. <h2>15. Request History</h2>
  585. <p>最近 12 次请求会保留在浏览器本地,刷新页面不会丢。</p>
  586. <div id="history" class="history"></div>
  587. </section>
  588. </div>
  589. <div class="grid" style="margin-top:16px;">
  590. <section class="panel">
  591. <h2>16. API 列表</h2>
  592. <p>把当前已实现接口按分组放进 workbench,直接看中文说明、鉴权要求和关键参数,不用来回翻文档。</p>
  593. <div class="api-toolbar">
  594. <input id="api-filter" placeholder="搜索路径、用途、参数,例如 launch / wechat / result">
  595. <div class="muted-note">共 24 个接口,支持按关键词筛选。</div>
  596. </div>
  597. <div id="api-catalog" class="api-catalog">
  598. <div class="api-item" data-api="healthz 健康检查 服务状态">
  599. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/healthz</span></div>
  600. <div class="api-desc">健康检查接口,用来确认服务是否存活。</div>
  601. <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
  602. </div>
  603. <div class="api-item" data-api="auth sms send 验证码 登录 绑定 手机">
  604. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/sms/send</span></div>
  605. <div class="api-desc">发送短信验证码,支持登录和绑定手机号两种场景。</div>
  606. <div class="api-meta">
  607. <div><strong>鉴权:</strong>无需鉴权</div>
  608. <div><strong>关键参数:</strong><code>countryCode</code>、<code>mobile</code>、<code>clientType</code>、<code>deviceKey</code>、<code>scene</code></div>
  609. </div>
  610. </div>
  611. <div class="api-item" data-api="auth login sms app 手机号 验证码 登录">
  612. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/sms</span></div>
  613. <div class="api-desc">APP 主登录入口,使用手机号验证码登录并返回 access/refresh token。</div>
  614. <div class="api-meta">
  615. <div><strong>鉴权:</strong>无需鉴权</div>
  616. <div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
  617. </div>
  618. </div>
  619. <div class="api-item" data-api="auth login wechat mini 微信 小程序 code openid 登录">
  620. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/wechat-mini</span></div>
  621. <div class="api-desc">微信小程序登录入口。开发环境支持 <code>dev-</code> 前缀 code 直接模拟登录。</div>
  622. <div class="api-meta">
  623. <div><strong>鉴权:</strong>无需鉴权</div>
  624. <div><strong>关键参数:</strong><code>code</code>、<code>clientType=wechat</code>、<code>deviceKey</code></div>
  625. </div>
  626. </div>
  627. <div class="api-item" data-api="auth bind mobile 绑定 手机号 合并 账号">
  628. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/bind/mobile</span></div>
  629. <div class="api-desc">已登录用户绑定手机号,必要时把微信轻账号合并到手机号主账号。</div>
  630. <div class="api-meta">
  631. <div><strong>鉴权:</strong>Bearer token</div>
  632. <div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
  633. </div>
  634. </div>
  635. <div class="api-item" data-api="auth refresh token 刷新 登录态">
  636. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/refresh</span></div>
  637. <div class="api-desc">使用 refresh token 刷新 access token。</div>
  638. <div class="api-meta">
  639. <div><strong>鉴权:</strong>无需 Bearer token</div>
  640. <div><strong>关键参数:</strong><code>refreshToken</code>、<code>clientType</code>、<code>deviceKey</code></div>
  641. </div>
  642. </div>
  643. <div class="api-item" data-api="auth logout 登出 撤销 refresh token">
  644. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/logout</span></div>
  645. <div class="api-desc">登出并撤销 refresh token。</div>
  646. <div class="api-meta"><div><strong>鉴权:</strong>可带 Bearer token</div></div>
  647. </div>
  648. <div class="api-item" data-api="entry resolve tenant channel 入口 解析">
  649. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/entry/resolve</span></div>
  650. <div class="api-desc">解析当前入口属于哪个 tenant / channel,是多俱乐部、多公众号接入的入口层基础接口。</div>
  651. <div class="api-meta">
  652. <div><strong>鉴权:</strong>无需鉴权</div>
  653. <div><strong>查询参数:</strong><code>channelCode</code>、<code>channelType</code>、<code>platformAppId</code>、<code>tenantCode</code></div>
  654. </div>
  655. </div>
  656. <div class="api-item" data-api="home 首页 卡片 列表">
  657. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/home</span></div>
  658. <div class="api-desc">返回入口首页卡片数据。</div>
  659. <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
  660. </div>
  661. <div class="api-item" data-api="cards 卡片 列表">
  662. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/cards</span></div>
  663. <div class="api-desc">只返回卡片列表,适合调试卡片数据本身。</div>
  664. <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
  665. </div>
  666. <div class="api-item" data-api="me entry home 首页 聚合 ongoing recent">
  667. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/entry-home</span></div>
  668. <div class="api-desc">首页聚合接口,返回用户、tenant、channel、cards、进行中 session 和最近一局。</div>
  669. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  670. </div>
  671. <div class="api-item" data-api="event detail 活动 详情 release resolvedRelease">
  672. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}</span></div>
  673. <div class="api-desc">活动详情接口,会带当前发布的 release 和 resolvedRelease。</div>
  674. <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
  675. </div>
  676. <div class="api-item" data-api="event play 活动 准备页 聚合 canLaunch continue review">
  677. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/play</span></div>
  678. <div class="api-desc">活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果。</div>
  679. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  680. </div>
  681. <div class="api-item" data-api="event launch 启动 一局 release manifest sessionToken">
  682. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/events/{eventPublicID}/launch</span></div>
  683. <div class="api-desc">基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken。</div>
  684. <div class="api-meta">
  685. <div><strong>鉴权:</strong>Bearer token</div>
  686. <div><strong>关键参数:</strong><code>releaseId</code>、<code>clientType</code>、<code>deviceKey</code></div>
  687. </div>
  688. </div>
  689. <div class="api-item" data-api="config sources event source 配置 列表">
  690. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/config-sources</span></div>
  691. <div class="api-desc">查看某个 event 下已经导入过的 source config 列表。</div>
  692. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  693. </div>
  694. <div class="api-item" data-api="config source detail 源配置 明细">
  695. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-sources/{sourceID}</span></div>
  696. <div class="api-desc">查看单条 source config 明细。</div>
  697. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  698. </div>
  699. <div class="api-item" data-api="config build detail 预览 build 明细 manifest assets">
  700. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-builds/{buildID}</span></div>
  701. <div class="api-desc">查看单次 build 的 manifest 和 asset index。</div>
  702. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  703. </div>
  704. <div class="api-item" data-api="session detail 一局 详情 resolvedRelease">
  705. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}</span></div>
  706. <div class="api-desc">查询一局详情,带 session 状态、event 和 resolvedRelease。</div>
  707. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  708. </div>
  709. <div class="api-item" data-api="session start running 开始 一局 sessionToken">
  710. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/start</span></div>
  711. <div class="api-desc">把 session 从 <code>launched</code> 推进到 <code>running</code>。</div>
  712. <div class="api-meta">
  713. <div><strong>鉴权:</strong><code>sessionToken</code></div>
  714. <div><strong>关键参数:</strong><code>sessionToken</code></div>
  715. </div>
  716. </div>
  717. <div class="api-item" data-api="session finish 结束 成绩 摘要 result summary sessionToken">
  718. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/finish</span></div>
  719. <div class="api-desc">结束一局并沉淀结果摘要,是结果页数据的来源。</div>
  720. <div class="api-meta">
  721. <div><strong>鉴权:</strong><code>sessionToken</code></div>
  722. <div><strong>关键参数:</strong><code>sessionToken</code>、<code>status</code>、<code>summary.*</code></div>
  723. </div>
  724. </div>
  725. <div class="api-item" data-api="me sessions 我的 最近 局 列表">
  726. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/sessions</span></div>
  727. <div class="api-desc">查询用户最近 session 列表。</div>
  728. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  729. </div>
  730. <div class="api-item" data-api="session result 单局 结果 页 成绩">
  731. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}/result</span></div>
  732. <div class="api-desc">单局结果页接口,返回 session 和 result。</div>
  733. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  734. </div>
  735. <div class="api-item" data-api="me results 我的 成绩 结果 列表">
  736. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/results</span></div>
  737. <div class="api-desc">查询用户最近结果列表。</div>
  738. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  739. </div>
  740. <div class="api-item" data-api="me 当前用户 信息">
  741. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me</span></div>
  742. <div class="api-desc">返回当前用户基础信息。</div>
  743. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  744. </div>
  745. <div class="api-item" data-api="me profile 我的页 聚合 绑定 最近记录">
  746. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/profile</span></div>
  747. <div class="api-desc">“我的页”聚合接口,返回绑定概览、绑定项列表和最近记录摘要。</div>
  748. <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
  749. </div>
  750. <div class="api-item" data-api="dev bootstrap demo 初始化 示例 数据">
  751. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/bootstrap-demo</span></div>
  752. <div class="api-desc">开发态自举 demo 数据,会准备 tenant、channel、event、release、card、source、build。</div>
  753. <div class="api-meta"><div><strong>鉴权:</strong>仅 non-production,无需鉴权</div></div>
  754. </div>
  755. <div class="api-item" data-api="dev config local files 本地 配置 文件 列表">
  756. <div class="api-head"><span class="api-method">GET</span><span class="api-path">/dev/config/local-files</span></div>
  757. <div class="api-desc">列出本地配置目录中的 JSON 文件,作为 source config 导入入口。</div>
  758. <div class="api-meta"><div><strong>鉴权:</strong>仅 non-production,无需鉴权</div></div>
  759. </div>
  760. <div class="api-item" data-api="dev import local source config 导入 本地 event json">
  761. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/events/{eventPublicID}/config-sources/import-local</span></div>
  762. <div class="api-desc">从本地 event 目录导入 source config。</div>
  763. <div class="api-meta">
  764. <div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
  765. <div><strong>关键参数:</strong><code>fileName</code>、<code>notes</code></div>
  766. </div>
  767. </div>
  768. <div class="api-item" data-api="dev config preview build 预览 manifest asset index">
  769. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/preview</span></div>
  770. <div class="api-desc">基于 source config 生成 preview build,并产出 preview manifest。</div>
  771. <div class="api-meta">
  772. <div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
  773. <div><strong>关键参数:</strong><code>sourceId</code></div>
  774. </div>
  775. </div>
  776. <div class="api-item" data-api="dev config publish build 发布 release 当前版本">
  777. <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/publish</span></div>
  778. <div class="api-desc">把成功的 build 发布成正式 release,并自动切换成当前 event 的可启动版本。</div>
  779. <div class="api-meta">
  780. <div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
  781. <div><strong>关键参数:</strong><code>buildId</code></div>
  782. </div>
  783. </div>
  784. </div>
  785. </section>
  786. </div>
  787. </div>
  788. <script>
  789. const STORAGE_KEY = 'cmr-backend-workbench-state-v1';
  790. const HISTORY_KEY = 'cmr-backend-workbench-history-v1';
  791. const SCENARIO_KEY = 'cmr-backend-workbench-scenarios-v1';
  792. const state = {
  793. accessToken: '',
  794. refreshToken: '',
  795. sourceId: '',
  796. buildId: '',
  797. releaseId: '',
  798. sessionId: '',
  799. sessionToken: '',
  800. lastCurl: ''
  801. };
  802. const $ = (id) => document.getElementById(id);
  803. const logEl = $('log');
  804. const curlEl = $('curl');
  805. const historyEl = $('history');
  806. const statusEl = $('status');
  807. const builtInScenarios = [
  808. {
  809. id: 'preset-demo-wechat',
  810. builtin: true,
  811. name: 'Preset: Demo WeChat Flow',
  812. fields: {
  813. smsClientType: 'wechat',
  814. smsScene: 'login',
  815. smsMobile: '13800138000',
  816. smsDevice: 'workbench-device-001',
  817. smsCountry: '86',
  818. smsCode: '',
  819. wechatCode: 'dev-workbench-user',
  820. wechatDevice: 'wechat-device-001',
  821. entryChannelCode: 'mini-demo',
  822. entryChannelType: 'wechat_mini',
  823. eventId: 'evt_demo_001',
  824. eventReleaseId: 'rel_demo_001',
  825. eventDevice: 'wechat-device-001',
  826. finishStatus: 'finished',
  827. finishDuration: '960',
  828. finishScore: '88',
  829. finishControlsDone: '7',
  830. finishControlsTotal: '8',
  831. finishDistance: '5230',
  832. finishSpeed: '6.45',
  833. finishHeartRate: '168'
  834. }
  835. },
  836. {
  837. id: 'preset-demo-app-launch',
  838. builtin: true,
  839. name: 'Preset: Demo App Launch Flow',
  840. fields: {
  841. smsClientType: 'app',
  842. smsScene: 'login',
  843. smsMobile: '13800138000',
  844. smsDevice: 'workbench-device-001',
  845. smsCountry: '86',
  846. smsCode: '',
  847. wechatCode: 'dev-workbench-user',
  848. wechatDevice: 'wechat-device-001',
  849. entryChannelCode: 'mini-demo',
  850. entryChannelType: 'wechat_mini',
  851. eventId: 'evt_demo_001',
  852. eventReleaseId: 'rel_demo_001',
  853. eventDevice: 'workbench-device-001',
  854. finishStatus: 'finished',
  855. finishDuration: '960',
  856. finishScore: '88',
  857. finishControlsDone: '7',
  858. finishControlsTotal: '8',
  859. finishDistance: '5230',
  860. finishSpeed: '6.45',
  861. finishHeartRate: '168'
  862. }
  863. }
  864. ];
  865. function syncState() {
  866. $('state-access').textContent = state.accessToken || '-';
  867. $('state-refresh').textContent = state.refreshToken || '-';
  868. $('state-source').textContent = state.sourceId || '-';
  869. $('state-build').textContent = state.buildId || '-';
  870. $('state-release').textContent = state.releaseId || '-';
  871. $('state-session').textContent = state.sessionId || '-';
  872. $('state-session-token').textContent = state.sessionToken || '-';
  873. $('config-source-id').value = state.sourceId || '';
  874. $('config-build-id').value = state.buildId || '';
  875. $('event-release-id').value = state.releaseId || $('event-release-id').value;
  876. $('session-id').value = state.sessionId || '';
  877. $('session-token').value = state.sessionToken || '';
  878. curlEl.textContent = state.lastCurl || '-';
  879. persistState();
  880. }
  881. function setStatus(text, isError = false) {
  882. statusEl.textContent = text;
  883. statusEl.className = isError ? 'status error' : 'status';
  884. }
  885. function writeLog(title, payload) {
  886. logEl.textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(payload, null, 2);
  887. }
  888. function persistState() {
  889. const payload = {
  890. state,
  891. fields: collectFields()
  892. };
  893. localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
  894. }
  895. function collectFields() {
  896. return {
  897. smsClientType: $('sms-client-type').value,
  898. smsScene: $('sms-scene').value,
  899. smsMobile: $('sms-mobile').value,
  900. smsDevice: $('sms-device').value,
  901. smsCountry: $('sms-country').value,
  902. smsCode: $('sms-code').value,
  903. wechatCode: $('wechat-code').value,
  904. wechatDevice: $('wechat-device').value,
  905. localConfigFile: $('local-config-file').value,
  906. configEventId: $('config-event-id').value,
  907. entryChannelCode: $('entry-channel-code').value,
  908. entryChannelType: $('entry-channel-type').value,
  909. eventId: $('event-id').value,
  910. eventReleaseId: $('event-release-id').value,
  911. eventDevice: $('event-device').value,
  912. finishStatus: $('finish-status').value,
  913. finishDuration: $('finish-duration').value,
  914. finishScore: $('finish-score').value,
  915. finishControlsDone: $('finish-controls-done').value,
  916. finishControlsTotal: $('finish-controls-total').value,
  917. finishDistance: $('finish-distance').value,
  918. finishSpeed: $('finish-speed').value,
  919. finishHeartRate: $('finish-heart-rate').value
  920. };
  921. }
  922. function restoreState() {
  923. const raw = localStorage.getItem(STORAGE_KEY);
  924. if (!raw) {
  925. return;
  926. }
  927. try {
  928. const payload = JSON.parse(raw);
  929. if (payload.state) {
  930. state.accessToken = payload.state.accessToken || '';
  931. state.refreshToken = payload.state.refreshToken || '';
  932. state.sourceId = payload.state.sourceId || '';
  933. state.buildId = payload.state.buildId || '';
  934. state.sessionId = payload.state.sessionId || '';
  935. state.sessionToken = payload.state.sessionToken || '';
  936. state.lastCurl = payload.state.lastCurl || '';
  937. }
  938. applyFields(payload.fields || {});
  939. } catch (_) {}
  940. }
  941. function applyFields(fields) {
  942. $('sms-client-type').value = fields.smsClientType || $('sms-client-type').value;
  943. $('sms-scene').value = fields.smsScene || $('sms-scene').value;
  944. $('sms-mobile').value = fields.smsMobile || $('sms-mobile').value;
  945. $('sms-device').value = fields.smsDevice || $('sms-device').value;
  946. $('sms-country').value = fields.smsCountry || $('sms-country').value;
  947. $('sms-code').value = fields.smsCode || '';
  948. $('wechat-code').value = fields.wechatCode || $('wechat-code').value;
  949. $('wechat-device').value = fields.wechatDevice || $('wechat-device').value;
  950. $('local-config-file').value = fields.localConfigFile || $('local-config-file').value;
  951. $('config-event-id').value = fields.configEventId || $('config-event-id').value;
  952. $('entry-channel-code').value = fields.entryChannelCode || $('entry-channel-code').value;
  953. $('entry-channel-type').value = fields.entryChannelType || $('entry-channel-type').value;
  954. $('event-id').value = fields.eventId || $('event-id').value;
  955. $('event-release-id').value = fields.eventReleaseId || $('event-release-id').value;
  956. $('event-device').value = fields.eventDevice || $('event-device').value;
  957. $('finish-status').value = fields.finishStatus || $('finish-status').value;
  958. $('finish-duration').value = fields.finishDuration || $('finish-duration').value;
  959. $('finish-score').value = fields.finishScore || $('finish-score').value;
  960. $('finish-controls-done').value = fields.finishControlsDone || $('finish-controls-done').value;
  961. $('finish-controls-total').value = fields.finishControlsTotal || $('finish-controls-total').value;
  962. $('finish-distance').value = fields.finishDistance || $('finish-distance').value;
  963. $('finish-speed').value = fields.finishSpeed || $('finish-speed').value;
  964. $('finish-heart-rate').value = fields.finishHeartRate || $('finish-heart-rate').value;
  965. }
  966. function parseIntOrNull(value) {
  967. if (value === '' || value === null || value === undefined) {
  968. return null;
  969. }
  970. const parsed = parseInt(value, 10);
  971. return Number.isNaN(parsed) ? null : parsed;
  972. }
  973. function parseFloatOrNull(value) {
  974. if (value === '' || value === null || value === undefined) {
  975. return null;
  976. }
  977. const parsed = parseFloat(value);
  978. return Number.isNaN(parsed) ? null : parsed;
  979. }
  980. function buildFinishSummary() {
  981. const summary = {
  982. finalDurationSec: parseIntOrNull($('finish-duration').value),
  983. finalScore: parseIntOrNull($('finish-score').value),
  984. completedControls: parseIntOrNull($('finish-controls-done').value),
  985. totalControls: parseIntOrNull($('finish-controls-total').value),
  986. distanceMeters: parseFloatOrNull($('finish-distance').value),
  987. averageSpeedKmh: parseFloatOrNull($('finish-speed').value),
  988. maxHeartRateBpm: parseIntOrNull($('finish-heart-rate').value)
  989. };
  990. Object.keys(summary).forEach(function(key) {
  991. if (summary[key] === null) {
  992. delete summary[key];
  993. }
  994. });
  995. return summary;
  996. }
  997. function buildCurl(method, url, body, headers) {
  998. let curl = 'curl -X ' + method + ' "' + window.location.origin + url + '"';
  999. Object.entries(headers || {}).forEach(function(entry) {
  1000. curl += ' -H "' + entry[0] + ': ' + String(entry[1]).replace(/"/g, '\\"') + '"';
  1001. });
  1002. if (body !== undefined) {
  1003. curl += " --data-raw '" + JSON.stringify(body).replace(/'/g, "'\"'\"'") + "'";
  1004. }
  1005. return curl;
  1006. }
  1007. function getHistory() {
  1008. const raw = localStorage.getItem(HISTORY_KEY);
  1009. if (!raw) {
  1010. return [];
  1011. }
  1012. try {
  1013. const list = JSON.parse(raw);
  1014. return Array.isArray(list) ? list : [];
  1015. } catch (_) {
  1016. return [];
  1017. }
  1018. }
  1019. function pushHistory(item) {
  1020. const next = [item].concat(getHistory()).slice(0, 12);
  1021. localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
  1022. renderHistory();
  1023. }
  1024. function renderHistory() {
  1025. const history = getHistory();
  1026. historyEl.innerHTML = '';
  1027. if (!history.length) {
  1028. historyEl.innerHTML = '<div class="muted-note">No requests yet.</div>';
  1029. return;
  1030. }
  1031. history.forEach(function(item) {
  1032. const node = document.createElement('div');
  1033. node.className = 'history-item';
  1034. node.innerHTML =
  1035. '<strong>' + item.title + '</strong><br>' +
  1036. item.time + '<br>' +
  1037. 'status=' + item.status + '<br>' +
  1038. 'url=' + item.url;
  1039. historyEl.appendChild(node);
  1040. });
  1041. }
  1042. function applyAPIFilter() {
  1043. const keyword = $('api-filter').value.trim().toLowerCase();
  1044. document.querySelectorAll('.api-item').forEach(function(node) {
  1045. const haystack = String(node.dataset.api || '').toLowerCase();
  1046. if (!keyword || haystack.indexOf(keyword) >= 0) {
  1047. node.classList.remove('hidden');
  1048. } else {
  1049. node.classList.add('hidden');
  1050. }
  1051. });
  1052. }
  1053. function getSavedScenarios() {
  1054. const raw = localStorage.getItem(SCENARIO_KEY);
  1055. if (!raw) {
  1056. return [];
  1057. }
  1058. try {
  1059. const list = JSON.parse(raw);
  1060. return Array.isArray(list) ? list : [];
  1061. } catch (_) {
  1062. return [];
  1063. }
  1064. }
  1065. function setSavedScenarios(items) {
  1066. localStorage.setItem(SCENARIO_KEY, JSON.stringify(items));
  1067. renderScenarioOptions();
  1068. }
  1069. function allScenarios() {
  1070. return builtInScenarios.concat(getSavedScenarios());
  1071. }
  1072. function renderScenarioOptions() {
  1073. const select = $('scenario-select');
  1074. const scenarios = allScenarios();
  1075. select.innerHTML = '';
  1076. if (!scenarios.length) {
  1077. select.innerHTML = '<option value="">No scenarios</option>';
  1078. return;
  1079. }
  1080. scenarios.forEach(function(item) {
  1081. const option = document.createElement('option');
  1082. option.value = item.id;
  1083. option.textContent = item.name + (item.builtin ? ' [preset]' : '');
  1084. select.appendChild(option);
  1085. });
  1086. }
  1087. function findScenario(id) {
  1088. return allScenarios().find(function(item) {
  1089. return item.id === id;
  1090. }) || null;
  1091. }
  1092. function saveCurrentScenario() {
  1093. const name = $('scenario-name').value.trim();
  1094. if (!name) {
  1095. setStatus('error: scenario name required', true);
  1096. return;
  1097. }
  1098. const saved = getSavedScenarios();
  1099. const scenario = {
  1100. id: 'custom-' + Date.now(),
  1101. builtin: false,
  1102. name: name,
  1103. fields: collectFields()
  1104. };
  1105. saved.unshift(scenario);
  1106. setSavedScenarios(saved.slice(0, 20));
  1107. $('scenario-select').value = scenario.id;
  1108. $('scenario-json').value = JSON.stringify(scenario, null, 2);
  1109. setStatus('ok: scenario saved');
  1110. }
  1111. function loadSelectedScenario() {
  1112. const scenario = findScenario($('scenario-select').value);
  1113. if (!scenario) {
  1114. setStatus('error: scenario not found', true);
  1115. return;
  1116. }
  1117. applyFields(scenario.fields || {});
  1118. $('scenario-name').value = scenario.name || '';
  1119. $('scenario-json').value = JSON.stringify(scenario, null, 2);
  1120. persistState();
  1121. setStatus('ok: scenario loaded');
  1122. }
  1123. function deleteSelectedScenario() {
  1124. const id = $('scenario-select').value;
  1125. const scenario = findScenario(id);
  1126. if (!scenario || scenario.builtin) {
  1127. setStatus('error: builtin scenario cannot be deleted', true);
  1128. return;
  1129. }
  1130. const next = getSavedScenarios().filter(function(item) {
  1131. return item.id !== id;
  1132. });
  1133. setSavedScenarios(next);
  1134. $('scenario-json').value = '';
  1135. setStatus('ok: scenario deleted');
  1136. }
  1137. function exportSelectedScenario() {
  1138. const scenario = findScenario($('scenario-select').value);
  1139. if (!scenario) {
  1140. setStatus('error: scenario not found', true);
  1141. return;
  1142. }
  1143. $('scenario-json').value = JSON.stringify(scenario, null, 2);
  1144. setStatus('ok: scenario exported');
  1145. }
  1146. function importScenarioFromJSON() {
  1147. const raw = $('scenario-json').value.trim();
  1148. if (!raw) {
  1149. setStatus('error: scenario json is empty', true);
  1150. return;
  1151. }
  1152. try {
  1153. const scenario = JSON.parse(raw);
  1154. if (!scenario.name || !scenario.fields) {
  1155. throw new Error('scenario must include name and fields');
  1156. }
  1157. const saved = getSavedScenarios();
  1158. saved.unshift({
  1159. id: 'custom-' + Date.now(),
  1160. builtin: false,
  1161. name: String(scenario.name),
  1162. fields: scenario.fields
  1163. });
  1164. setSavedScenarios(saved.slice(0, 20));
  1165. setStatus('ok: scenario imported');
  1166. } catch (err) {
  1167. setStatus('error: invalid scenario json', true);
  1168. }
  1169. }
  1170. async function request(method, url, body, needAuth = false) {
  1171. const headers = {};
  1172. if (body !== undefined) {
  1173. headers['Content-Type'] = 'application/json';
  1174. }
  1175. if (needAuth) {
  1176. headers['Authorization'] = 'Bearer ' + state.accessToken;
  1177. }
  1178. state.lastCurl = buildCurl(method, url, body, headers);
  1179. syncState();
  1180. const resp = await fetch(url, {
  1181. method,
  1182. headers,
  1183. body: body === undefined ? undefined : JSON.stringify(body)
  1184. });
  1185. const data = await resp.json().catch(() => ({}));
  1186. if (!resp.ok) {
  1187. throw { status: resp.status, body: data, url: url, method: method };
  1188. }
  1189. return data;
  1190. }
  1191. async function run(title, fn) {
  1192. setStatus('running: ' + title);
  1193. try {
  1194. const result = await fn();
  1195. setStatus('ok: ' + title);
  1196. writeLog(title, result);
  1197. pushHistory({
  1198. title: title,
  1199. time: new Date().toLocaleString(),
  1200. status: 'ok',
  1201. url: state.lastCurl
  1202. });
  1203. syncState();
  1204. } catch (err) {
  1205. setStatus('error: ' + title, true);
  1206. writeLog(title, err);
  1207. pushHistory({
  1208. title: title,
  1209. time: new Date().toLocaleString(),
  1210. status: 'error',
  1211. url: state.lastCurl
  1212. });
  1213. }
  1214. }
  1215. $('btn-clear-state').onclick = () => {
  1216. state.accessToken = '';
  1217. state.refreshToken = '';
  1218. state.sourceId = '';
  1219. state.buildId = '';
  1220. state.releaseId = '';
  1221. state.sessionId = '';
  1222. state.sessionToken = '';
  1223. state.lastCurl = '';
  1224. syncState();
  1225. writeLog('clear-state', { ok: true });
  1226. setStatus('ready');
  1227. };
  1228. $('btn-config-files').onclick = () => run('config/local-files', () =>
  1229. request('GET', '/dev/config/local-files')
  1230. );
  1231. $('btn-config-import').onclick = () => run('config/import-local', async () => {
  1232. const result = await request('POST', '/dev/events/' + encodeURIComponent($('config-event-id').value) + '/config-sources/import-local', {
  1233. fileName: $('local-config-file').value
  1234. });
  1235. state.sourceId = result.data.id;
  1236. return result;
  1237. });
  1238. $('btn-config-preview').onclick = () => run('config/build-preview', async () => {
  1239. const result = await request('POST', '/dev/config-builds/preview', {
  1240. sourceId: $('config-source-id').value
  1241. });
  1242. state.buildId = result.data.id;
  1243. return result;
  1244. });
  1245. $('btn-config-publish').onclick = () => run('config/publish-build', async () => {
  1246. const result = await request('POST', '/dev/config-builds/publish', {
  1247. buildId: $('config-build-id').value
  1248. });
  1249. state.releaseId = result.data.release.releaseId;
  1250. $('event-release-id').value = result.data.release.releaseId;
  1251. return result;
  1252. });
  1253. $('btn-config-source').onclick = () => run('config/get-source', () =>
  1254. request('GET', '/config-sources/' + encodeURIComponent($('config-source-id').value), undefined, true)
  1255. );
  1256. $('btn-config-build').onclick = () => run('config/get-build', () =>
  1257. request('GET', '/config-builds/' + encodeURIComponent($('config-build-id').value), undefined, true)
  1258. );
  1259. $('btn-send-sms').onclick = () => run('auth/sms/send', async () => {
  1260. const result = await request('POST', '/auth/sms/send', {
  1261. countryCode: $('sms-country').value,
  1262. mobile: $('sms-mobile').value,
  1263. clientType: $('sms-client-type').value,
  1264. deviceKey: $('sms-device').value,
  1265. scene: $('sms-scene').value
  1266. });
  1267. if (result.data && result.data.devCode) {
  1268. $('sms-code').value = result.data.devCode;
  1269. }
  1270. return result;
  1271. });
  1272. $('btn-login-sms').onclick = () => run('auth/login/sms', async () => {
  1273. const result = await request('POST', '/auth/login/sms', {
  1274. countryCode: $('sms-country').value,
  1275. mobile: $('sms-mobile').value,
  1276. code: $('sms-code').value,
  1277. clientType: $('sms-client-type').value,
  1278. deviceKey: $('sms-device').value
  1279. });
  1280. state.accessToken = result.data.tokens.accessToken;
  1281. state.refreshToken = result.data.tokens.refreshToken;
  1282. return result;
  1283. });
  1284. $('btn-bind-mobile').onclick = () => run('auth/bind/mobile', async () => {
  1285. const result = await request('POST', '/auth/bind/mobile', {
  1286. countryCode: $('sms-country').value,
  1287. mobile: $('sms-mobile').value,
  1288. code: $('sms-code').value,
  1289. clientType: $('sms-client-type').value,
  1290. deviceKey: $('sms-device').value
  1291. }, true);
  1292. state.accessToken = result.data.tokens.accessToken;
  1293. state.refreshToken = result.data.tokens.refreshToken;
  1294. return result;
  1295. });
  1296. $('btn-login-wechat').onclick = () => run('auth/login/wechat-mini', async () => {
  1297. const result = await request('POST', '/auth/login/wechat-mini', {
  1298. code: $('wechat-code').value,
  1299. clientType: 'wechat',
  1300. deviceKey: $('wechat-device').value
  1301. });
  1302. state.accessToken = result.data.tokens.accessToken;
  1303. state.refreshToken = result.data.tokens.refreshToken;
  1304. return result;
  1305. });
  1306. $('btn-resolve-entry').onclick = () => run('entry/resolve', () =>
  1307. request('GET', '/entry/resolve?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
  1308. );
  1309. $('btn-home').onclick = () => run('home', () =>
  1310. request('GET', '/home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
  1311. );
  1312. $('btn-entry-home').onclick = () => run('me/entry-home', () =>
  1313. request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true)
  1314. );
  1315. $('btn-event-detail').onclick = () => run('event-detail', () =>
  1316. request('GET', '/events/' + encodeURIComponent($('event-id').value))
  1317. );
  1318. $('btn-event-play').onclick = () => run('event-play', () =>
  1319. request('GET', '/events/' + encodeURIComponent($('event-id').value) + '/play', undefined, true)
  1320. );
  1321. $('btn-launch').onclick = () => run('event-launch', async () => {
  1322. const result = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
  1323. releaseId: $('event-release-id').value,
  1324. clientType: $('sms-client-type').value,
  1325. deviceKey: $('event-device').value
  1326. }, true);
  1327. state.sessionId = result.data.launch.business.sessionId;
  1328. state.sessionToken = result.data.launch.business.sessionToken;
  1329. return result;
  1330. });
  1331. $('btn-session-detail').onclick = () => run('session-detail', () =>
  1332. request('GET', '/sessions/' + encodeURIComponent($('session-id').value), undefined, true)
  1333. );
  1334. $('btn-session-start').onclick = () => run('session-start', () =>
  1335. request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/start', {
  1336. sessionToken: $('session-token').value
  1337. })
  1338. );
  1339. $('btn-session-finish').onclick = () => run('session-finish', () =>
  1340. request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
  1341. sessionToken: $('session-token').value,
  1342. status: $('finish-status').value,
  1343. summary: buildFinishSummary()
  1344. })
  1345. );
  1346. $('btn-my-sessions').onclick = () => run('me/sessions', () =>
  1347. request('GET', '/me/sessions?limit=10', undefined, true)
  1348. );
  1349. $('btn-session-result').onclick = () => run('session-result', () =>
  1350. request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true)
  1351. );
  1352. $('btn-my-results').onclick = () => run('me/results', () =>
  1353. request('GET', '/me/results?limit=10', undefined, true)
  1354. );
  1355. $('btn-me').onclick = () => run('me', () =>
  1356. request('GET', '/me', undefined, true)
  1357. );
  1358. $('btn-profile').onclick = () => run('me/profile', () =>
  1359. request('GET', '/me/profile', undefined, true)
  1360. );
  1361. $('btn-copy-curl').onclick = async () => {
  1362. if (!state.lastCurl) {
  1363. setStatus('error: no curl to copy', true);
  1364. return;
  1365. }
  1366. try {
  1367. await navigator.clipboard.writeText(state.lastCurl);
  1368. setStatus('ok: curl copied');
  1369. } catch (_) {
  1370. setStatus('error: clipboard unavailable', true);
  1371. }
  1372. };
  1373. $('btn-clear-history').onclick = () => {
  1374. localStorage.removeItem(HISTORY_KEY);
  1375. renderHistory();
  1376. setStatus('ok: history cleared');
  1377. };
  1378. $('btn-scenario-save').onclick = saveCurrentScenario;
  1379. $('btn-scenario-load').onclick = loadSelectedScenario;
  1380. $('btn-scenario-delete').onclick = deleteSelectedScenario;
  1381. $('btn-scenario-export').onclick = exportSelectedScenario;
  1382. $('btn-scenario-import').onclick = importScenarioFromJSON;
  1383. $('btn-flow-home').onclick = () => run('flow-home', async () => {
  1384. await request('POST', '/dev/bootstrap-demo');
  1385. const login = await request('POST', '/auth/login/wechat-mini', {
  1386. code: $('wechat-code').value,
  1387. clientType: 'wechat',
  1388. deviceKey: $('wechat-device').value
  1389. });
  1390. state.accessToken = login.data.tokens.accessToken;
  1391. state.refreshToken = login.data.tokens.refreshToken;
  1392. return await request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true);
  1393. });
  1394. $('btn-bootstrap').onclick = () => run('bootstrap-demo', async () => {
  1395. const result = await request('POST', '/dev/bootstrap-demo');
  1396. state.sourceId = result.data.sourceId || '';
  1397. state.buildId = result.data.buildId || '';
  1398. state.releaseId = result.data.releaseId || state.releaseId || '';
  1399. if (result.data.releaseId) {
  1400. $('event-release-id').value = result.data.releaseId;
  1401. }
  1402. return result;
  1403. });
  1404. $('btn-flow-launch').onclick = () => run('flow-launch', async () => {
  1405. await request('POST', '/dev/bootstrap-demo');
  1406. const smsSend = await request('POST', '/auth/sms/send', {
  1407. countryCode: $('sms-country').value,
  1408. mobile: $('sms-mobile').value,
  1409. clientType: $('sms-client-type').value,
  1410. deviceKey: $('sms-device').value,
  1411. scene: 'login'
  1412. });
  1413. if (smsSend.data && smsSend.data.devCode) {
  1414. $('sms-code').value = smsSend.data.devCode;
  1415. }
  1416. const login = await request('POST', '/auth/login/sms', {
  1417. countryCode: $('sms-country').value,
  1418. mobile: $('sms-mobile').value,
  1419. code: $('sms-code').value,
  1420. clientType: $('sms-client-type').value,
  1421. deviceKey: $('sms-device').value
  1422. });
  1423. state.accessToken = login.data.tokens.accessToken;
  1424. state.refreshToken = login.data.tokens.refreshToken;
  1425. const launch = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
  1426. releaseId: $('event-release-id').value,
  1427. clientType: $('sms-client-type').value,
  1428. deviceKey: $('event-device').value
  1429. }, true);
  1430. state.sessionId = launch.data.launch.business.sessionId;
  1431. state.sessionToken = launch.data.launch.business.sessionToken;
  1432. return await request('POST', '/sessions/' + encodeURIComponent(state.sessionId) + '/start', {
  1433. sessionToken: state.sessionToken
  1434. });
  1435. });
  1436. $('btn-flow-finish').onclick = () => run('flow-finish', async () => {
  1437. return await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
  1438. sessionToken: $('session-token').value,
  1439. status: $('finish-status').value,
  1440. summary: buildFinishSummary()
  1441. });
  1442. });
  1443. $('btn-flow-result').onclick = () => run('flow-result', async () => {
  1444. await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
  1445. sessionToken: $('session-token').value,
  1446. status: $('finish-status').value,
  1447. summary: buildFinishSummary()
  1448. });
  1449. return await request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true);
  1450. });
  1451. [
  1452. 'sms-client-type', 'sms-scene', 'sms-mobile', 'sms-device', 'sms-country', 'sms-code',
  1453. 'wechat-code', 'wechat-device', 'local-config-file', 'config-event-id', 'entry-channel-code', 'entry-channel-type',
  1454. 'event-id', 'event-release-id', 'event-device', 'finish-status', 'finish-duration', 'finish-score',
  1455. 'finish-controls-done', 'finish-controls-total', 'finish-distance', 'finish-speed',
  1456. 'finish-heart-rate'
  1457. ].forEach(function(id) {
  1458. $(id).addEventListener('change', persistState);
  1459. $(id).addEventListener('input', persistState);
  1460. });
  1461. $('api-filter').addEventListener('input', applyAPIFilter);
  1462. restoreState();
  1463. syncState();
  1464. renderHistory();
  1465. renderScenarioOptions();
  1466. applyAPIFilter();
  1467. writeLog('workbench-ready', { ok: true, hint: 'Use Bootstrap Demo first on a fresh database.' });
  1468. </script>
  1469. </body>
  1470. </html>`