diff --git a/web/Dockerfile b/web/Dockerfile index b9865fe..cc7621a 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -21,8 +21,9 @@ COPY web ./web # 注意:镜像里不埋任何口令默认值。STORY_WEB_USERS(名字:口令,…)必须运行时注入, # 未配置时 app 启动即退出(拒绝弱口令裸奔)。 ENV STORY_DB_PATH=/data/story_events.db \ - STORY_POINTSETS_DIR=/pointsets -RUN mkdir -p /data /pointsets + STORY_POINTSETS_DIR=/pointsets \ + STORY_SCENESHOTS_DIR=/sceneshots +RUN mkdir -p /data /pointsets /sceneshots EXPOSE 8787 WORKDIR /app/web diff --git a/web/app.py b/web/app.py index b962135..2e746bd 100644 --- a/web/app.py +++ b/web/app.py @@ -34,6 +34,9 @@ _DICT_PATH = os.path.join(_AUTHORING, "ir_dictionary.json") # 点位集目录:容器内用 STORY_POINTSETS_DIR 指向挂载卷;本地默认指向项目 Assets。 _POINTSETS_DIR = os.environ.get("STORY_POINTSETS_DIR") or \ os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "PointSets") +# 场景俯视底图目录:Unity「剧情场景俯视抓拍」产出 {name}.png + {name}.shot.json(含覆盖的 map-local 范围)。 +_SCENESHOTS_DIR = os.environ.get("STORY_SCENESHOTS_DIR") or \ + os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "SceneShots") _STATIC_DIR = os.path.join(_HERE, "static") COOKIE = "story_auth" @@ -162,6 +165,25 @@ async def dictionary(): return json.load(f) +def _load_shot(name): + """读 {name}.shot.json(含俯视底图覆盖的 map-local 范围);图与 sidecar 都在才算有效。""" + try: + meta = os.path.join(_SCENESHOTS_DIR, name + ".shot.json") + png = os.path.join(_SCENESHOTS_DIR, name + ".png") + if not (os.path.isfile(meta) and os.path.isfile(png)): + return None + with open(meta, encoding="utf-8") as f: + m = json.load(f) + b = m.get("bounds") + if not (isinstance(b, list) and len(b) == 4): + return None + # 缓存击穿:附 png mtime,图更新后前端能拿到新图 + return {"url": "/sceneshot/" + name + ".png?v=" + str(int(os.path.getmtime(png))), + "bounds": b, "w": m.get("w"), "h": m.get("h")} + except Exception: + return None + + @app.get("/api/pointsets") async def pointsets(): out = {} @@ -188,11 +210,25 @@ async def pointsets(): for p in pts ], } + shot = _load_shot(name) + if shot: + out[name]["shot"] = shot # 有正交俯视底图 → 场景/点位页叠真实场景 except Exception as e: out[name] = {"error": str(e)} return out +@app.get("/sceneshot/{name}.png") +async def sceneshot(name: str): + # 防目录穿越:只认纯文件名 + if not name or "/" in name or "\\" in name or ".." in name: + return JSONResponse({"error": "bad name"}, status_code=400) + p = os.path.join(_SCENESHOTS_DIR, name + ".png") + if not os.path.isfile(p): + return JSONResponse({"error": "not found"}, status_code=404) + return FileResponse(p, media_type="image/png") + + # ---------- 事件 CRUD ---------- @app.get("/api/events") async def events(status: str = "all"): diff --git a/web/docker-compose.gitsync.yml b/web/docker-compose.gitsync.yml index 52cbee9..766d085 100644 --- a/web/docker-compose.gitsync.yml +++ b/web/docker-compose.gitsync.yml @@ -49,3 +49,8 @@ services: # 整个同步仓库盖在 /app(只读):web/ 前端+后端、ir_core、ir_dictionary.json 全部 # 来自 git 同步目录;镜像内的 COPY 副本只作无挂载时的兜底。路径按上面共享路径,一般不用改。 - "/SATA存储11/Docker/story-edit-web-src:/app:ro" + # 点位集 + 场景俯视底图(可选):这两个来自 SGame 仓 Assets/StreamingAssets/Story/, + # 不在本同步仓里。要让线上「演出配置」有真实坐标、「场景/点位」有底图,把这两目录同步到 + # NAS 后取消下面两行注释并改成实际路径(缺失时分别降级为:坐标校验警告 / 白模回退): + # - "/SATA存储11/Docker/story-assets/PointSets:/pointsets:ro" + # - "/SATA存储11/Docker/story-assets/SceneShots:/sceneshots:ro" diff --git a/web/docker-compose.nas.yml b/web/docker-compose.nas.yml index d75fec7..db0cb49 100644 --- a/web/docker-compose.nas.yml +++ b/web/docker-compose.nas.yml @@ -20,4 +20,7 @@ services: # 点位集(可选):先不挂 = 坐标校验降级为警告,能正常用。 # 把 Assets/StreamingAssets/Story/PointSets 同步到 NAS 某目录后取消下一行注释并改成实际路径: # - /vol1/docker/story/pointsets:/pointsets:ro + # 场景俯视底图(可选):先不挂 =「场景/点位」页回退黑底白模。 + # 把 Assets/StreamingAssets/Story/SceneShots 同步到 NAS 某目录后取消下一行注释并改成实际路径: + # - /vol1/docker/story/sceneshots:/sceneshots:ro restart: unless-stopped diff --git a/web/docker-compose.yml b/web/docker-compose.yml index ba64b28..013517f 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -22,6 +22,9 @@ services: - ./data:/data # 点位集(开发侧产出,只读引用;缺失时坐标校验降级为警告) - ../../../Assets/StreamingAssets/Story/PointSets:/pointsets:ro + # 场景正交俯视底图(Unity「剧情场景俯视抓拍」产出 {name}.png + {name}.shot.json); + # 缺失时「场景/点位」页回退黑底白模 + - ../../../Assets/StreamingAssets/Story/SceneShots:/sceneshots:ro # 前端静态文件热挂载:改 static/* 后刷新浏览器即生效,无需重建镜像 - ./static:/app/web/static:ro restart: unless-stopped diff --git a/web/static/app.js b/web/static/app.js index bdb57eb..4d7130f 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -9,7 +9,7 @@ status: null, selectedNode: null, dirty: false, - mode: "review", // review=海选审核 / perform=演出配置 + mode: "review", // review=海选审核 / perform=演出配置 / points=场景点位 by: localStorage.getItem("story_by") || "匿名", }; window.App = App; @@ -196,20 +196,50 @@ // ---------- 试走 ---------- $("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 = '