From 676df30c67179699122e449dd54bbd9788d49288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=93=E9=9B=A8=E9=B9=8F?= <846149189@qq.com> Date: Mon, 15 Jun 2026 12:01:14 +0800 Subject: [PATCH] =?UTF-8?q?@=20feat(web):=20=E6=BC=94=E5=87=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20Timeline=20=E5=8A=A0=E3=80=8C=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E5=BA=95=E5=9B=BE=E3=80=8D=E9=80=89=E6=8B=A9=E5=99=A8(venue=20?= =?UTF-8?q?=E7=89=B9=E5=86=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 /api/sceneshots:列 SceneShots 全部俯视底图(venue 特写) name->{url,bounds} - timeline.js:底图优先级 ir.stage.backdrop(venue) > 点位集默认 shot; 顶栏加底图下拉 renderMapInfo + applyBackdrop(换底+改投影范围+重画+回调) - app.js:拉 /api/sceneshots;performSelect 传入;saveBackdrop 写 ir.stage.backdrop 并 PUT - venue 特写与点位集同 map-local → 换底图后锚点自动落对位(无头实拍擂台验证) - ir.stage.backdrop 是编辑器元数据:validate 不读、compile 不碰 @ --- web/app.py | 15 ++++++++++ web/static/app.js | 19 +++++++++++- web/static/style.css | 4 ++- web/static/timeline.js | 65 ++++++++++++++++++++++++++++++++++++++---- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/web/app.py b/web/app.py index f4f8afa..f7b37f1 100644 --- a/web/app.py +++ b/web/app.py @@ -219,6 +219,21 @@ async def pointsets(): return out +@app.get("/api/sceneshots") +async def sceneshots(): + """列出 SceneShots 目录里所有可用俯视底图(venue 特写等):name -> {url,bounds,w,h}。 + 供演出配置「场景底图」选择器:事件可挑一张当 Timeline 底图(写进 ir.stage.backdrop)。""" + out = {} + if os.path.isdir(_SCENESHOTS_DIR): + for fn in os.listdir(_SCENESHOTS_DIR): + if fn.endswith(".shot.json"): + name = fn[: -len(".shot.json")] + shot = _load_shot(name) + if shot: + out[name] = shot + return out + + @app.get("/sceneshot/{name}.png") async def sceneshot(name: str): # 防目录穿越:只认纯文件名 diff --git a/web/static/app.js b/web/static/app.js index 02309ad..04e28b5 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -3,6 +3,7 @@ const App = { dict: { conditions: {}, grants: {} }, pointsets: {}, // name -> {mapId, points:[]} + sceneshots: {}, // name -> {url,bounds,w,h}(演出配置「场景底图」可选项) events: [], current: null, // 当前 group ir: null, // 工作副本 @@ -42,6 +43,7 @@ try { App.dict = await (await api("/api/dictionary")).json(); App.pointsets = await (await api("/api/pointsets")).json(); + try { App.sceneshots = await (await api("/api/sceneshots")).json(); } catch (_) { App.sceneshots = {}; } } catch (e) { return; } await loadList(); } @@ -252,12 +254,27 @@ host.appendChild(d); }); } + let performIr = null; // 演出配置当前事件 IR(底图选择写这里并持久化) async function performSelect(group) { let d; try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; } performCurrent = group; + performIr = d.ir; performLoadList(); - Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets); + Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets, { + sceneshots: App.sceneshots, + onPickBackdrop: name => saveBackdrop(group, name), + }); + } + // 演出配置选底图 → 写 ir.stage.backdrop(编辑器元数据,validate/compile 都不读)并 PUT 持久化。 + async function saveBackdrop(group, name) { + if (!performIr || group !== performCurrent) return; + performIr.stage = performIr.stage || {}; + if (name) performIr.stage.backdrop = name; else delete performIr.stage.backdrop; + try { + await api("/api/events/" + encodeURIComponent(group), { method: "PUT", body: JSON.stringify({ ir: performIr, by: App.by }) }); + toast(name ? ("底图已设为 " + name) : "已恢复默认底图"); + } catch (e) { toast("底图保存失败"); } } // ---------- 导入 ---------- diff --git a/web/static/style.css b/web/static/style.css index 67226e5..8e83670 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -323,7 +323,9 @@ body.points-mode .mode-switch { border-color:#4a52a0; } /* ---- 演出预览:上=舞台面板(自适应放大) / 下=时间轴面板(可拖高度) ---- */ .tl-stagepanel { flex:1 1 auto; min-height:150px; display:flex; flex-direction:column; } -.tl-mapinfo { flex:none; font-size:12px; color:#9a8f7e; margin-bottom:6px; } +.tl-mapinfo { flex:none; font-size:12px; color:#9a8f7e; margin-bottom:6px; display:flex; align-items:center; flex-wrap:wrap; gap:4px; } +.tl-bd-label { margin-left:10px; color:#7fae9c; } +.tl-bd-select { background:#1d1a13; color:#d8cda0; border:1px solid #4a4534; border-radius:4px; padding:1px 4px; font-size:12px; max-width:200px; } .tl-stagewrap { position:relative; flex:1; min-height:0; background:#15130d; border:1px solid #3a322a; border-radius:6px; overflow:hidden; } .tl-stage { width:100%; height:100%; object-fit:contain; display:block; } diff --git a/web/static/timeline.js b/web/static/timeline.js index 1f2b175..2b1f8f3 100644 --- a/web/static/timeline.js +++ b/web/static/timeline.js @@ -305,6 +305,9 @@ // ===================== 状态 & 挂载 ===================== let S, model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}, pending = null, selNode = null, PX = 80, fitMode = false, lastDecisionId = null; let shotBg = null, shotReady = false, shotBounds = null; // 真实场景正交俯视底图 + // 场景底图选择器:SHOTS=所有可选 venue 特写({name:{url,bounds}});curBackdrop=当前选中底图名(""=点位集默认); + // onPick=切换底图回调(app.js 写 ir.stage.backdrop 并持久化);baseBounds=无底图时回退的投影范围。 + let SHOTS = {}, curPsName = "", curPs = {}, curBackdrop = "", onPick = null, baseBounds = null; const TEMPLATE = '
' + @@ -322,12 +325,21 @@ '
' + '
'; - function show(host, IR, DICT, POINTSETS, startId) { + function show(host, IR, DICT, POINTSETS, opts) { stopPlay(); + opts = opts || {}; + const startId = opts.startId; + SHOTS = opts.sceneshots || {}; + onPick = opts.onPickBackdrop || null; const psName = (IR.stage || {}).point_set || IR.id; const ps = (POINTSETS || {})[psName] || {}; + curPsName = psName; curPs = ps; + curBackdrop = (IR.stage || {}).backdrop || ""; S = prepare(IR, ps.anchors || []); model = S; - setupShot(ps.shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载,drawStage 当舞台底 + baseBounds = Object.assign({}, S.bounds); // 无底图时回退用 + // 底图优先级:事件指定的 venue 特写(ir.stage.backdrop) > 点位集默认底图 + const shot = (curBackdrop && SHOTS[curBackdrop]) ? SHOTS[curBackdrop] : ps.shot; + setupShot(shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载,drawStage 当舞台底 host.innerHTML = TEMPLATE; els = { @@ -345,9 +357,7 @@ timelinepanel: host.querySelector(".tl-timelinepanel"), playhead: null, }; - els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + ")" : "") + - (S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" - : (shotBounds ? " · 真实坐标 · 已叠场景俯视底图" : " · 真实坐标")); + renderMapInfo(); stageCv = els.stage; stageCtx = stageCv.getContext("2d"); els.play.onclick = () => playing ? stopPlay() : play(); @@ -422,6 +432,51 @@ shotBg.src = shot.url; } + // 顶部信息栏 + 场景底图选择器。底图列表来自 /api/sceneshots(venue 特写等)。 + function renderMapInfo() { + const mi = els.mapinfo; if (!mi) return; + mi.innerHTML = ""; + const span = document.createElement("span"); + span.textContent = "点位集:" + curPsName + (curPs.mapId ? "(地图 " + curPs.mapId + ")" : "") + + (S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" + : (shotBounds ? " · 真实坐标 · 已叠场景底图" : " · 真实坐标")); + mi.appendChild(span); + const names = Object.keys(SHOTS).sort(); + if (names.length) { + const lab = document.createElement("label"); + lab.className = "tl-bd-label"; lab.textContent = "场景底图:"; + const sel = document.createElement("select"); + sel.className = "tl-bd-select"; + const def = document.createElement("option"); + def.value = ""; def.textContent = curPs.shot ? "(点位集默认)" : "(无)"; + sel.appendChild(def); + names.forEach(n => { + const o = document.createElement("option"); + o.value = n; o.textContent = n; + if (n === curBackdrop) o.selected = true; + sel.appendChild(o); + }); + sel.onchange = () => applyBackdrop(sel.value); + mi.appendChild(lab); mi.appendChild(sel); + } + } + + // 切换 Timeline 底图:换底 + 改投影范围 + 重画 + 回调持久化(写 ir.stage.backdrop)。 + // 锚点(点位集坐标)不变,因 venue 特写与点位集同为 map-local,事件点会落在特写图正确位置上。 + function applyBackdrop(name) { + curBackdrop = name; + const shot = (name && SHOTS[name]) ? SHOTS[name] : curPs.shot; + if (shot && Array.isArray(shot.bounds) && shot.bounds.length === 4) { + setupShot(shot); + } else { // 选「无/默认」且无点位集底图 → 回退基础范围 + shotBg = null; shotReady = false; shotBounds = null; + if (baseBounds) S.bounds = Object.assign({}, baseBounds); + } + renderMapInfo(); + renderFrame(); + if (onPick) onPick(name); // app.js: ir.stage.backdrop = name 并 PUT 持久化 + } + function restart() { startFrom(firstNode(S.IR)); } function refreshTimeline() { orderRows(S); buildTracks(); }