"""前端管理页面的构建与静态挂载。 方案: 不再由 bot 拉起 vite dev server, 而是把构建产物用 FastAPI StaticFiles 挂载到 bot 端口 /learning_chat 路径, 与 API 同源, 无孤儿进程、无 CORS 问题。 前端开发时手动 `npm run dev` 即可(API 已配置 dev proxy)。 """ import asyncio from pathlib import Path from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from nonebot import logger BASE_DIR = Path(__file__).resolve().parent ADMIN_DIR = BASE_DIR / "web" / "learning-chat-admin" DIST_DIR = ADMIN_DIR / "dist" # 参与构建新鲜度检查的源文件(不在 src/ 下的) SRC_FILES = ("vite.config.ts", "index.html", "package.json") def _dist_is_stale() -> bool: """dist/index.html 不存在, 或比任何源码旧时视为需要重建""" dist_index = DIST_DIR / "index.html" if not dist_index.exists(): return True dist_mtime = dist_index.stat().st_mtime for src_file in (ADMIN_DIR / "src").rglob("*"): if src_file.is_file() and src_file.stat().st_mtime > dist_mtime: return True return any( (ADMIN_DIR / name).stat().st_mtime > dist_mtime for name in SRC_FILES if (ADMIN_DIR / name).exists() ) async def build_frontend() -> bool: """执行 vite 构建, 返回是否成功""" logger.info("群聊学习 | 前端源码已更新, 开始重新构建(约10-40秒)...") proc = await asyncio.create_subprocess_exec( "npx.cmd", "vite", "build", cwd=str(ADMIN_DIR), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) output, _ = await proc.communicate() if proc.returncode != 0: logger.error( "群聊学习 | 前端构建失败(%s):\n%s", proc.returncode, output.decode(errors="ignore")[-3000:], ) return False logger.info("群聊学习 | 前端构建完成") return True async def ensure_frontend_ready() -> bool: """确保构建产物是最新的, 返回是否可用(最新或已成功重建)""" if not _dist_is_stale(): return True return await build_frontend() def mount_frontend(app) -> None: """将构建产物挂载到 bot 端口的 /learning_chat 路径(需在 API 路由注册后调用)。 history 模式 SPA 需要回退: assets/ 静态挂载 + 真实文件直接返回, 其余路径(如 /login /config 等前端路由)一律回退到 index.html。 """ if not DIST_DIR.exists(): logger.error("群聊学习 | 前端构建产物不存在, 管理页面不可用") return assets_dir = DIST_DIR / "assets" if assets_dir.exists(): app.mount( "/learning_chat/assets", StaticFiles(directory=str(assets_dir)), name="learning_chat_assets", ) dist_resolved = DIST_DIR.resolve() @app.get("/learning_chat/{full_path:path}") async def learning_chat_spa(full_path: str): target = (DIST_DIR / full_path).resolve() if full_path and target.is_file() and target.is_relative_to(dist_resolved): return FileResponse(target) # SPA 回退: 前端路由(如 /login /config)全部交给 index.html return FileResponse(DIST_DIR / "index.html") @app.get("/learning_chat") async def learning_chat_root(): return FileResponse(DIST_DIR / "index.html") logger.info("群聊学习 | 管理页面已挂载: http://:/learning_chat/")