diff --git a/web/app.py b/web/app.py index 2e746bd..f4f8afa 100644 --- a/web/app.py +++ b/web/app.py @@ -13,13 +13,14 @@ import datetime import io import json import os +import re import secrets import sys import time import zipfile 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 _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("/") 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") diff --git a/web/db.py b/web/db.py index e0b5ce4..92e8f14 100644 --- a/web/db.py +++ b/web/db.py @@ -32,6 +32,7 @@ def init_db(path=None): "group" TEXT PRIMARY KEY, title TEXT, theme TEXT, + scene TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', ir_json TEXT NOT NULL, updated_at TEXT, @@ -39,6 +40,11 @@ def init_db(path=None): notes TEXT )""" ) + # 旧库迁移:补 scene 列(海选审核按场景分组的归类维度,镜像自 ir.scene)。 + try: + c.execute("ALTER TABLE events ADD COLUMN scene TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass # 已存在 c.execute( """CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, @@ -80,7 +86,7 @@ def purge_sessions(now, path=None): def list_events(status=None, path=None): """列表(不含 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") args = [] 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"] title = ir.get("title", "") theme = ir.get("theme", "") + scene = ir.get("scene", "") or "" # 海选审核分组维度,镜像自 ir.scene ir_str = json.dumps(ir, ensure_ascii=False) with _conn(path) as c: exists = c.execute('SELECT status FROM events WHERE "group" = ?', (group,)).fetchone() if exists: 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"=?', - (title, theme, ir_str, now, by, notes, group), + (title, theme, scene, ir_str, now, by, notes, group), ) else: c.execute( - 'INSERT INTO events ("group", title, theme, status, ir_json, ' - "updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?)", - (group, title, theme, "pending", ir_str, now, by, notes or ""), + 'INSERT INTO events ("group", title, theme, scene, status, ir_json, ' + "updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?,?)", + (group, title, theme, scene, "pending", ir_str, now, by, notes or ""), ) return group diff --git a/web/static/app.js b/web/static/app.js index 4d7130f..02309ad 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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 = '' + esc(label) + '' + cnt + ''; + 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 = '
无事件,点「导入 IR」
'; + if (!host.children.length) host.innerHTML = '
该场景下无事件
'; } $("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 = '
未找到任何点位集(StreamingAssets/Story/PointSets/*.points.json)。
'; 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 = '
' + esc(name) + '
' - + (ps.mapId ? '地图 ' + esc(ps.mapId) + ' · ' : '') + cnt + ' 个点
'; - 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; diff --git a/web/static/form.js b/web/static/form.js index b9234f1..55d5e79 100644 --- a/web/static/form.js +++ b/web/static/form.js @@ -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); })), diff --git a/web/static/index.html b/web/static/index.html index 5b0b582..5ca2707 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -25,7 +25,6 @@
-
@@ -42,18 +41,26 @@
- + @@ -89,18 +96,6 @@
- - -