- 后端新增 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 双入口
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()
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", "")
}
+# ---- 任务门禁接口 ----
+@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 {
--- /dev/null
+[
+ {
+ "id": "aidemo",
+ "name": "AIDemo",
+ "open": true
+ }
+]
\ No newline at end of file
--- /dev/null
+<!doctype html>
+<html lang="zh-CN">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>任务管理 · AIDemo</title>
+ </head>
+ <body>
+ <div id="list-root"></div>
+ <script type="module" src="/src/list-main.tsx"></script>
+ </body>
+</html>
--- /dev/null
+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<string>(() => localStorage.getItem(KEY_STORAGE) || "");
+ const [tasks, setTasks] = useState<Task[]>([]);
+ const [error, setError] = useState<string>("");
+ const [busy, setBusy] = useState<boolean>(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 (
+ <div
+ style={{
+ maxWidth: 720,
+ margin: "40px auto",
+ padding: "0 16px",
+ fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif",
+ color: "#1f2329",
+ }}
+ >
+ <h1 style={{ fontSize: 22, marginBottom: 4 }}>任务管理</h1>
+ <p style={{ color: "#8a8f99", marginTop: 0, fontSize: 13 }}>
+ 管理各任务的开放状态;关闭后,对应分享链接将无法访问。
+ </p>
+
+ <div style={{ display: "flex", gap: 8, margin: "16px 0" }}>
+ <input
+ type="password"
+ placeholder="管理密钥(X-Task-Admin-Key)"
+ value={key}
+ onChange={(e) => saveKey(e.target.value)}
+ style={{
+ flex: 1,
+ padding: "8px 10px",
+ border: "1px solid #d0d3d9",
+ borderRadius: 6,
+ fontSize: 14,
+ }}
+ />
+ <button
+ onClick={load}
+ disabled={busy || !key}
+ style={{
+ padding: "8px 16px",
+ border: "none",
+ borderRadius: 6,
+ background: "#2b6cff",
+ color: "#fff",
+ fontSize: 14,
+ cursor: key ? "pointer" : "not-allowed",
+ opacity: key ? 1 : 0.5,
+ }}
+ >
+ 加载任务
+ </button>
+ </div>
+
+ {error && (
+ <div
+ style={{
+ background: "#fff1f0",
+ border: "1px solid #ffccc7",
+ color: "#cf1322",
+ padding: "8px 12px",
+ borderRadius: 6,
+ fontSize: 13,
+ marginBottom: 16,
+ }}
+ >
+ {error}
+ </div>
+ )}
+
+ {tasks.length === 0 && !error && (
+ <div style={{ color: "#8a8f99", fontSize: 14 }}>暂无任务,或先输入密钥后点击「加载任务」。</div>
+ )}
+
+ <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
+ {tasks.map((t) => {
+ const shareUrl = `${window.location.origin}/t/${t.id}`;
+ return (
+ <li
+ key={t.id}
+ style={{
+ border: "1px solid #e5e6eb",
+ borderRadius: 8,
+ padding: 16,
+ marginBottom: 12,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ }}
+ >
+ <div style={{ minWidth: 0 }}>
+ <div style={{ fontSize: 16, fontWeight: 600 }}>{t.name}</div>
+ <div style={{ fontSize: 12, color: "#8a8f99", margin: "4px 0" }}>
+ 任务 ID:{t.id}
+ </div>
+ <a
+ href={shareUrl}
+ target="_blank"
+ rel="noreferrer"
+ style={{ fontSize: 13, color: "#2b6cff", wordBreak: "break-all" }}
+ >
+ {shareUrl}
+ </a>
+ </div>
+ <div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
+ <span
+ style={{
+ fontSize: 13,
+ color: t.open ? "#389e0d" : "#bfbfbf",
+ fontWeight: 600,
+ }}
+ >
+ {t.open ? "开放" : "关闭"}
+ </span>
+ <label
+ style={{
+ position: "relative",
+ display: "inline-block",
+ width: 44,
+ height: 24,
+ cursor: busy ? "not-allowed" : "pointer",
+ }}
+ >
+ <input
+ type="checkbox"
+ checked={t.open}
+ disabled={busy}
+ onChange={(e) => toggle(t, e.target.checked)}
+ style={{ opacity: 0, width: 0, height: 0 }}
+ />
+ <span
+ style={{
+ position: "absolute",
+ inset: 0,
+ background: t.open ? "#389e0d" : "#d0d3d9",
+ borderRadius: 24,
+ transition: "0.2s",
+ }}
+ />
+ <span
+ style={{
+ position: "absolute",
+ top: 3,
+ left: t.open ? 23 : 3,
+ width: 18,
+ height: 18,
+ background: "#fff",
+ borderRadius: "50%",
+ transition: "0.2s",
+ }}
+ />
+ </label>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ );
+}
--- /dev/null
+import React from "react";
+import ReactDOM from "react-dom/client";
+import TaskList from "./List";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("list-root")!).render(
+ <React.StrictMode>
+ <TaskList />
+ </React.StrictMode>
+);
// 前端 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: {