From 65424a4dfb98aec88cff521a04351f46645dcc1b 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 11:46:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=B5=B7=E9=80=89=E6=8C=89?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E5=88=86=E7=BB=84=20+=20=E5=88=A0=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=82=B9=E4=BD=8D=E9=A1=B5=E7=AD=BE=20+=20=E6=BC=94?= =?UTF-8?q?=E5=87=BA=E7=9C=9F=E5=AE=9E=E5=BA=95=E5=9B=BE=20+=20=E7=A0=B4?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 海选审核左侧改两列:场景列(按新字段 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崩溃→弹口令) --- web/app.py | 22 ++++++++++++-- web/db.py | 19 ++++++++---- web/static/app.js | 68 ++++++++++++++++++++---------------------- web/static/form.js | 11 +++++++ web/static/index.html | 42 +++++++++++--------------- web/static/style.css | 21 +++++++++++-- web/static/timeline.js | 25 +++++++++++++++- 7 files changed, 137 insertions(+), 71 deletions(-) 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 = '