100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""前端管理页面的构建与静态挂载。
|
|||
|
|
|
||
|
|
方案: 不再由 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://<host>:<port>/learning_chat/")
|