feat(web): 海选按场景分组 + 删场景点位页签 + 演出真实底图 + 破缓存

- 海选审核左侧改两列:场景列(按新字段 ir.scene 手动归类聚合,含全部/未分类) + 该场景事件列
- 删独立「场景/点位」页签(pointview.js 保留未引用)
- 演出配置 Timeline 接真实场景俯视底图(setupShot 覆盖投影范围 + drawStage 叠图,复用 /api/pointsets 的 shot)
- 事件 meta 加「所属场景」归类输入框(datalist 提示已有场景名)
- db: events 加 scene 列 + 旧库 ALTER 迁移;upsert 镜像 ir.scene;list 返回
- app.py: 首页按文件 mtime 给 js/css 注入 ?v= 破浏览器缓存(根治新html配旧缓存js崩溃→弹口令)
This commit is contained in:
2026-06-15 11:46:59 +08:00
parent 603f78b77f
commit 65424a4dfb
7 changed files with 137 additions and 71 deletions

View File

@ -9,7 +9,8 @@
status: null,
selectedNode: null,
dirty: false,
mode: "review", // review=海选审核 / perform=演出配置 / points=场景点位
mode: "review", // review=海选审核 / perform=演出配置
sceneCurrent: null, // 海选审核第一列选中的场景null=全部 / ""=未分类 / 具体场景名
by: localStorage.getItem("story_by") || "匿名",
};
window.App = App;
@ -52,11 +53,36 @@
App.events = await r.json();
renderList();
}
function renderList() {
function renderList() { renderSceneList(); renderEventList(); }
// 第一列:按 e.scene 聚合成场景,加「全部」「未分类」两个特殊项。
function renderSceneList() {
const host = $("scene-list"); host.innerHTML = "";
const counts = new Map(); let unclassified = 0;
App.events.forEach(e => {
const sc = e.scene || "";
if (!sc) { unclassified++; return; }
counts.set(sc, (counts.get(sc) || 0) + 1);
});
const mk = (key, label, cnt) => {
const d = document.createElement("div");
d.className = "scene-item" + (App.sceneCurrent === key ? " sel" : "");
d.innerHTML = '<span class="snm">' + esc(label) + '</span><span class="scnt">' + cnt + '</span>';
d.onclick = () => { App.sceneCurrent = key; renderList(); };
host.appendChild(d);
};
mk(null, "全部", App.events.length);
[...counts.keys()].sort().forEach(k => mk(k, k, counts.get(k)));
if (unclassified) mk("", "未分类", unclassified);
}
// 第二列:当前场景下、且匹配搜索的事件。
function renderEventList() {
const q = $("search").value.trim().toLowerCase();
const host = $("event-list"); host.innerHTML = "";
const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] };
App.events
.filter(e => App.sceneCurrent === null || (e.scene || "") === App.sceneCurrent)
.filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q))
.forEach(e => {
const b = badge[e.status] || ["b-pending", e.status];
@ -68,10 +94,10 @@
d.onclick = () => selectEvent(e.group);
host.appendChild(d);
});
if (!host.children.length) host.innerHTML = '<div class="empty" style="padding:14px">无事件,点「导入 IR」</div>';
if (!host.children.length) host.innerHTML = '<div class="empty" style="padding:14px">该场景下无事件</div>';
}
$("filter-status").onchange = loadList;
$("search").oninput = renderList;
$("search").oninput = renderEventList;
// ---------- 选中事件 ----------
async function selectEvent(group) {
@ -196,52 +222,22 @@
// ---------- 试走 ----------
$("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict);
// ---------- 模式切换:海选审核 / 演出配置 / 场景点位 ----------
// ---------- 模式切换:海选审核 / 演出配置 ----------
function setMode(m) {
App.mode = m;
$("mode-review").classList.toggle("active", m === "review");
$("mode-perform").classList.toggle("active", m === "perform");
$("mode-points").classList.toggle("active", m === "points");
$("wrap").classList.toggle("hidden", m !== "review");
$("perform-wrap").classList.toggle("hidden", m !== "perform");
$("points-wrap").classList.toggle("hidden", m !== "points");
$("review-toolbar").style.display = m === "review" ? "" : "none";
document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调
document.body.classList.toggle("points-mode", m === "points");
Timeline.stop();
if (m === "points") PointView.clear();
if (m === "perform") performLoadList();
if (m === "points") pointsLoadList();
}
$("mode-review").onclick = () => setMode("review");
$("mode-perform").onclick = () => setMode("perform");
$("mode-points").onclick = () => setMode("points");
// ---------- 场景点位页:点位集列表 + 只读白模查看器 ----------
let pointsCurrent = null;
function pointsLoadList() {
const names = Object.keys(App.pointsets || {}).sort();
const host = $("points-set-list"); host.innerHTML = "";
if (!names.length) { host.innerHTML = '<div class="empty" style="padding:14px">未找到任何点位集StreamingAssets/Story/PointSets/*.points.json。</div>'; return; }
names.forEach(name => {
const ps = App.pointsets[name] || {};
const cnt = (ps.anchors || ps.points || []).length;
const d = document.createElement("div");
d.className = "ev" + (name === pointsCurrent ? " sel" : "");
d.innerHTML = '<div class="t">' + esc(name) + '</div><div class="g">'
+ (ps.mapId ? '地图 ' + esc(ps.mapId) + ' · ' : '') + cnt + ' 个点</div>';
d.onclick = () => pointsSelect(name);
host.appendChild(d);
});
}
function pointsSelect(name) {
pointsCurrent = name;
pointsLoadList();
$("points-empty").style.display = "none";
PointView.show($("points-view"), name, App.pointsets[name] || {});
}
// ---------- 演出配置页:已确认事件列表 + 内嵌白模预览 ----------
// ---------- 演出配置页:已确认事件列表 + 内嵌预览 ----------
let performCurrent = null;
async function performLoadList() {
let list;

View File

@ -21,6 +21,14 @@
}
function field(label, input) { return el("div", { class: "fld" }, [el("label", {}, [label]), input]); }
function txt(val, oninput) { const i = el("input", { type: "text", value: val == null ? "" : val }); i.oninput = () => oninput(i.value); return i; }
// 带下拉建议的文本框datalist用于场景名等可复用又可自由输入的字段
function txtDatalist(val, listId, options, oninput) {
const i = el("input", { type: "text", value: val == null ? "" : val, list: listId, placeholder: "输入或选择场景名" });
i.oninput = () => oninput(i.value);
const dl = el("datalist", { id: listId });
(options || []).forEach(o => dl.appendChild(el("option", { value: o })));
return el("span", { class: "with-datalist" }, [i, dl]);
}
function area(val, oninput) { const t = el("textarea", {}, [val || ""]); t.oninput = () => oninput(t.value); return t; }
function num(val, oninput) { const i = el("input", { type: "number", value: val == null ? "" : val }); i.oninput = () => oninput(i.value === "" ? null : Number(i.value)); return i; }
function sel(val, opts, onchange) {
@ -107,6 +115,9 @@
const psHint = (ctx.pointNames && ctx.pointNames.length)
? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)";
host.appendChild(field("标题", txt(ir.title, v => { ir.title = v; ctx.onChange(false); })));
// 所属场景:海选审核第一列的分组维度,手动归类(可复用已有场景名)。改后保存即生效。
const scenes = [...new Set((window.App && window.App.events ? window.App.events.map(e => e.scene) : []).filter(Boolean))].sort();
host.appendChild(field("所属场景(海选分组)", txtDatalist(ir.scene, "scene-datalist", scenes, v => { ir.scene = v; ctx.onChange(false); })));
host.appendChild(el("div", { class: "row2" }, [
field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })),
field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })),

