]> acesimba.cloud Git - StationAIDemo.git/commitdiff
feat: 任务门禁(/t/{id} 开关)+ /list 管理页
authorCodeBuddy <codebuddy@acesimba.cloud>
Tue, 25 Aug 2026 07:50:44 +0000 (15:50 +0800)
committerCodeBuddy <codebuddy@acesimba.cloud>
Tue, 25 Aug 2026 07:50:44 +0000 (15:50 +0800)
- 后端新增 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
backend/tasks.json [new file with mode: 0644]
frontend/list.html [new file with mode: 0644]
frontend/src/List.tsx [new file with mode: 0644]
frontend/src/list-main.tsx [new file with mode: 0644]
frontend/vite.config.ts

index 506f5ba0238e8187966ce5545562817ff81b422b..4fe312293383a783705e3828a1eaf34576b9b36c 100644 (file)
@@ -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 (file)
index 0000000..59d10e1
--- /dev/null
@@ -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 (file)
index 0000000..34bdab2
--- /dev/null
@@ -0,0 +1,12 @@
+<!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>
diff --git a/frontend/src/List.tsx b/frontend/src/List.tsx
new file mode 100644 (file)
index 0000000..4fba1fa
--- /dev/null
@@ -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<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>
+  );
+}
diff --git a/frontend/src/list-main.tsx b/frontend/src/list-main.tsx
new file mode 100644 (file)
index 0000000..4a485df
--- /dev/null
@@ -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(
+  <React.StrictMode>
+    <TaskList />
+  </React.StrictMode>
+);
index aa114ee75884e24ca1977d31bc61eaa06f267b8f..a3d50c7cc70f8b0868b715df64547f400c2ebc49 100644 (file)
@@ -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: {