فهرست منبع

feat: 添加工具调用日志功能

- 新增工具调用日志路由和API接口
- 实现工具调用日志列表和详情页面
- 添加日志查询、分页和详情查看功能
- 在导航菜单中添加工具调用日志入口
max_liu 1 ماه پیش
والد
کامیت
f157a62599
6فایلهای تغییر یافته به همراه580 افزوده شده و 4 حذف شده
  1. 112 0
      server/routes/toolsCallLog.js
  2. 2 0
      server/server.js
  3. 12 1
      src/App.js
  4. 195 0
      src/pages/ToolsCallLogDetail.js
  5. 251 0
      src/pages/ToolsCallLogList.js
  6. 8 3
      src/services/api.js

+ 112 - 0
server/routes/toolsCallLog.js

@@ -0,0 +1,112 @@
+const express = require("express");
+const router = express.Router();
+const db = require("../config/database");
+
+// 获取工具调用日志列表
+router.get("/", async (req, res) => {
+  try {
+    const { page = 1, pageSize = 10, user, mcp_tools_name, status, start_time, end_time } = req.query;
+    const offset = (page - 1) * pageSize;
+
+    // 构建查询条件
+    let whereConditions = [];
+    let queryParams = [];
+
+    if (user) {
+      whereConditions.push("user LIKE ?");
+      queryParams.push(`%${user}%`);
+    }
+
+    if (mcp_tools_name) {
+      whereConditions.push("mcp_tools_name LIKE ?");
+      queryParams.push(`%${mcp_tools_name}%`);
+    }
+
+    if (status) {
+      whereConditions.push("status = ?");
+      queryParams.push(status);
+    }
+
+    if (start_time) {
+      whereConditions.push("call_timestamp >= ?");
+      queryParams.push(parseInt(start_time));
+    }
+
+    if (end_time) {
+      whereConditions.push("call_timestamp <= ?");
+      queryParams.push(parseInt(end_time));
+    }
+
+    const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
+
+    // 获取总数
+    const countQuery = `SELECT COUNT(*) as total FROM tools_call_log ${whereClause}`;
+    const countResult = await db.query(countQuery, queryParams);
+    const total = countResult[0].total;
+
+    // 获取列表数据
+    const listQuery = `
+      SELECT id, user, token, mcp_tools_name, status, fail_msg, 
+             call_timestamp, finish_timestamp
+      FROM tools_call_log 
+      ${whereClause}
+      ORDER BY call_timestamp DESC 
+      LIMIT ? OFFSET ?
+    `;
+    
+    const listParams = [...queryParams, parseInt(pageSize), offset];
+    const list = await db.query(listQuery, listParams);
+
+    res.json({
+      success: true,
+      data: {
+        list,
+        total,
+        page: parseInt(page),
+        pageSize: parseInt(pageSize),
+      },
+    });
+  } catch (error) {
+    console.error("获取工具调用日志列表失败:", error);
+    res.status(500).json({
+      success: false,
+      error: "获取工具调用日志列表失败",
+    });
+  }
+});
+
+// 获取工具调用日志详情
+router.get("/:id", async (req, res) => {
+  try {
+    const { id } = req.params;
+    
+    const query = `
+      SELECT id, user, token, mcp_tools_name, request_params, status, 
+             response, fail_msg, call_timestamp, finish_timestamp
+      FROM tools_call_log 
+      WHERE id = ?
+    `;
+    
+    const result = await db.query(query, [id]);
+    
+    if (result.length === 0) {
+      return res.status(404).json({
+        success: false,
+        error: "工具调用日志不存在",
+      });
+    }
+
+    res.json({
+      success: true,
+      data: result[0],
+    });
+  } catch (error) {
+    console.error("获取工具调用日志详情失败:", error);
+    res.status(500).json({
+      success: false,
+      error: "获取工具调用日志详情失败",
+    });
+  }
+});
+
+module.exports = router;

+ 2 - 0
server/server.js

@@ -9,6 +9,7 @@ const toolsLibraryRoutes = require("./routes/toolsLibrary");
 const accountsRoutes = require("./routes/accounts");
 const userTokenRoutes = require("./routes/userToken");
 const userToolsSetRoutes = require("./routes/userToolsSet");
