From 803f446bee52e29b2932d5c8e3e7426658a5e3b8 Mon Sep 17 00:00:00 2001 From: CodeBuddy Date: Tue, 25 Aug 2026 15:50:44 +0800 Subject: [PATCH] =?utf8?q?=20feat:=20=E4=BB=BB=E5=8A=A1=E9=97=A8=E7=A6=81?= =?utf8?q?=EF=BC=88/t/{id}=20=E5=BC=80=E5=85=B3=EF=BC=89+=20/list=20?= =?utf8?q?=E7=AE=A1=E7=90=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 后端新增 GET /api/tasks、POST /api/tasks/{id}/open|close(X-Task-Admin-Key 保护)、GET /t/{id} 门禁(开放返回应用、关闭 403) - backend/tasks.json 种子含 AIDemo(open:true) - 前端多页构建:新增 list.html / src/List.tsx / src/list-main.tsx 任务管理页 - vite.config.ts 改为 index + list 双入口 --- backend/main.py | 91 +++++++++++++++ backend/tasks.json | 7 ++ frontend/list.html | 12 ++ frontend/src/List.tsx | 231 +++++++++++++++++++++++++++++++++++++ frontend/src/list-main.tsx | 10 ++ frontend/vite.config.ts | 10 ++ 6 files changed, 361 insertions(+) create mode 100644 backend/tasks.json create mode 100644 frontend/list.html create mode 100644 frontend/src/List.tsx create mode 100644 frontend/src/list-main.tsx diff --git a/backend/main.py b/backend/main.py index 506f5ba..4fe3122 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,7 @@ from dotenv import load_dotenv import httpx from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel load_dotenv() @@ -26,6 +27,46 @@ app.add_middleware( allow_headers=["*"], ) +# ---- 任务门禁配置 ---- +# 任务清单持久化在 backend/tasks.json;开关受 X-Task-Admin-Key 保护。 +TASKS_FILE = os.path.join(os.path.dirname(__file__), "tasks.json") +TASK_ADMIN_KEY = os.getenv("TASK_ADMIN_KEY", "") +# 门禁放行时返回的应用首页(前端构建产物,绝对路径引用 /assets/) +TASK_INDEX_HTML = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "frontend", "dist", "index.html" +) + + +def load_tasks() -> list[dict]: + if not os.path.exists(TASKS_FILE): + seed = [{"id": "aidemo", "name": "AIDemo", "open": True}] + save_tasks(seed) + return seed + try: + with open(TASKS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except Exception: + pass + return [] + + +def save_tasks(tasks: list[dict]) -> None: + with open(TASKS_FILE, "w", encoding="utf-8") as f: + json.dump(tasks, f, ensure_ascii=False, indent=2) + + +def get_task(task_id: str) -> Optional[dict]: + return next((t for t in load_tasks() if t.get("id") == task_id), None) + + +def check_admin_key(request: Request) -> bool: + if not TASK_ADMIN_KEY: + return False + return request.headers.get("X-Task-Admin-Key", "") == TASK_ADMIN_KEY + + # ---- CodeBuddy(AI 脑)配置 ---- CODEBUDDY_API_BASE = os.getenv("CODEBUDDY_API_BASE", "") CODEBUDDY_API_KEY = os.getenv("CODEBUDDY_API_KEY", "") @@ -974,6 +1015,56 @@ async def mu_period_compare(req: MuPeriodCompareRequest): } +# ---- 任务门禁接口 ---- +@app.get("/api/tasks") +async def list_tasks(request: Request): + """列出全部任务及其开放状态;需管理密钥。""" + if not check_admin_key(request): + return JSONResponse(status_code=401, content={"ok": False, "message": "缺少或错误的管理密钥"}) + return {"ok": True, "tasks": load_tasks()} + + +@app.post("/api/tasks/{task_id}/open") +async def open_task(task_id: str, request: Request): + if not check_admin_key(request): + return JSONResponse(status_code=401, content={"ok": False, "message": "缺少或错误的管理密钥"}) + tasks = load_tasks() + task = next((t for t in tasks if t.get("id") == task_id), None) + if task is None: + return JSONResponse(status_code=404, content={"ok": False, "message": "任务不存在"}) + task["open"] = True + save_tasks(tasks) + return {"ok": True, "task": task} + + +@app.post("/api/tasks/{task_id}/close") +async def close_task(task_id: str, request: Request): + if not check_admin_key(request): + return JSONResponse(status_code=401, content={"ok": False, "message": "缺少或错误的管理密钥"}) + tasks = load_tasks() + task = next((t for t in tasks if t.get("id") == task_id), None) + if task is None: + return JSONResponse(status_code=404, content={"ok": False, "message": "任务不存在"}) + task["open"] = False + save_tasks(tasks) + return {"ok": True, "task": task} + + +@app.get("/t/{task_id}") +async def task_gate(task_id: str): + """任务门禁:开放则返回应用首页,关闭或不存在则返回 403/404。""" + task = get_task(task_id) + if task is None: + return JSONResponse(status_code=404, content={"ok": False, "message": "任务不存在"}) + if not task.get("open"): + return JSONResponse(status_code=403, content={"ok": False, "message": "该任务已关闭,访问被拒绝"}) + if not os.path.exists(TASK_INDEX_HTML): + return JSONResponse(status_code=404, content={"ok": False, "message": "前端构建产物缺失,请先构建 dist"}) + with open(TASK_INDEX_HTML, "r", encoding="utf-8") as f: + html = f.read() + return HTMLResponse(content=html) + + @app.get("/api/health") async def health(): return { diff --git a/backend/tasks.json b/backend/tasks.json new file mode 100644 index 0000000..59d10e1 --- /dev/null +++ b/backend/tasks.json @@ -0,0 +1,7 @@ +[ + { + "id": "aidemo", + "name": "AIDemo", + "open": true + } +] \ No newline at end of file diff --git a/frontend/list.html b/frontend/list.html new file mode 100644 index 0000000..34bdab2 --- /dev/null +++ b/frontend/list.html @@ -0,0 +1,12 @@ + + + + + + 任务管理 · AIDemo + + +
+ + + diff --git a/frontend/src/List.tsx b/frontend/src/List.tsx new file mode 100644 index 0000000..4fba1fa --- /dev/null +++ b/frontend/src/List.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState } from "react"; + +type Task = { + id: string; + name: string; + open: boolean; +}; + +const KEY_STORAGE = "aidemo_task_admin_key"; + +export default function TaskList() { + const [key, setKey] = useState(() => localStorage.getItem(KEY_STORAGE) || ""); + const [tasks, setTasks] = useState([]); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function load() { + if (!key) { + setError("请先输入管理密钥"); + setTasks([]); + return; + } + setBusy(true); + setError(""); + try { + const res = await fetch("/api/tasks", { + headers: { "X-Task-Admin-Key": key }, + }); + const data = await res.json(); + if (!res.ok) { + setError(data.message || "加载失败"); + setTasks([]); + return; + } + setTasks(data.tasks || []); + } catch (e) { + setError("网络错误:" + String(e)); + } finally { + setBusy(false); + } + } + + useEffect(() => { + if (key) load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function saveKey(value: string) { + setKey(value); + localStorage.setItem(KEY_STORAGE, value); + } + + async function toggle(task: Task, nextOpen: boolean) { + if (!key) { + setError("请先输入管理密钥"); + return; + } + setBusy(true); + setError(""); + try { + const res = await fetch(`/api/tasks/${task.id}/${nextOpen ? "open" : "close"}`, { + method: "POST", + headers: { "X-Task-Admin-Key": key }, + }); + const data = await res.json(); + if (!res.ok) { + setError(data.message || "操作失败"); + return; + } + setTasks((prev) => prev.map((t) => (t.id === task.id ? (data.task as Task) : t))); + } catch (e) { + setError("网络错误:" + String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+

任务管理

+

+ 管理各任务的开放状态;关闭后,对应分享链接将无法访问。 +

+ +
+ saveKey(e.target.value)} + style={{ + flex: 1, + padding: "8px 10px", + border: "1px solid #d0d3d9", + borderRadius: 6, + fontSize: 14, + }} + /> + +
+ + {error && ( +
+ {error} +
+ )} + + {tasks.length === 0 && !error && ( +
暂无任务,或先输入密钥后点击「加载任务」。
+ )} + +
    + {tasks.map((t) => { + const shareUrl = `${window.location.origin}/t/${t.id}`; + return ( +
  • +
    +
    {t.name}
    +
    + 任务 ID:{t.id} +
    + + {shareUrl} + +
    +
    + + {t.open ? "开放" : "关闭"} + + +
    +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/src/list-main.tsx b/frontend/src/list-main.tsx new file mode 100644 index 0000000..4a485df --- /dev/null +++ b/frontend/src/list-main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import TaskList from "./List"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("list-root")!).render( + + + +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index aa114ee..a3d50c7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,8 +3,18 @@ import react from "@vitejs/plugin-react"; // 前端 dev 端口 5173(避开你常用 1060/1061/2341/4999) // /api 代理到当前项目 FastAPI 后端(8000) +// 多页构建:index.html(应用本体,经 /t/ 门禁提供)+ list.html(任务管理页) +// rollup input 相对路径以项目根(本文件所在目录)解析,无需引入 Node 路径模块。 export default defineConfig({ plugins: [react()], + build: { + rollupOptions: { + input: { + main: "index.html", + list: "list.html", + }, + }, + }, server: { port: 5173, proxy: { -- 2.43.0