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:
22
web/app.py
22
web/app.py
@ -13,13 +13,14 @@ import datetime
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
@ -339,9 +340,26 @@ async def export_zip():
|
|||||||
|
|
||||||
|
|
||||||
# ---------- 静态前端 ----------
|
# ---------- 静态前端 ----------
|
||||||
|
def _stamp_static_refs(html):
|
||||||
|
"""给 index.html 里引用的本地 js/css 加 ?v=<文件mtime> 破浏览器缓存。
|
||||||
|
只给改过的文件 bump 版本号(mtime 变才变)→ 改前端不必手动改版本、也不会再出现
|
||||||
|
新 html 配旧缓存 js 的崩溃。外链(//)与已带 ?查询串的引用跳过。"""
|
||||||
|
def repl(m):
|
||||||
|
attr, ref = m.group(1), m.group(2)
|
||||||
|
if ref.startswith("http") or ref.startswith("//"):
|
||||||
|
return m.group(0)
|
||||||
|
fp = os.path.join(_STATIC_DIR, ref)
|
||||||
|
if os.path.isfile(fp):
|
||||||
|
return '%s="%s?v=%d"' % (attr, ref, int(os.path.getmtime(fp)))
|
||||||
|
return m.group(0)
|
||||||
|
return re.sub(r'(src|href)="([^"?]+\.(?:js|css))"', repl, html)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
return FileResponse(os.path.join(_STATIC_DIR, "index.html"))
|
with open(os.path.join(_STATIC_DIR, "index.html"), encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
return HTMLResponse(_stamp_static_refs(html))
|
||||||
|
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory=_STATIC_DIR), name="static")
|
app.mount("/", StaticFiles(directory=_STATIC_DIR), name="static")
|
||||||
|
|||||||
19
web/db.py
19
web/db.py
@ -32,6 +32,7 @@ def init_db(path=None):
|
|||||||
"group" TEXT PRIMARY KEY,
|
"group" TEXT PRIMARY KEY,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
theme TEXT,
|
theme TEXT,
|
||||||
|
scene TEXT DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
ir_json TEXT NOT NULL,
|
ir_json TEXT NOT NULL,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
@ -39,6 +40,11 @@ def init_db(path=None):
|
|||||||
notes TEXT
|
notes TEXT
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
# 旧库迁移:补 scene 列(海选审核按场景分组的归类维度,镜像自 ir.scene)。
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE events ADD COLUMN scene TEXT DEFAULT ''")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # 已存在
|
||||||
c.execute(
|
c.execute(
|
||||||
"""CREATE TABLE IF NOT EXISTS sessions (
|
"""CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
@ -80,7 +86,7 @@ def purge_sessions(now, path=None):
|
|||||||
|
|
||||||
def list_events(status=None, path=None):
|
def list_events(status=None, path=None):
|
||||||
"""列表(不含 ir_json,轻量)。"""
|
"""列表(不含 ir_json,轻量)。"""
|
||||||
sql = ('SELECT "group", title, theme, status, updated_at, updated_by, notes '
|
sql = ('SELECT "group", title, theme, scene, status, updated_at, updated_by, notes '
|
||||||
"FROM events")
|
"FROM events")
|
||||||
args = []
|
args = []
|
||||||
if status and status != "all":
|
if status and status != "all":
|
||||||
@ -106,21 +112,22 @@ def upsert_event(ir, by, now, notes=None, keep_status=True, path=None):
|
|||||||
group = ir["id"]
|
group = ir["id"]
|
||||||
title = ir.get("title", "")
|
title = ir.get("title", "")
|
||||||
theme = ir.get("theme", "")
|
theme = ir.get("theme", "")
|
||||||
|
scene = ir.get("scene", "") or "" # 海选审核分组维度,镜像自 ir.scene
|
||||||
ir_str = json.dumps(ir, ensure_ascii=False)
|
ir_str = json.dumps(ir, ensure_ascii=False)
|
||||||
with _conn(path) as c:
|
with _conn(path) as c:
|
||||||
exists = c.execute('SELECT status FROM events WHERE "group" = ?',
|
exists = c.execute('SELECT status FROM events WHERE "group" = ?',
|
||||||
(group,)).fetchone()
|
(group,)).fetchone()
|
||||||
if exists:
|
if exists:
|
||||||
c.execute(
|
c.execute(
|
||||||
'UPDATE events SET title=?, theme=?, ir_json=?, updated_at=?, '
|
'UPDATE events SET title=?, theme=?, scene=?, ir_json=?, updated_at=?, '
|
||||||
'updated_by=?, notes=COALESCE(?, notes) WHERE "group"=?',
|
'updated_by=?, notes=COALESCE(?, notes) WHERE "group"=?',
|
||||||
(title, theme, ir_str, now, by, notes, group),
|
(title, theme, scene, ir_str, now, by, notes, group),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
c.execute(
|
c.execute(
|
||||||
'INSERT INTO events ("group", title, theme, status, ir_json, '
|
'INSERT INTO events ("group", title, theme, scene, status, ir_json, '
|
||||||
"updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?)",
|
"updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
(group, title, theme, "pending", ir_str, now, by, notes or ""),
|
(group, title, theme, scene, "pending", ir_str, now, by, notes or ""),
|
||||||
)
|
)
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
status: null,
|
status: null,
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
mode: "review", // review=海选审核 / perform=演出配置 / points=场景点位
|
mode: "review", // review=海选审核 / perform=演出配置
|
||||||
|
sceneCurrent: null, // 海选审核第一列选中的场景:null=全部 / ""=未分类 / 具体场景名
|
||||||
by: localStorage.getItem("story_by") || "匿名",
|
by: localStorage.getItem("story_by") || "匿名",
|
||||||
};
|
};
|
||||||
window.App = App;
|
window.App = App;
|
||||||
@ -52,11 +53,36 @@
|
|||||||
App.events = await r.json();
|
App.events = await r.json();
|
||||||
renderList();
|
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 q = $("search").value.trim().toLowerCase();
|
||||||
const host = $("event-list"); host.innerHTML = "";
|
const host = $("event-list"); host.innerHTML = "";
|
||||||
const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] };
|
const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] };
|
||||||
App.events
|
App.events
|
||||||
|
.filter(e => App.sceneCurrent === null || (e.scene || "") === App.sceneCurrent)
|
||||||
.filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q))
|
.filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q))
|
||||||
.forEach(e => {
|
.forEach(e => {
|
||||||
const b = badge[e.status] || ["b-pending", e.status];
|
const b = badge[e.status] || ["b-pending", e.status];
|
||||||
@ -68,10 +94,10 @@
|
|||||||
d.onclick = () => selectEvent(e.group);
|
d.onclick = () => selectEvent(e.group);
|
||||||
host.appendChild(d);
|
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;
|
$("filter-status").onchange = loadList;
|
||||||
$("search").oninput = renderList;
|
$("search").oninput = renderEventList;
|
||||||
|
|
||||||
// ---------- 选中事件 ----------
|
// ---------- 选中事件 ----------
|
||||||
async function selectEvent(group) {
|
async function selectEvent(group) {
|
||||||
@ -196,52 +222,22 @@
|
|||||||
// ---------- 试走 ----------
|
// ---------- 试走 ----------
|
||||||
$("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict);
|
$("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict);
|
||||||
|
|
||||||
// ---------- 模式切换:海选审核 / 演出配置 / 场景点位 ----------
|
// ---------- 模式切换:海选审核 / 演出配置 ----------
|
||||||
function setMode(m) {
|
function setMode(m) {
|
||||||
App.mode = m;
|
App.mode = m;
|
||||||
$("mode-review").classList.toggle("active", m === "review");
|
$("mode-review").classList.toggle("active", m === "review");
|
||||||
$("mode-perform").classList.toggle("active", m === "perform");
|
$("mode-perform").classList.toggle("active", m === "perform");
|
||||||
$("mode-points").classList.toggle("active", m === "points");
|
|
||||||
$("wrap").classList.toggle("hidden", m !== "review");
|
$("wrap").classList.toggle("hidden", m !== "review");
|
||||||
$("perform-wrap").classList.toggle("hidden", m !== "perform");
|
$("perform-wrap").classList.toggle("hidden", m !== "perform");
|
||||||
$("points-wrap").classList.toggle("hidden", m !== "points");
|
|
||||||
$("review-toolbar").style.display = m === "review" ? "" : "none";
|
$("review-toolbar").style.display = m === "review" ? "" : "none";
|
||||||
document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调
|
document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调
|
||||||
document.body.classList.toggle("points-mode", m === "points");
|
|
||||||
Timeline.stop();
|
Timeline.stop();
|
||||||
if (m === "points") PointView.clear();
|
|
||||||
if (m === "perform") performLoadList();
|
if (m === "perform") performLoadList();
|
||||||
if (m === "points") pointsLoadList();
|
|
||||||
}
|
}
|
||||||
$("mode-review").onclick = () => setMode("review");
|
$("mode-review").onclick = () => setMode("review");
|
||||||
$("mode-perform").onclick = () => setMode("perform");
|
$("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;
|
let performCurrent = null;
|
||||||
async function performLoadList() {
|
async function performLoadList() {
|
||||||
let list;
|
let list;
|
||||||
|
|||||||
@ -21,6 +21,14 @@
|
|||||||
}
|
}
|
||||||
function field(label, input) { return el("div", { class: "fld" }, [el("label", {}, [label]), input]); }
|
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; }
|
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 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 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) {
|
function sel(val, opts, onchange) {
|
||||||
@ -107,6 +115,9 @@
|
|||||||
const psHint = (ctx.pointNames && ctx.pointNames.length)
|
const psHint = (ctx.pointNames && ctx.pointNames.length)
|
||||||
? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)";
|
? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)";
|
||||||
host.appendChild(field("标题", txt(ir.title, v => { ir.title = v; ctx.onChange(false); })));
|
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" }, [
|
host.appendChild(el("div", { class: "row2" }, [
|
||||||
field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })),
|
field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })),
|
||||||
field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })),
|
field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })),
|
||||||
|
|||||||
@ -25,7 +25,6 @@
|
|||||||
<div class="mode-switch">
|
<div class="mode-switch">
|
||||||
<button id="mode-review" class="mode-btn active">海选审核</button>
|
<button id="mode-review" class="mode-btn active">海选审核</button>
|
||||||
<button id="mode-perform" class="mode-btn">演出配置</button>
|
<button id="mode-perform" class="mode-btn">演出配置</button>
|
||||||
<button id="mode-points" class="mode-btn">场景/点位</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar" id="review-toolbar">
|
<div class="toolbar" id="review-toolbar">
|
||||||
<button id="btn-save" class="primary" disabled>保存</button>
|
<button id="btn-save" class="primary" disabled>保存</button>
|
||||||
@ -42,8 +41,15 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="wrap">
|
<div id="wrap">
|
||||||
<!-- 左:事件列表 -->
|
<!-- 左:场景 / 事件 两列 -->
|
||||||
<aside id="list-pane">
|
<aside id="list-pane">
|
||||||
|
<!-- 第一列:场景 -->
|
||||||
|
<div id="scene-col">
|
||||||
|
<div class="listhead">场景</div>
|
||||||
|
<div id="scene-list"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 第二列:该场景下的事件 -->
|
||||||
|
<div id="event-col">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<select id="filter-status">
|
<select id="filter-status">
|
||||||
<option value="all">全部</option>
|
<option value="all">全部</option>
|
||||||
@ -54,6 +60,7 @@
|
|||||||
<input id="search" type="text" placeholder="搜索标题/group">
|
<input id="search" type="text" placeholder="搜索标题/group">
|
||||||
</div>
|
</div>
|
||||||
<div id="event-list"></div>
|
<div id="event-list"></div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 中:分支图(Drawflow 可拖拽连线) -->
|
<!-- 中:分支图(Drawflow 可拖拽连线) -->
|
||||||
@ -89,18 +96,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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 id="validate-modal" class="overlay hidden">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@ -149,7 +144,6 @@
|
|||||||
<script src="playtest.js"></script>
|
<script src="playtest.js"></script>
|
||||||
<script src="timeline.js"></script>
|
<script src="timeline.js"></script>
|
||||||
<script src="scene_edit.js"></script>
|
<script src="scene_edit.js"></script>
|
||||||
<script src="pointview.js"></script>
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -20,8 +20,25 @@ button.mini { padding:2px 8px; font-size:12px; }
|
|||||||
|
|
||||||
/* ---- layout ---- */
|
/* ---- layout ---- */
|
||||||
#wrap { display:flex; flex:1; min-height:0; }
|
#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 { padding:10px; display:flex; gap:6px; border-bottom:1px solid #3a322a; }
|
||||||
.filters select, .filters input, #login input, #import-text {
|
.filters select, .filters input, #login input, #import-text {
|
||||||
background:#241f18; color:#e8e0d4; border:1px solid #4a4030; border-radius:4px;
|
background:#241f18; color:#e8e0d4; border:1px solid #4a4030; border-radius:4px;
|
||||||
|
|||||||
@ -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 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 =
|
const TEMPLATE =
|
||||||
'<div class="tl-stagepanel">' +
|
'<div class="tl-stagepanel">' +
|
||||||
@ -326,6 +327,7 @@
|
|||||||
const psName = (IR.stage || {}).point_set || IR.id;
|
const psName = (IR.stage || {}).point_set || IR.id;
|
||||||
const ps = (POINTSETS || {})[psName] || {};
|
const ps = (POINTSETS || {})[psName] || {};
|
||||||
S = prepare(IR, ps.anchors || []); model = S;
|
S = prepare(IR, ps.anchors || []); model = S;
|
||||||
|
setupShot(ps.shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载,drawStage 当舞台底
|
||||||
|
|
||||||
host.innerHTML = TEMPLATE;
|
host.innerHTML = TEMPLATE;
|
||||||
els = {
|
els = {
|
||||||
@ -344,7 +346,8 @@
|
|||||||
playhead: null,
|
playhead: null,
|
||||||
};
|
};
|
||||||
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + ")" : "") +
|
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + ")" : "") +
|
||||||
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标");
|
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)"
|
||||||
|
: (shotBounds ? " · 真实坐标 · 已叠场景俯视底图" : " · 真实坐标"));
|
||||||
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
|
stageCv = els.stage; stageCtx = stageCv.getContext("2d");
|
||||||
|
|
||||||
els.play.onclick = () => playing ? stopPlay() : play();
|
els.play.onclick = () => playing ? stopPlay() : play();
|
||||||
@ -405,6 +408,20 @@
|
|||||||
}
|
}
|
||||||
function clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = S = null; }
|
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 restart() { startFrom(firstNode(S.IR)); }
|
||||||
|
|
||||||
function refreshTimeline() { orderRows(S); buildTracks(); }
|
function refreshTimeline() { orderRows(S); buildTracks(); }
|
||||||
@ -472,6 +489,12 @@
|
|||||||
function drawStage(tau) {
|
function drawStage(tau) {
|
||||||
const ctx = stageCtx, w = stageCv.width, h = stageCv.height;
|
const ctx = stageCtx, w = stageCv.width, h = stageCv.height;
|
||||||
ctx.clearRect(0, 0, w, h); ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h);
|
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 => {
|
(model.anchors || []).forEach(a => {
|
||||||
const p = worldToStage({ x: a.pos[0], z: a.pos[2] });
|
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();
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user