+const toolsCallLogRoutes = require("./routes/toolsCallLog");
 
 const app = express();
 const PORT = process.env.PORT || 3001;
@@ -39,6 +40,7 @@ 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.use("/api/tools-call-log", toolsCallLogRoutes);
 
 app.get("/api/health", (req, res) => {
   res.json({ status: "OK", message: "Server is running" });

+ 12 - 1
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, Dropdown, Avatar, message } from "antd";
-import { ToolOutlined, SearchOutlined, AppstoreOutlined, UserOutlined, PoweroffOutlined } from "@ant-design/icons";
+import { ToolOutlined, SearchOutlined, AppstoreOutlined, UserOutlined, PoweroffOutlined, FileTextOutlined } from "@ant-design/icons";
 import Dashboard from "./components/Dashboard";
 import PendingToolsList from "./pages/PendingToolsList";
 import PendingToolsAdd from "./pages/PendingToolsAdd";
@@ -18,6 +18,8 @@ import Login from "./pages/Login";
 import UserToolsSetList from "./pages/UserToolsSetList";
 import UserToolsSetAdd from "./pages/UserToolsSetAdd";
 import UserAdd from "./pages/UserAdd";
+import ToolsCallLogList from "./pages/ToolsCallLogList";
+import ToolsCallLogDetail from "./pages/ToolsCallLogDetail";
 
 const { Header, Content, Sider } = Layout;
 
@@ -55,6 +57,7 @@ function AppContent() {
     if (path.startsWith("/tools-library")) return ["3"];
     if (path.startsWith("/accounts")) return ["4"];
     if (path.startsWith("/user-tools-set")) return ["5"];
+    if (path.startsWith("/tools-call-log")) return ["6"];
     return ["1"]; // 默认选中第一项
   };
 
@@ -116,6 +119,12 @@ function AppContent() {
                   label: "用户工具集管理",
                   onClick: () => navigate("/user-tools-set"),
                 },
+                {
+                  key: "6",
+                  icon: <FileTextOutlined />,
+                  label: "工具调用日志",
+                  onClick: () => navigate("/tools-call-log"),
+                },
               ]}
             />
           </Sider>
@@ -139,6 +148,8 @@ function AppContent() {
               <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 />} />
+              <Route path="/tools-call-log" element={<ToolsCallLogList />} />
+              <Route path="/tools-call-log/detail/:id" element={<ToolsCallLogDetail />} />
             </Routes>
           </Content>
         </Layout>

+ 195 - 0
src/pages/ToolsCallLogDetail.js

