فهرست منبع

feat: 实现用户登录及工具集管理功能

新增用户登录功能,包括登录页面和token验证机制
添加用户工具集管理模块,支持用户分配和管理工具集
扩展API接口,支持用户token验证和工具集操作
优化前端路由和权限控制,增加用户管理相关页面
max_liu 1 ماه پیش
والد
کامیت
d42dc4fa0a

+ 3 - 8
server/routes/toolsLibrary.js

@@ -30,7 +30,7 @@ router.get("/", async (req, res) => {
       countParams.push(status);
     }
 
-    const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
+    const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
 
     const sql = `
       SELECT tools_id, tools_name, tools_function_name, mcp_tools_name, tools_full_name,
@@ -47,10 +47,7 @@ router.get("/", async (req, res) => {
     // 添加分页参数
     sqlParams.push(parseInt(pageSize), offset);
 
-    const [data, countResult] = await Promise.all([
-      executeQuery(sql, sqlParams),
-      executeQuery(countSql, countParams),
-    ]);
+    const [data, countResult] = await Promise.all([executeQuery(sql, sqlParams), executeQuery(countSql, countParams)]);
 
     res.json({
       data,
@@ -100,7 +97,6 @@ router.put("/:id", async (req, res) => {
       tools_full_name,
       tools_desc,
       tools_version,
-      access_task_id,
       status,
       call_type,
       api_provider,
@@ -116,7 +112,7 @@ router.put("/:id", async (req, res) => {
     const sql = `
       UPDATE tools_library
       SET tools_name = ?, tools_function_name = ?, mcp_tools_name = ?, tools_full_name = ?,
-          tools_desc = ?, tools_version = ?, access_task_id = ?,
+          tools_desc = ?, tools_version = ?, 
           status = ?, call_type = ?, api_provider = ?, api_url_path = ?,
           website_location = ?, website_name = ?, website_address = ?,
           operate_path_data = ?, params_definition = ?, response_desc = ?,
@@ -132,7 +128,6 @@ router.put("/:id", async (req, res) => {
       tools_full_name ?? null,
       tools_desc ?? null,
       tools_version ?? null,
-      access_task_id ?? null,
       status ?? null,
       call_type ?? null,
       api_provider ?? null,

+ 59 - 0
server/routes/userToken.js

@@ -0,0 +1,59 @@
+const express = require("express");
+const router = express.Router();
+const crypto = require("crypto");
+const { executeQuery } = require("../config/database");
+
+// 登录接口:校验用户名密码,返回/生成 token
+router.post("/login", async (req, res) => {
+  try {
+    const { name, password } = req.body;
+
+    if (!name || !password) {
+      return res.status(400).json({ success: false, error: "Missing name or password" });
+    }
+
+    const sql = `
+      SELECT id, status, name, is_admin, password, token, create_time
+      FROM user_info
+      WHERE name = ? AND password = ?
+      LIMIT 1
+    `;
+
+    const rows = await executeQuery(sql, [name, password]);
+
+    if (!rows || rows.length === 0) {
+      return res.status(401).json({ success: false, error: "账号不存在" });
+    }
+
+    const user = rows[0];
+
+    // 如果没有 token 或 token 为空,则生成新的 token 并更新到数据库
+    let token = user.token;
+    if (!token) {
+      token = crypto.randomBytes(32).toString("hex");
+      const updateSql = `
+        UPDATE user_info
+        SET token = ?, status = 'enable'
+        WHERE id = ?
+      `;
+      await executeQuery(updateSql, [token, user.id]);
+    }
+
+    return res.json({
+      success: true,
+      message: "登录成功",
+      data: {
+        id: user.id,
+        name: user.name,
+        is_admin: user.is_admin || 0,
+        status: user.status || null,
+        token,
+      },
+    });
+  } catch (error) {
+    console.error("Login error:", error);
+    return res.status(500).json({ success: false, error: "Internal server error" });
+  }
+});
+
+module.exports = router;

+ 185 - 0
server/routes/userToolsSet.js

@@ -0,0 +1,185 @@
+const express = require("express");
+const router = express.Router();
+const { executeQuery } = require("../config/database");
+const crypto = require("crypto");
+
+// 获取用户列表(user_info:name, create_time)
+router.get("/users", async (req, res) => {
+  try {
+    const auth = req.headers["authorization"] || "";
+    const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
+    if (!token) {
+      return res.status(401).json({ error: "未登录或缺少token" });
+    }
+
+    const meRows = await executeQuery(`SELECT name, is_admin FROM user_info WHERE token = ? LIMIT 1`, [token]);
+    if (!meRows || meRows.length === 0) {
+      return res.status(401).json({ error: "无效的用户token" });
+    }
+    const me = meRows[0];
+
+    let sql = "";
+    let params = [];
+    if (Number(me.is_admin) === 1) {
+      sql = `SELECT name, create_time FROM user_info ORDER BY create_time DESC`;
+      params = [];
+    } else {
+      sql = `SELECT name, create_time FROM user_info WHERE name = ? ORDER BY create_time DESC`;
+      params = [me.name];
+    }
+
+    const rows = await executeQuery(sql, params);
+    res.json({ users: rows, me });
+  } catch (error) {
+    console.error("Error fetching users:", error);
+    res.status(500).json({ error: "Internal server error" });
+  }
+});
+
+// 新增:获取用户 tokens 列表(tools_token:user, token)
+router.get("/tokens", async (req, res) => {
+  try {
+    const auth = req.headers["authorization"] || "";
+    const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
+    if (!token) {
+      return res.status(401).json({ error: "未登录或缺少token" });
+    }
+
+    const meRows = await executeQuery(`SELECT name, is_admin FROM user_info WHERE token = ? LIMIT 1`, [token]);
+    if (!meRows || meRows.length === 0) {
+      return res.status(401).json({ error: "无效的用户token" });
+    }
+    const me = meRows[0];
+
+    let sql = "";
+    let params = [];
+    if (Number(me.is_admin) === 1) {
+      sql = `SELECT user, token FROM tools_token ORDER BY id DESC`;
+      params = [];
+    } else {
+      sql = `SELECT user, token FROM tools_token WHERE user = ? ORDER BY id DESC`;
+      params = [me.name];
+    }
+
+    const rows = await executeQuery(sql, params);
+    res.json({ tokens: rows, me });
+  } catch (error) {
+    console.error("Error fetching tokens:", error);
+    res.status(500).json({ error: "Internal server error" });
+  }
+});
+
+// 获取工具列表(tools_library:tools_id, tools_name, tools_function_name, status)
+router.get("/tools", async (req, res) => {
+  try {
+    const sql = `
+      SELECT tools_id, tools_name, tools_function_name,tools_full_name, mcp_tools_name, status
+      FROM tools_library
+      ORDER BY create_time DESC
+    `;
+    const rows = await executeQuery(sql, []);
+    res.json({ tools: rows });
+  } catch (error) {
+    console.error("Error fetching tools:", error);
+    res.status(500).json({ error: "Internal server error" });
+  }
+});
+
+// 根据token获取已设置的工具集
+router.get("/token-tools/:token", async (req, res) => {
+  try {
+    const { token } = req.params;
+    if (!token) {
+      return res.status(400).json({ error: "缺少token参数" });
+    }
+
+    const sql = `
+      SELECT tools_id
+      FROM tools_token_set
+      WHERE token = ?
+      ORDER BY create_time DESC
+    `;
+    const rows = await executeQuery(sql, [token]);
+    const toolsIds = rows.map((row) => row.tools_id);
+
+    res.json({ tools_ids: toolsIds });
+  } catch (error) {
+    console.error("Error fetching token tools:", error);
+    res.status(500).json({ error: "Internal server error" });
+  }
+});
+
+// 保存用户的工具集:根据 user_name 查找启用的 token,然后把所选 tools_id 批量插入 tools_token_set
+router.post("/save", async (req, res) => {
+  try {
+    const { user_name, tools_ids, token: payloadToken } = req.body;
+    if (!user_name || !Array.isArray(tools_ids)) {
+      return res.status(400).json({ error: "参数错误:需要 user_name 和 tools_ids 数组" });
+    }
+
+    // 优先使用请求体中的 token;若未提供则回退为该用户最新启用的 token
+    let token = payloadToken;
+    if (!token) {
+      const tokenRows = await executeQuery(
+        `SELECT token FROM tools_token WHERE user = ? AND status = 'enable' ORDER BY id DESC LIMIT 1`,
+        [user_name]
+      );
+      if (!tokenRows || tokenRows.length === 0) {
+        return res.status(404).json({ error: "未找到启用的用户token" });
+      }
+      token = tokenRows[0].token;
+    }
+
+    // 覆盖保存:先清空当前 token 下的旧设置
+    await executeQuery(`DELETE FROM tools_token_set WHERE token = ?`, [token]);
+
+    // 若本次没有选择工具,则直接返回成功(代表清空)
+    if (!tools_ids || tools_ids.length === 0) {
+      return res.json({ success: true, message: "已清空工具设置", token, count: 0 });
+    }
+
+    // 批量插入所选工具
+    for (const tools_id of tools_ids) {
+      await executeQuery(
+        `INSERT INTO tools_token_set (token, tools_id, create_time) VALUES (?, ?, NOW())`,
+        [token, tools_id]
+      );
+    }
+
+    res.json({ success: true, message: "保存成功", token, count: tools_ids.length });
+  } catch (error) {
+    console.error("Error saving tools token set:", error);
+    res.status(500).json({ error: "Internal server error" });
+  }
+});
+
+// 新增用户:name, password, is_admin,并生成 token = MD5(name+password) 大写32位
+router.post("/add-user", async (req, res) => {
+  try {
+    const { name, password, is_admin } = req.body;
+    if (!name || !password || typeof is_admin === "undefined") {
+      return res.status(400).json({ error: "缺少必要参数:name、password、is_admin" });
+    }
+    const raw = `${name}${password}`;
+    const md5 = crypto.createHash("md5").update(raw).digest("hex").toUpperCase();
+
+    // 检查是否已存在同名用户
+    const existRows = await executeQuery(`SELECT id FROM user_info WHERE name = ? LIMIT 1`, [name]);
+    if (existRows && existRows.length > 0) {
+      return res.status(409).json({ error: "用户名已存在" });
+    }
+
+    const insertSql = `
+      INSERT INTO user_info (status, name, is_admin, password, token, create_time)
+      VALUES ('enable', ?, ?, ?, ?, NOW())
+    `;
+    await executeQuery(insertSql, [name, Number(is_admin) ? 1 : 0, password, md5]);
+
+    res.json({ success: true, token: md5 });
+  } catch (error) {
+    console.error("Error adding user:", error);
+    res.status(500).json({ error: "Internal server error" });
+  }
+});
+
+module.exports = router;

+ 5 - 0
server/server.js

@@ -7,6 +7,8 @@ const pendingToolsRoutes = require("./routes/pendingTools");
 const autoAccessTasksRoutes = require("./routes/autoAccessTasks");
 const toolsLibraryRoutes = require("./routes/toolsLibrary");
 const accountsRoutes = require("./routes/accounts");
+const userTokenRoutes = require("./routes/userToken");
+const userToolsSetRoutes = require("./routes/userToolsSet");
 
 const app = express();
 const PORT = process.env.PORT || 3001;
@@ -19,6 +21,7 @@ app.use(
       // 'http://47.93.61.163:3030',  // 生产环境前端地址
       "http://localhost:3000", // 本地开发环境
       "http://localhost:3030", // 本地测试环境
+      "http://localhost:3003", // 本地开发环境(当前会话)
     ],
     methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
     allowedHeaders: ["Content-Type", "Authorization"],
@@ -34,6 +37,8 @@ app.use("/api/pending-tools", pendingToolsRoutes);
 app.use("/api/auto-access-tasks", autoAccessTasksRoutes);
 app.use("/api/tools-library", toolsLibraryRoutes);
 app.use("/api/accounts", accountsRoutes);
+app.use("/api/user-token", userTokenRoutes);
+app.use("/api/user-tools-set", userToolsSetRoutes);
 
 app.get("/api/health", (req, res) => {
   res.json({ status: "OK", message: "Server is running" });

+ 108 - 98
src/App.js

@@ -1,7 +1,7 @@
 import React from "react";
 import { BrowserRouter as Router, Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
-import { Layout, Menu } from "antd";
-import { ToolOutlined, SearchOutlined, AppstoreOutlined, UserOutlined } from "@ant-design/icons";
+import { Layout, Menu, Dropdown, Avatar, message } from "antd";
+import { ToolOutlined, SearchOutlined, AppstoreOutlined, UserOutlined, PoweroffOutlined } from "@ant-design/icons";
 import Dashboard from "./components/Dashboard";
 import PendingToolsList from "./pages/PendingToolsList";
 import PendingToolsAdd from "./pages/PendingToolsAdd";
@@ -14,6 +14,10 @@ import ToolsLibraryDetail from "./pages/ToolsLibraryDetail";
 import AccountList from "./pages/AccountList";
 import AccountDetail from "./pages/AccountDetail";
 import "./App.css";
+import Login from "./pages/Login";
+import UserToolsSetList from "./pages/UserToolsSetList";
+import UserToolsSetAdd from "./pages/UserToolsSetAdd";
+import UserAdd from "./pages/UserAdd";
 
 const { Header, Content, Sider } = Layout;
 
@@ -21,114 +25,120 @@ function AppContent() {
   const location = useLocation();
   const navigate = useNavigate();
 
-  // 根据当前路径确定选中的菜单项
+  const isLoginPage = location.pathname === "/login";
+  const userName = typeof window !== "undefined" ? (localStorage.getItem("userName") || "") : "";
+
+  const handleLogout = () => {
+    localStorage.removeItem("userToken");
+    localStorage.removeItem("userName");
+    localStorage.removeItem("isAdmin");
+    message.success("已退出登录");
+    navigate("/login", { replace: true });
+  };
+
+  const profileMenuItems = [
+    { key: "name", label: userName || "未登录", icon: <UserOutlined />, disabled: true },
+    { key: "logout", label: "退出登录", icon: <PoweroffOutlined /> },
+  ];
+
+  React.useEffect(() => {
+    const token = typeof window !== "undefined" ? localStorage.getItem("userToken") : null;
+    if (!token && location.pathname !== "/login") {
+      navigate("/login", { replace: true });
+    }
+  }, [location.pathname, navigate]);
+
   const getSelectedKey = () => {
     const path = location.pathname;
     if (path.startsWith("/pending-tools")) return ["1"];
     if (path.startsWith("/auto-access-tasks")) return ["2"];
     if (path.startsWith("/tools-library")) return ["3"];
     if (path.startsWith("/accounts")) return ["4"];
+    if (path.startsWith("/user-tools-set")) return ["5"];
     return ["1"]; // 默认选中第一项
   };
 
   return (
     <Layout className="min-h-screen">
-      <Header className="fixed top-0 left-0 right-0 z-50 flex items-center bg-blue-600 shadow-lg">
-        <div className="text-white text-xl font-bold tracking-wide">🛠️ 工具库管理系统</div>
-      </Header>
-      <Layout className="pt-16">
-        <Sider
-          width={200}
-          className="fixed left-0 top-16 bottom-0 z-40 bg-white border-r border-gray-200 overflow-y-auto"
-        >
-          <Menu
-            mode="inline"
-            selectedKeys={getSelectedKey()}
-            className="h-full border-r-0"
-            items={[
-              {
-                key: "1",
-                icon: <SearchOutlined />,
-                label: "待接入工具列表",
-                onClick: () => navigate("/pending-tools"),
-              },
-              {
-                key: "2",
-                icon: <ToolOutlined />,
-                label: "自动接入任务列表",
-                onClick: () => navigate("/auto-access-tasks"),
-              },
-              {
-                key: "3",
-                icon: <AppstoreOutlined />,
-                label: "工具列表",
-                onClick: () => navigate("/tools-library"),
-              },
-              {
-                key: "4",
-                icon: <UserOutlined />,
-                label: "平台账号列表",
-                onClick: () => navigate("/accounts"),
-              },
-            ]}
-          />
-        </Sider>
-        <Layout className="ml-[200px] p-6 bg-gray-50">
-          <Content className="bg-white p-6 rounded-lg shadow-sm min-h-screen overflow-y-auto">
+      {!isLoginPage && (
+        <Header className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between bg-blue-600 shadow-lg">
+          <div className="text-white text-xl font-bold tracking-wide">🛠️ 工具库管理系统</div>
+          <Dropdown
+            menu={{ items: profileMenuItems, onClick: ({ key }) => { if (key === "logout") handleLogout(); } }}
+            placement="bottomRight"
+          >
+            <Avatar
+              style={{ cursor: "pointer", backgroundColor: "#fff", color: "#2b6cb0" }}
+              size={32}
+              icon={<UserOutlined />}
+            />
+          </Dropdown>
+        </Header>
+      )}
+      <Layout className={isLoginPage ? "" : "pt-16"}>
+        {!isLoginPage && (
+          <Sider
+            width={200}
+            className="fixed left-0 top-16 bottom-0 z-40 bg-white border-r border-gray-200 overflow-y-auto"
+          >
+            <Menu
+              mode="inline"
+              selectedKeys={getSelectedKey()}
+              className="h-full border-r-0"
+              items={[
+                {
+                  key: "1",
+                  icon: <SearchOutlined />,
+                  label: "待接入工具列表",
+                  onClick: () => navigate("/pending-tools"),
+                },
+                {
+                  key: "2",
+                  icon: <ToolOutlined />,
+                  label: "自动接入任务列表",
+                  onClick: () => navigate("/auto-access-tasks"),
+                },
+                {
+                  key: "3",
+                  icon: <AppstoreOutlined />,
+                  label: "工具列表",
+                  onClick: () => navigate("/tools-library"),
+                },
+                {
+                  key: "4",
+                  icon: <UserOutlined />,
+                  label: "平台账号列表",
+                  onClick: () => navigate("/accounts"),
+                },
+                {
+                  key: "5",
+                  icon: <AppstoreOutlined />,
+                  label: "用户工具集管理",
+                  onClick: () => navigate("/user-tools-set"),
+                },
+              ]}
+            />
+          </Sider>
+        )}
+        <Layout className={isLoginPage ? "p-0" : "ml-[200px] p-6 bg-gray-50"}>
+          <Content className={isLoginPage ? "min-h-screen bg-gray-50" : "bg-white p-6 rounded-lg shadow-sm min-h-screen overflow-y-auto"}>
             <Routes>
-              <Route
-                path="/"
-                element={
-                  <Navigate
-                    to="/pending-tools"
-                    replace
-                  />
-                }
-              />
-              <Route
-                path="/dashboard"
-                element={<Dashboard />}
-              />
-              <Route
-                path="/pending-tools"
-                element={<PendingToolsList />}
-              />
-              <Route
-                path="/pending-tools/add"
-                element={<PendingToolsAdd />}
-              />
-              <Route
-                path="/pending-tools/:id"
-                element={<PendingToolsDetail />}
-              />
-              <Route
-                path="/auto-access-tasks"
-                element={<AutoAccessTaskList />}
-              />
-              <Route
-                path="/auto-access-tasks/add"
-                element={<AutoAccessTaskAdd />}
-              />
-              <Route
-                path="/auto-access-tasks/:id"
-                element={<AutoAccessTaskDetail />}
-              />
-              <Route
-                path="/tools-library"
-                element={<ToolsLibraryList />}
-              />
-              <Route
-                path="/tools-library/:id"
-                element={<ToolsLibraryDetail />}
-              />
-              <Route
-                path="/accounts"
-                element={<AccountList />}
-              />
-              <Route
-                path="/accounts/:id"
-                element={<AccountDetail />}
-              />
+              <Route path="/login" element={<Login />} />
+              <Route path="/" element={<Navigate to="/pending-tools" replace />} />
+              <Route path="/dashboard" element={<Dashboard />} />
+              <Route path="/pending-tools" element={<PendingToolsList />} />
+              <Route path="/pending-tools/add" element={<PendingToolsAdd />} />
+              <Route path="/pending-tools/:id" element={<PendingToolsDetail />} />
+              <Route path="/auto-access-tasks" element={<AutoAccessTaskList />} />
+              <Route path="/auto-access-tasks/add" element={<AutoAccessTaskAdd />} />
+              <Route path="/auto-access-tasks/:id" element={<AutoAccessTaskDetail />} />
+              <Route path="/tools-library" element={<ToolsLibraryList />} />
+              <Route path="/tools-library/:id" element={<ToolsLibraryDetail />} />
+              <Route path="/accounts" element={<AccountList />} />
+              <Route path="/accounts/:id" element={<AccountDetail />} />
+              <Route path="/user-tools-set" element={<UserToolsSetList />} />
+              <Route path="/user-tools-set/add/:userName" element={<UserToolsSetAdd />} />
+              <Route path="/user-tools-set/user/add" element={<UserAdd />} />
             </Routes>
           </Content>
         </Layout>

+ 65 - 0
src/pages/Login.js

@@ -0,0 +1,65 @@
+import React, { useState } from "react";
+import { Form, Input, Button, Card, message } from "antd";
+import { useNavigate } from "react-router-dom";
+import { authApi } from "../services/api";
+
+const Login = () => {
+  const [form] = Form.useForm();
+  const [loading, setLoading] = useState(false);
+  const navigate = useNavigate();
+
+  const handleSubmit = async (values) => {
+    try {
+      setLoading(true);
+      const res = await authApi.login({ name: values.name, password: values.password });
+      const data = res.data?.data;
+      if (!data || !data.token) {
+        throw new Error("登录失败:未返回token");
+      }
+      localStorage.setItem("userToken", data.token);
+      localStorage.setItem("userName", data.name || "");
+      localStorage.setItem("isAdmin", String(data.is_admin || 0));
+      message.success("登录成功");
+      navigate("/pending-tools", { replace: true });
+    } catch (error) {
+      const errorMessage = error.response?.data?.error || error.message || "登录失败,请重试";
+      message.error(errorMessage);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div
+      style={{
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+        minHeight: "100vh",
+        background: "#eef5ff",
+      }}
+    >
+      <Card
+        title="Let's Get Started"
+        style={{ width: 420, borderRadius: 10, boxShadow: "0 10px 30px rgba(0,0,0,0.06)" }}
+        styles={{ header: { textAlign: "center", fontWeight: 600 } }}
+      >
+        <Form form={form} layout="vertical" onFinish={handleSubmit} autoComplete="off">
+          <Form.Item label="账号" name="name" rules={[{ required: true, message: "请输入登录名" }]}>
+            <Input placeholder="请输入登录名" />
+          </Form.Item>
+          <Form.Item label="密码" name="password" rules={[{ required: true, message: "请输入密码" }]}>
+            <Input.Password placeholder="请输入密码" />
+          </Form.Item>
+          <Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading} block>
+              登录
+            </Button>
+          </Form.Item>
+        </Form>
+      </Card>
+    </div>
+  );
+};
+
+export default Login;

+ 1 - 3
src/pages/ToolsLibraryDetail.js

@@ -144,7 +144,6 @@ const ToolsLibraryDetail = () => {
                 </Form.Item>
               </Col>
               <Col span={12}>
-                {" "}
                 <Form.Item
                   label="工具功能名称"
                   name="tools_function_name"
@@ -196,7 +195,6 @@ const ToolsLibraryDetail = () => {
                 </Form.Item>
               </Col>
               <Col span={12}>
-                {" "}
                 <Form.Item
                   label="工具版本"
                   name="tools_version"
@@ -225,7 +223,7 @@ const ToolsLibraryDetail = () => {
                   label="API路径"
                   name="api_url_path"
                 >
-                  <Input />
+                  <Input disabled />
                 </Form.Item>
               </Col>
             </Row>

+ 106 - 0
src/pages/UserAdd.js

@@ -0,0 +1,106 @@
+import React, { useState } from "react";
+import { Card, Form, Input, Switch, Button, message } from "antd";
+import { useNavigate } from "react-router-dom";
+import { userToolsSetApi } from "../services/api";
+
+const UserAdd = () => {
+  const [form] = Form.useForm();
+  const [loading, setLoading] = useState(false);
+  const navigate = useNavigate();
+
+  const onFinish = async (values) => {
+    const name = String(values.name || "").trim();
+    if (!name) {
+      message.error("请输入有效的用户名");
+      return;
+    }
+    // 先查询是否已存在同名用户(admin 能查询到所有用户)
+    try {
+      const usersRes = await userToolsSetApi.getUsers();
+      const users = usersRes.data?.users || [];
+      const exists = users.some((u) => String(u.name).trim().toLowerCase() === name.toLowerCase());
+      if (exists) {
+        message.error("用户名已存在");
+        return;
+      }
+    } catch (err) {
+      // 查询失败不影响后续创建,但提示一下
+      console.warn("检查用户名重名失败", err);
+    }
+
+    const payload = {
+      name,
+      password: values.password,
+      is_admin: values.is_admin ? 1 : 0,
+    };
+    try {
+      setLoading(true);
+      const res = await userToolsSetApi.addUser(payload);
+      if (res.data?.success) {
+        message.success("新增用户成功");
+        navigate("/user-tools-set", { replace: true });
+      } else {
+        message.error(res.data?.error || "新增失败");
+      }
+    } catch (error) {
+      if (error?.response?.status === 409) {
+        message.error("用户名已存在");
+      } else {
+        message.error(error.response?.data?.error || "新增失败");
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <Card
+      title="新增用户"
+      extra={<Button onClick={() => navigate(-1)}>返回</Button>}
+    >
+      <Form
+        form={form}
+        layout="vertical"
+        onFinish={onFinish}
+      >
+        <Form.Item
+          label="用户名"
+          name="name"
+          rules={[{ required: true, message: "请输入用户名" }]}
+        >
+          <Input placeholder="请输入用户名" />
+        </Form.Item>
+        <Form.Item
+          label="用户密码"
+          name="password"
+          rules={[{ required: true, message: "请输入用户密码" }]}
+        >
+          <Input.Password placeholder="请输入用户密码" />
+        </Form.Item>
+        <Form.Item
+          label="是否管理员"
+          name="is_admin"
+          valuePropName="checked"
+        >
+          <Switch
+            checkedChildren="管理员"
+            unCheckedChildren="普通用户"
+          />
+        </Form.Item>
+        <Form.Item>
+          <div className="flex justify-center">
+            <Button
+              type="primary"
+              htmlType="submit"
+              loading={loading}
+            >
+              保存
+            </Button>
+          </div>
+        </Form.Item>
+      </Form>
+    </Card>
+  );
+};
+
+export default UserAdd;

+ 240 - 0
src/pages/UserToolsSetAdd.js

@@ -0,0 +1,240 @@
+import React, { useEffect, useState } from "react";
+import { Card, Checkbox, Button, message, Spin, Input } from "antd";
+import { useParams, useNavigate, useSearchParams } from "react-router-dom";
+import { userToolsSetApi } from "../services/api";
+
+const UserToolsSetAdd = () => {
+  const { userName } = useParams();
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const token = searchParams.get("token"); // 获取URL中的token参数
+  const [tools, setTools] = useState([]);
+  const [allTools, setAllTools] = useState([]); // 保存完整列表用于筛选
+  const [checked, setChecked] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [checkAll, setCheckAll] = useState(false);
+  const [indeterminate, setIndeterminate] = useState(false);
+  const [searchName, setSearchName] = useState("");
+  const [searchMcpName, setSearchMcpName] = useState("");
+  const [searchFuncName, setSearchFuncName] = useState("");
+
+  const fetchTools = async () => {
+    try {
+      setLoading(true);
+      const res = await userToolsSetApi.getTools();
+      const list = (res.data?.tools || [])
+        .filter((t) => String(t.status || "").toLowerCase() === "normal")
+        .map((t) => ({
+          value: t.tools_id,
+          label: `${t.tools_name || t.tools_id} / ${t.mcp_tools_name || ""} / ${t.tools_full_name || ""}`,
+          raw: t,
+        }));
+      setAllTools(list);
+      setTools(list);
+
+      // 如果有token参数,获取已设置的工具并回显
+      if (token) {
+        try {
+          const tokenRes = await userToolsSetApi.getTokenTools(token);
+          const existingToolsIds = tokenRes.data?.tools_ids || [];
+          setChecked(existingToolsIds);
+        } catch (error) {
+          console.error("获取已设置工具失败:", error);
+          // 不显示错误消息,因为可能是新token没有设置过工具
+        }
+      }
+    } catch (error) {
+      message.error("获取工具列表失败");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchTools();
+  }, [token]);
+
+  useEffect(() => {
+    // 根据当前可见的tools与选中数量,计算全选与半选态
+    const visibleValues = tools.map((t) => t.value);
+    const selectedVisibleCount = checked.filter((v) => visibleValues.includes(v)).length;
+    setCheckAll(visibleValues.length > 0 && selectedVisibleCount === visibleValues.length);
+    setIndeterminate(selectedVisibleCount > 0 && selectedVisibleCount < visibleValues.length);
+  }, [tools, checked]);
+
+  const onCheckAllChange = (e) => {
+    const checkedAll = e.target.checked;
+    const visibleValues = tools.map((t) => t.value);
+    if (checkedAll) {
+      // 合并已有其他不可见选中值,避免丢失
+      const otherChecked = checked.filter((v) => !visibleValues.includes(v));
+      setChecked([...otherChecked, ...visibleValues]);
+    } else {
+      // 取消当前可见项的选中,保留不可见项
+      const otherChecked = checked.filter((v) => !visibleValues.includes(v));
+      setChecked(otherChecked);
+    }
+  };
+
+  const handleSearch = () => {
+    const normalize = (s) => String(s || "").toLowerCase();
+    const nameQ = normalize(searchName);
+    const mcpQ = normalize(searchMcpName);
+    const funcQ = normalize(searchFuncName);
+    const filtered = allTools.filter((item) => {
+      const r = item.raw || {};
+      const nameMatch = !nameQ || normalize(r.tools_name || r.tools_id).includes(nameQ);
+      const mcpMatch = !mcpQ || normalize(r.mcp_tools_name).includes(mcpQ);
+      const funcSrc = r.tools_function_name || r.tools_full_name || "";
+      const funcMatch = !funcQ || normalize(funcSrc).includes(funcQ);
+      return nameMatch && mcpMatch && funcMatch;
+    });
+    setTools(filtered);
+  };
+
+  const handleReset = () => {
+    setSearchName("");
+    setSearchMcpName("");
+    setSearchFuncName("");
+    setTools(allTools);
+  };
+
+  const handleSave = async () => {
+    // 允许清空的需求可按需放开:当前仍要求至少选择一个
+    if (checked.length === 0) {
+      message.warning("请至少选择一个工具");
+      return;
+    }
+    try {
+      setSaving(true);
+      const payload = {
+        user_name: decodeURIComponent(userName),
+        tools_ids: checked,
+        token: token || undefined, // 将当前token传给后端以覆盖保存
+      };
+      const res = await userToolsSetApi.save(payload);
+      if (res.data?.success) {
+        message.success(`保存成功,共 ${checked.length} 个工具`);
+        navigate("/user-tools-set", { replace: true });
+      } else {
+        message.error(res.data?.error || "保存失败");
+      }
+    } catch (error) {
+      message.error(error.response?.data?.error || "保存失败");
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const pageTitle = token ? (
+    <span>
+      为用户 {decodeURIComponent(userName)} 编辑工具集 (Token:
+      <span
+        style={{
+          color: "#1890ff",
+          cursor: "pointer",
+          textDecoration: "underline",
+          marginLeft: "4px",
+        }}
+        title="点击复制 Token"
+        onClick={() => {
+          navigator.clipboard
+            .writeText(token)
+            .then(() => {
+              message.success("Token 已复制到剪贴板");
+            })
+            .catch(() => {
+              message.error("复制失败");
+            });
+        }}
+      >
+        {token}
+      </span>
+      )
+    </span>
+  ) : (
+    `为用户 ${decodeURIComponent(userName)} 添加工具`
+  );
+
+  return (
+    <Card
+      title={pageTitle}
+      extra={<Button onClick={() => navigate(-1)}>返回</Button>}
+    >
+      {loading ? (
+        <div style={{ textAlign: "center" }}>
+          <Spin />
+        </div>
+      ) : (
+        <div>
+          <div className=" mb-[20px]">
+            {/* <Checkbox
+              indeterminate={indeterminate}
+              onChange={onCheckAllChange}
+              checked={checkAll}
+            >
+              全选
+            </Checkbox> */}
+            <Input
+              className="mr-[20px]"
+              placeholder="输入工具名"
+              allowClear
+              value={searchName}
+              onChange={(e) => setSearchName(e.target.value)}
+              style={{ width: 200 }}
+            />
+            <Input
+              className="mr-[20px]"
+              placeholder="输入 mcp 工具名"
+              allowClear
+              value={searchMcpName}
+              onChange={(e) => setSearchMcpName(e.target.value)}
+              style={{ width: 200 }}
+            />
+            <Input
+              placeholder="输入工具功能名或全称"
+              allowClear
+              value={searchFuncName}
+              onChange={(e) => setSearchFuncName(e.target.value)}
+              style={{ width: 220 }}
+            />
+            <Button
+              type="primary"
+              style={{ marginLeft: 12 }}
+              onClick={handleSearch}
+            >
+              搜索
+            </Button>
+            <Button
+              style={{ marginLeft: 8 }}
+              onClick={handleReset}
+            >
+              重置
+            </Button>
+          </div>
+          <Checkbox.Group
+            options={tools}
+            value={checked}
+            onChange={(list) => setChecked(list)}
+            style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}
+          />
+          <div
+            className="flex justify-center"
+            style={{ marginTop: 16 }}
+          >
+            <Button
+              type="primary"
+              onClick={handleSave}
+              loading={saving}
+            >
+              保存
+            </Button>
+          </div>
+        </div>
+      )}
+    </Card>
+  );
+};
+
+export default UserToolsSetAdd;

+ 125 - 0
src/pages/UserToolsSetList.js

@@ -0,0 +1,125 @@
+import React, { useEffect, useState } from "react";
+import { Table, Card, message, Button, Tooltip } from "antd";
+import { useNavigate } from "react-router-dom";
+import { userToolsSetApi } from "../services/api";
+
+const UserToolsSet = () => {
+  const [data, setData] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const navigate = useNavigate();
+  const isAdmin = typeof window !== "undefined" ? Number(localStorage.getItem("isAdmin") || 0) : 0;
+
+  const formatDateTime = (v) => {
+    if (!v) return "";
+    const d = new Date(v);
+    const pad = (n) => String(n).padStart(2, "0");
+    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(
+      d.getMinutes()
+    )}:${pad(d.getSeconds())}`;
+  };
+
+  const fetchUsers = async () => {
+    try {
+      setLoading(true);
+      const [usersRes, tokensRes] = await Promise.all([userToolsSetApi.getUsers(), userToolsSetApi.getTokens()]);
+      const users = usersRes.data?.users || [];
+      const tokens = tokensRes.data?.tokens || [];
+
+      // 将 tokens 按 user 分组
+      const tokensByUser = tokens.reduce((acc, t) => {
+        const u = t.user || t.name;
+        if (!u) return acc;
+        if (!acc[u]) acc[u] = [];
+        acc[u].push(t.token);
+        return acc;
+      }, {});
+
+      // 合并:每个用户仅一行,保留其 create_time,并将 tokens 列表合并在一条记录中
+      const merged = users.map((u) => ({
+        name: u.name,
+        create_time: u.create_time,
+        tokens: tokensByUser[u.name] || [],
+      }));
+      console.log("%c [ merged ]-39", "font-size:13px; background:pink; color:#bf2c9f;", merged);
+
+      setData(merged);
+    } catch (error) {
+      message.error("获取用户或token列表失败");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchUsers();
+  }, []);
+
+  const columns = [
+    {
+      title: "姓名",
+      dataIndex: "name",
+      key: "name",
+    },
+    {
+      title: "创建时间",
+      dataIndex: "create_time",
+      key: "create_time",
+      render: (text) => formatDateTime(text),
+    },
+    {
+      title: "操作",
+      key: "actions",
+      width: 260,
+      align: "left",
+      render: (_, record) => {
+        const btns =
+          record.tokens && record.tokens.length > 0
+            ? record.tokens.map((token, idx) => (
+                <Tooltip
+                  key={idx}
+                  title={token}
+                >
+                  <Button
+                    type="link"
+                    onClick={() =>
+                      navigate(
+                        `/user-tools-set/add/${encodeURIComponent(record.name)}?token=${encodeURIComponent(token)}`
+                      )
+                    }
+                    style={{ padding: 0, marginRight: 12 }}
+                  >
+                    {`token${idx + 1}`}分配工具集
+                  </Button>
+                </Tooltip>
+              ))
+            : null;
+        return <>{btns}</>;
+      },
+    },
+  ];
+
+  return (
+    <Card
+      title="用户工具集管理"
+      extra={
+        isAdmin === 1 ? (
+          <Button
+            type="primary"
+            onClick={() => navigate("/user-tools-set/user/add")}
+          >
+            新增用户
+          </Button>
+        ) : null
+      }
+    >
+      <Table
+        rowKey={(r) => r.name}
+        columns={columns}
+        dataSource={data}
+        loading={loading}
+      />
+    </Card>
+  );
+};
+
+export default UserToolsSet;

+ 28 - 0
src/services/api.js

@@ -12,6 +12,19 @@ const api = axios.create({
   timeout: 10000,
 });
 
+// 在每次请求中附带本地 token
+api.interceptors.request.use(
+  (config) => {
+    const token = typeof window !== "undefined" ? localStorage.getItem("userToken") : null;
+    if (token) {
+      config.headers = config.headers || {};
+      config.headers["Authorization"] = `Bearer ${token}`;
+    }
+    return config;
+  },
+  (error) => Promise.reject(error)
+);
+
 api.interceptors.response.use(
   (response) => response,
   (error) => {
@@ -50,4 +63,19 @@ export const accountsApi = {
   update: (id, data) => api.put(`/accounts/${id}`, data),
 };
 
+export const authApi = {
+  login: (data) => api.post("/user-token/login", data),
+};
+
+export const userToolsSetApi = {
+  getUsers: () => api.get("/user-tools-set/users"),
+  getTools: () => api.get("/user-tools-set/tools"),
+  save: (data) => api.post("/user-tools-set/save", data),
+  addUser: (data) => api.post("/user-tools-set/add-user", data),
+  // 新增:获取 tokens 列表
+  getTokens: () => api.get("/user-tools-set/tokens"),
+  // 新增:根据token获取已设置的工具集
+  getTokenTools: (token) => api.get(`/user-tools-set/token-tools/${encodeURIComponent(token)}`),
+};
+
 export default api;