View File

@ -25,7 +25,6 @@
<div class="mode-switch">
<button id="mode-review" class="mode-btn active">海选审核</button>
<button id="mode-perform" class="mode-btn">演出配置</button>
<button id="mode-points" class="mode-btn">场景/点位</button>
</div>
<div class="toolbar" id="review-toolbar">
<button id="btn-save" class="primary" disabled>保存</button>
@ -42,18 +41,26 @@
</header>
<div id="wrap">
<!-- 左:事件列表 -->
<!-- 左:场景 / 事件 两列 -->
<aside id="list-pane">
<div class="filters">
<select id="filter-status">
<option value="all">全部</option>
<option value="pending">待审</option>
<option value="confirmed">已确认</option>
<option value="discarded">已丢弃</option>
</select>
<input id="search" type="text" placeholder="搜索标题/group">
<!-- 第一列:场景 -->
<div id="scene-col">
<div class="listhead">场景</div>
<div id="scene-list"></div>
</div>
<!-- 第二列:该场景下的事件 -->
<div id="event-col">
<div class="filters">
<select id="filter-status">
<option value="all">全部</option>
<option value="pending">待审</option>
<option value="confirmed">已确认</option>
<option value="discarded">已丢弃</option>
</select>
<input id="search" type="text" placeholder="搜索标题/group">
</div>
<div id="event-list"></div>
</div>
<div id="event-list"></div>
</aside>
<!--分支图Drawflow 可拖拽连线) -->
@ -89,18 +96,6 @@
</main>
</div>
<!-- 场景/点位页(只读:看每个点位集里各点的真实位置/朝向,配 move.to 时对照用)-->
<div id="points-wrap" class="hidden">
<aside id="points-list-pane">
<div class="perform-listhead">场景点位集</div>
<div id="points-set-list"></div>
</aside>
<main id="points-main">
<div id="points-empty" class="empty-center">从左侧选择一个点位集,查看其中各点的位置与朝向</div>
<div id="points-view"></div>
</main>
</div>
<!-- 校验结果遮罩 -->
<div id="validate-modal" class="overlay hidden">
<div class="modal">
@ -149,7 +144,6 @@
<script src="playtest.js"></script>
<script src="timeline.js"></script>
<script src="scene_edit.js"></script>
<script src="pointview.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@ -20,8 +20,25 @@ button.mini { padding:2px 8px; font-size:12px; }
/* ---- layout ---- */
#wrap { display:flex; flex:1; min-height:0; }
#list-pane { width:250px; background:#19150f; border-right:1px solid #3a322a;
display:flex; flex-direction:column; flex:none; }
/* 海选审核左侧两列:场景列 | 事件列 */
#list-pane { width:430px; background:#19150f; border-right:1px solid #3a322a;
display:flex; flex-direction:row; flex:none; }
#scene-col { width:150px; flex:none; display:flex; flex-direction:column;
border-right:1px solid #3a322a; background:#161009; }
#scene-col .listhead { padding:10px 12px; font-size:12px; color:#9a8f7e; letter-spacing:1px;
border-bottom:1px solid #3a322a; background:#19150f; }
#scene-list { overflow:auto; flex:1; }
.scene-item { padding:8px 10px; border-bottom:1px solid #241f18; cursor:pointer;
display:flex; justify-content:space-between; align-items:center; gap:6px;
font-size:13px; color:#cabfae; }
.scene-item:hover { background:#221d16; }
.scene-item.sel { background:#2a2316; border-left:3px solid #e6c878; padding-left:7px; color:#f0e2c0; }
.scene-item .snm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.scene-item .scnt { flex:none; font-size:11px; color:#7a7264; background:#241f18;
border-radius:9px; padding:0 7px; }
#event-col { flex:1; min-width:0; display:flex; flex-direction:column; }
.with-datalist { display:block; }
.with-datalist input { width:100%; box-sizing:border-box; }
.filters { padding:10px; display:flex; gap:6px; border-bottom:1px solid #3a322a; }
.filters select, .filters input, #login input, #import-text {
background:#241f18; color:#e8e0d4; border:1px solid #4a4030; border-radius:4px;

View File

@ -304,6 +304,7 @@
// ===================== 状态 & 挂载 =====================
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; // 真实场景正交俯视底图
const TEMPLATE =
'<div class="tl-stagepanel">' +
@ -326,6 +327,7 @@
const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {};
S = prepare(IR, ps.anchors || []); model = S;
setupShot(ps.shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载drawStage 当舞台底
host.innerHTML = TEMPLATE;
els = {
@ -344,7 +346,8 @@
playhead: null,
};
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标");
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)"
: (shotBounds ? " · 真实坐标 · 已叠场景俯视底图" : " · 真实坐标"));
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
els.play.onclick = () => playing ? stopPlay() : play();
@ -405,6 +408,20 @@
}
function clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = S = null; }
// 真实场景俯视底图shot={url,bounds:[minX,maxX,minZ,maxZ]}Unity 抓拍产出)。
// 有底图时把投影范围覆盖成底图覆盖的 map-local 范围 → actor/锚点像素级落在真实场景上。
function setupShot(shot) {
shotBg = null; shotReady = false; shotBounds = null;
if (!shot || !Array.isArray(shot.bounds) || shot.bounds.length !== 4) return;
const b = shot.bounds;
shotBounds = { minX: b[0], maxX: b[1], minZ: b[2], maxZ: b[3] };
S.bounds = shotBounds; // 与底图对齐(口径同 pointview屏幕右=+X、上=+Z
shotBg = new Image();
shotBg.onload = () => { shotReady = true; if (stageCv) renderFrame(); };
shotBg.onerror = () => { shotReady = false; };
shotBg.src = shot.url;
}
function restart() { startFrom(firstNode(S.IR)); }
function refreshTimeline() { orderRows(S); buildTracks(); }
@ -472,6 +489,12 @@
function drawStage(tau) {
const ctx = stageCtx, w = stageCv.width, h = stageCv.height;
ctx.clearRect(0, 0, w, h); ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h);
// 真实场景俯视底图:画进 bounds 投影出的矩形(纵横比=bounds=图,故像素级贴合)
if (shotBg && shotReady && shotBounds) {
const tl = worldToStage({ x: shotBounds.minX, z: shotBounds.maxZ });
const br = worldToStage({ x: shotBounds.maxX, z: shotBounds.minZ });
ctx.drawImage(shotBg, tl.x, tl.y, br.x - tl.x, br.y - tl.y);
}
(model.anchors || []).forEach(a => {
const p = worldToStage({ x: a.pos[0], z: a.pos[2] });
ctx.strokeStyle = "rgba(180,170,140,.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.stroke();