@@ -0,0 +1,195 @@
+import React, { useState, useEffect } from "react";
+import { Card, Descriptions, Button, message, Tag, Spin } from "antd";
+import { ArrowLeftOutlined, CopyOutlined } from "@ant-design/icons";
+import { useParams, useNavigate } from "react-router-dom";
+import { toolsCallLogApi } from "../services/api";
+import dayjs from "dayjs";
+
+const ToolsCallLogDetail = () => {
+  const { id } = useParams();
+  const navigate = useNavigate();
+  const [loading, setLoading] = useState(false);
+  const [data, setData] = useState(null);
+
+  // 格式化时间戳为 YYYY-MM-DD HH:mm:ss
+  const formatTimestamp = (timestamp) => {
+    if (!timestamp) return "-";
+    return dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss");
+  };
+
+  // 计算耗时(秒)
+  const calculateDuration = (callTime, finishTime) => {
+    if (!callTime || !finishTime) return "-";
+    return ((finishTime - callTime) / 1000).toFixed(2) + "s";
+  };
+
+  // 复制到剪贴板
+  const copyToClipboard = async (text) => {
+    try {
+      await navigator.clipboard.writeText(text);
+      message.success("复制成功");
+    } catch (error) {
+      message.error("复制失败");
+    }
+  };
+
+  // 获取详情数据
+  const fetchDetail = async () => {
+    setLoading(true);
+    try {
+      const response = await toolsCallLogApi.getDetail(id);
+      setData(response.data);
+    } catch (error) {
+      message.error("获取详情失败");
+      console.error("获取工具调用日志详情失败:", error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 返回列表
+  const handleBack = () => {
+    navigate("/tools-call-log");
+  };
+
+  useEffect(() => {
+    if (id) {
+      fetchDetail();
+    }
+  }, [id]);
+
+  if (loading) {
+    return (
+      <div style={{ padding: "24px", textAlign: "center" }}>
+        <Spin size="large" />
+      </div>
+    );
+  }
+
+  if (!data) {
+    return (
+      <div style={{ padding: "24px" }}>
+        <Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
+          返回列表
+        </Button>
+        <div style={{ textAlign: "center", marginTop: "50px" }}>
+          <p>未找到相关数据</p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div style={{ padding: "24px" }}>
+      <div style={{ marginBottom: "16px" }}>
+        <Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
+          返回列表
+        </Button>
+      </div>
+
+      <Card title="工具调用日志详情">
+        <Descriptions bordered column={2}>
+          <Descriptions.Item label="用户名" span={1}>
+            {data.user || "-"}
+          </Descriptions.Item>
+          <Descriptions.Item label="工具名称" span={1}>
+            {data.mcp_tools_name || "-"}
+          </Descriptions.Item>
+          <Descriptions.Item label="Token" span={2}>
+            <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
+              <span style={{ wordBreak: "break-all" }}>{data.token || "-"}</span>
+              {data.token && (
+                <Button
+                  type="text"
+                  size="small"
+                  icon={<CopyOutlined />}
+                  onClick={() => copyToClipboard(data.token)}
+                  title="复制Token"
+                />
+              )}
+            </div>
+          </Descriptions.Item>
+          <Descriptions.Item label="状态" span={1}>
+            <Tag color={data.status === "success" ? "green" : "red"}>
+              {data.status === "success" ? "成功" : "失败"}
+            </Tag>
+          </Descriptions.Item>
+          <Descriptions.Item label="耗时" span={1}>
+            {calculateDuration(data.call_timestamp, data.finish_timestamp)}
+          </Descriptions.Item>
+          <Descriptions.Item label="调用时间" span={1}>
+            {formatTimestamp(data.call_timestamp)}
+          </Descriptions.Item>
+          <Descriptions.Item label="完成时间" span={1}>
+            {formatTimestamp(data.finish_timestamp)}
+          </Descriptions.Item>
+          {data.fail_msg && (
+            <Descriptions.Item label="失败原因" span={2}>
+              <div style={{ color: "#ff4d4f", wordBreak: "break-word" }}>
+                {data.fail_msg}
+              </div>
+            </Descriptions.Item>
+          )}
+        </Descriptions>
+      </Card>
+
+      {/* 请求参数 */}
+      <Card title="请求参数" style={{ marginTop: "16px" }}>
+        <div style={{ position: "relative" }}>
+          <Button
+            type="text"
+            size="small"
+            icon={<CopyOutlined />}
+            onClick={() => copyToClipboard(data.request_params || "")}
+            style={{ position: "absolute", top: "8px", right: "8px", zIndex: 1 }}
+            title="复制请求参数"
+          />
+          <pre
+            style={{
+              background: "#f5f5f5",
+              padding: "12px",
+              borderRadius: "4px",
+              overflow: "auto",
+              maxHeight: "300px",
+              margin: 0,
+              fontSize: "12px",
+              lineHeight: "1.4",
+            }}
+          >
+            {data.request_params || "无请求参数"}
+          </pre>
+        </div>
+      </Card>
+
+      {/* 工具响应 */}
+      <Card title="工具响应" style={{ marginTop: "16px" }}>
+        <div style={{ position: "relative" }}>
+          <Button
+            type="text"
+            size="small"
+            icon={<CopyOutlined />}
+            onClick={() => copyToClipboard(data.response || "")}
+            style={{ position: "absolute", top: "8px", right: "8px", zIndex: 1 }}
+            title="复制工具响应"
+          />
+          <pre
+            style={{
+              background: "#f5f5f5",
+              padding: "12px",
+              borderRadius: "4px",
+              overflow: "auto",
+              maxHeight: "300px",
+              margin: 0,
+              fontSize: "12px",
+              lineHeight: "1.4",
+            }}
+          >
+            {data.response || "无响应数据"}
+          </pre>
+        </div>
+      </Card>
+    </div>
+  );
+};
+
+export default ToolsCallLogDetail;

+ 251 - 0
src/pages/ToolsCallLogList.js

@@ -0,0 +1,251 @@
+import React, { useState, useEffect } from "react";
+import { Table, Button, Input, Select, DatePicker, Space, message, Tag } from "antd";
+import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
+import { useNavigate } from "react-router-dom";
+import { toolsCallLogApi } from "../services/api";
+import dayjs from "dayjs";
+
+const { RangePicker } = DatePicker;
+const { Option } = Select;
+
+const ToolsCallLogList = () => {
+  const navigate = useNavigate();
+  const [loading, setLoading] = useState(false);
+  const [data, setData] = useState([]);
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+  
+  // 搜索条件
+  const [searchParams, setSearchParams] = useState({
+    user: "",
+    mcp_tools_name: "",
+    status: "",
+    timeRange: null,
+  });
+
+  // 格式化时间戳为 YYYY-MM-DD HH:mm:ss
+  const formatTimestamp = (timestamp) => {
+    if (!timestamp) return "-";
+    return dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss");
+  };
+
+  // 计算耗时(秒)
+  const calculateDuration = (callTime, finishTime) => {
+    if (!callTime || !finishTime) return "-";
+    return ((finishTime - callTime) / 1000).toFixed(2) + "s";
+  };
+
+  // 获取数据
+  const fetchData = async (params = {}) => {
+    setLoading(true);
+    try {
+      const queryParams = {
+        page: pagination.current,
+        pageSize: pagination.pageSize,
+        ...searchParams,
+        ...params,
+      };
+
+      // 处理时间范围
+      if (searchParams.timeRange && searchParams.timeRange.length === 2) {
+        queryParams.startTime = searchParams.timeRange[0].valueOf();
+        queryParams.endTime = searchParams.timeRange[1].valueOf();
+      }
+
+      const response = await toolsCallLogApi.getList(queryParams);
+      const { data: list, total } = response.data;
+
+      setData(list);
+      setPagination(prev => ({
+        ...prev,
+        total,
+      }));
+    } catch (error) {
+      message.error("获取数据失败");
+      console.error("获取工具调用日志失败:", error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 搜索
+  const handleSearch = () => {
+    setPagination(prev => ({ ...prev, current: 1 }));
+    fetchData({ page: 1 });
+  };
+
+  // 重置搜索
+  const handleReset = () => {
+    setSearchParams({
+      user: "",
+      mcp_tools_name: "",
+      status: "",
+      timeRange: null,
+    });
+    setPagination(prev => ({ ...prev, current: 1 }));
+    setTimeout(() => {
+      fetchData({ 
+        page: 1,
+        user: "",
+        mcp_tools_name: "",
+        status: "",
+        timeRange: null,
+      });
+    }, 0);
+  };
+
+  // 查看详情
+  const handleViewDetail = (record) => {
+    navigate(`/tools-call-log/detail/${record.id}`);
+  };
+
+  // 表格列配置
+  const columns = [
+    {
+      title: "用户名",
+      dataIndex: "user",
+      key: "user",
+      width: 120,
+    },
+    {
+      title: "工具名称",
+      dataIndex: "mcp_tools_name",
+      key: "mcp_tools_name",
+      width: 150,
+    },
+    {
+      title: "状态",
+      dataIndex: "status",
+      key: "status",
+      width: 100,
+      render: (status) => (
+        <Tag color={status === "success" ? "green" : "red"}>
+          {status === "success" ? "成功" : "失败"}
+        </Tag>
+      ),
+    },
+    {
+      title: "失败原因",
+      dataIndex: "fail_msg",
+      key: "fail_msg",
+      width: 200,
+      render: (text) => text || "-",
+      ellipsis: true,
+    },
+    {
+      title: "调用时间",
+      dataIndex: "call_timestamp",
+      key: "call_timestamp",
+      width: 160,
+      render: formatTimestamp,
+    },
+    {
+      title: "完成时间",
+      dataIndex: "finish_timestamp",
+      key: "finish_timestamp",
+      width: 160,
+      render: formatTimestamp,
+    },
+    {
+      title: "耗时",
+      key: "duration",
+      width: 100,
+      render: (_, record) => calculateDuration(record.call_timestamp, record.finish_timestamp),
+    },
+    {
+      title: "操作",
+      key: "action",
+      width: 100,
+      render: (_, record) => (
+        <Button type="link" onClick={() => handleViewDetail(record)}>
+          详情
+        </Button>
+      ),
+    },
+  ];
+
+  // 分页变化
+  const handleTableChange = (paginationConfig) => {
+    setPagination(prev => ({
+      ...prev,
+      current: paginationConfig.current,
+      pageSize: paginationConfig.pageSize,
+    }));
+    fetchData({
+      page: paginationConfig.current,
+      pageSize: paginationConfig.pageSize,
+    });
+  };
+
+  useEffect(() => {
+    fetchData();
+  }, []);
+
+  return (
+    <div style={{ padding: "24px" }}>
+      <h2>工具调用日志</h2>
+      
+      {/* 搜索区域 */}
+      <div style={{ marginBottom: "16px", padding: "16px", background: "#fafafa", borderRadius: "6px" }}>
+        <Space wrap>
+          <Input
+            placeholder="用户名"
+            value={searchParams.user}
+            onChange={(e) => setSearchParams(prev => ({ ...prev, user: e.target.value }))}
+            style={{ width: 120 }}
+          />
+          <Input
+            placeholder="工具名称"
+            value={searchParams.mcp_tools_name}
+            onChange={(e) => setSearchParams(prev => ({ ...prev, mcp_tools_name: e.target.value }))}
+            style={{ width: 150 }}
+          />
+          <Select
+            placeholder="状态"
+            value={searchParams.status}
+            onChange={(value) => setSearchParams(prev => ({ ...prev, status: value }))}
+            style={{ width: 120 }}
+            allowClear
+          >
+            <Option value="success">成功</Option>
+            <Option value="failure">失败</Option>
+          </Select>
+          <RangePicker
+            placeholder={["开始时间", "结束时间"]}
+            value={searchParams.timeRange}
+            onChange={(dates) => setSearchParams(prev => ({ ...prev, timeRange: dates }))}
+            showTime
+            format="YYYY-MM-DD HH:mm:ss"
+          />
+          <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
+            搜索
+          </Button>
+          <Button icon={<ReloadOutlined />} onClick={handleReset}>
+            重置
+          </Button>
+        </Space>
+      </div>
+
+      {/* 表格 */}
+      <Table
+        columns={columns}
+        dataSource={data}
+        rowKey="id"
+        loading={loading}
+        pagination={{
+          ...pagination,
+          showSizeChanger: true,
+          showQuickJumper: true,
+          showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
+        }}
+        onChange={handleTableChange}
+        scroll={{ x: 1200 }}
+      />
+    </div>
+  );
+};
+
+export default ToolsCallLogList;

+ 8 - 3
src/services/api.js

@@ -4,8 +4,10 @@ import axios from "axios";
 // 非 localhost 环境下,优先使用同源地址,避免跨域
 const isLocalhost = typeof window !== "undefined" && /localhost/.test(window.location.host);
 const API_BASE_URL = isLocalhost
-  ? (process.env.REACT_APP_API_BASE_URL || "http://localhost:3001/api")
-  : (typeof window !== "undefined" ? `${window.location.origin}/api` : "http://tools.aiddit.com/api");
+  ? process.env.REACT_APP_API_BASE_URL || "http://localhost:3001/api"
+  : typeof window !== "undefined"
+  ? `${window.location.origin}/api`
+  : "http://tools.aiddit.com/api";
 
 const api = axios.create({
   baseURL: API_BASE_URL,
@@ -77,5 +79,8 @@ export const userToolsSetApi = {
   // 新增:根据token获取已设置的工具集
   getTokenTools: (token) => api.get(`/user-tools-set/token-tools/${encodeURIComponent(token)}`),
 };
-
+export const toolsCallLogApi = {
+  getList: (params) => api.get("/tools-call-log", { params }),
+  getDetail: (id) => api.get(`/tools-call-log/${id}`),
+};
 export default api;