1 #1

Merged
hnu202326010419 merged 15 commits from zhaowenqi_branch into develop 2 months ago

@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

@ -0,0 +1,33 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip

@ -0,0 +1,944 @@
### 测试 Hello
GET http://localhost:8080/test/hello
### 测试所有数据库
GET http://localhost:8080/test/all
### ==================== 新增表接口测试 ====================
### 好友关系 - 查询用户好友列表
GET http://localhost:8080/friend-relation/list/1
### 好友关系 - 添加好友
POST http://localhost:8080/friend-relation
Content-Type: application/json
{
"userId": 1,
"friendId": 2,
"friendUsername": "user2",
"remarkName": "小明"
}
### 好友请求 - 查询待处理请求
GET http://localhost:8080/friend-request/list/1/0
### 好友请求 - 发送好友请求
POST http://localhost:8080/friend-request
Content-Type: application/json
{
"applicantId": 1,
"recipientId": 2,
"applyMsg": "我是张三,加个好友"
}
### 好友请求 - 处理请求(同意)
PUT http://localhost:8080/friend-request
Content-Type: application/json
{
"id": 1,
"status": 1
}
### 查询分享 - 查询接收的分享
GET http://localhost:8080/query-share/list/receive/1
### 查询分享 - 分享查询
POST http://localhost:8080/query-share
Content-Type: application/json
{
"shareUserId": 1,
"receiveUserId": 2,
"dialogId": "conv_12345",
"targetRounds": "[1,2,3]",
"queryTitle": "销售数据分析"
}
### Token消耗 - 查询所有记录
GET http://localhost:8080/token-consume/list
### Token消耗 - 查询指定日期
GET http://localhost:8080/token-consume/gemini-2.5-pro/2025-12-01
### Token消耗 - 添加记录
POST http://localhost:8080/token-consume
Content-Type: application/json
{
"llmName": "gemini-2.5-pro",
"totalTokens": 1500,
"promptTokens": 1000,
"completionTokens": 500,
"consumeDate": "2025-12-01"
}
### 性能指标 - 查询所有
GET http://localhost:8080/performance-metric/list
### 性能指标 - 按类型查询
GET http://localhost:8080/performance-metric/list/query_count
### 性能指标 - 添加记录
POST http://localhost:8080/performance-metric
Content-Type: application/json
{
"metricType": "query_count",
"metricValue": 150,
"metricTime": "2025-12-01T10:00:00"
}
### 连接日志 - 查询所有
GET http://localhost:8080/db-connection-log/list
### 连接日志 - 按连接ID查询
GET http://localhost:8080/db-connection-log/list/connection/1
### 连接日志 - 添加日志
POST http://localhost:8080/db-connection-log
Content-Type: application/json
{
"dbConnectionId": 1,
"dbName": "订单主库",
"status": "success",
"handlerId": 1
}
### 用户搜索 - 查询用户搜索历史
GET http://localhost:8080/user-search/list/1
### 用户搜索 - 查询常用搜索Top 5
GET http://localhost:8080/user-search/list/top/1/5
### 用户搜索 - 记录搜索
POST http://localhost:8080/user-search
Content-Type: application/json
{
"userId": 1,
"sqlContent": "SELECT * FROM users WHERE status = 1",
"queryTitle": "查询活跃用户"
}
### 收藏组 - 查询用户收藏组
GET http://localhost:8080/query-collection/list/1
### 收藏组 - 创建收藏组
POST http://localhost:8080/query-collection
Content-Type: application/json
{
"userId": 1,
"groupName": "销售报表查询"
}
### 收藏记录 - 按组查询
GET http://localhost:8080/collection-record/list/query/674c8e9f1234567890abcdef
### 收藏记录 - 添加收藏
POST http://localhost:8080/collection-record
Content-Type: application/json
{
"queryId": "674c8e9f1234567890abcdef",
"userId": 1,
"sqlContent": "SELECT * FROM orders WHERE date > '2025-01-01'",
"dbConnectionId": 1,
"llmConfigId": 1
}
### 对话详情 - 查询对话内容
GET http://localhost:8080/dialog-detail/conv_12345
### 对话详情 - 保存对话
POST http://localhost:8080/dialog-detail
Content-Type: application/json
{
"dialogId": "conv_12345",
"rounds": [
{
"roundNum": 1,
"userInput": "查询所有用户",
"aiResponse": "已生成SQL查询",
"generatedSql": "SELECT * FROM users"
}
]
}
### SQL缓存 - 查询用户缓存
GET http://localhost:8080/sql-cache/list/1
### SQL缓存 - 添加缓存
POST http://localhost:8080/sql-cache
Content-Type: application/json
{
"nlHash": "abc123def456",
"userId": 1,
"connectionId": 1,
"tableIds": [1, 2],
"dbType": "mysql",
"generatedSql": "SELECT * FROM users",
"hitCount": 0,
"expireTime": "2025-12-08T00:00:00"
}
### AI交互日志 - 按用户查询
GET http://localhost:8080/ai-interaction-log/list/user/1
### AI交互日志 - 按模型查询
GET http://localhost:8080/ai-interaction-log/list/llm/gemini-2.5-pro
### AI交互日志 - 添加日志
POST http://localhost:8080/ai-interaction-log
Content-Type: application/json
{
"userId": 1,
"requestType": "nl2sql",
"llmName": "gemini-2.5-pro",
"requestParams": {
"naturalLanguage": "查询所有用户"
},
"tokenUsage": {
"promptTokens": 100,
"completionTokens": 50,
"totalTokens": 150
},
"responseTime": 1200,
"status": "success"
}
### 好友聊天 - 查询聊天记录
GET http://localhost:8080/friend-chat/list/1/2
### 好友聊天 - 查询未读消息
GET http://localhost:8080/friend-chat/unread/1
### 好友聊天 - 发送消息
POST http://localhost:8080/friend-chat
Content-Type: application/json
{
"userId": 1,
"friendId": 2,
"contentType": "text",
"content": {
"text": "你好,最近怎么样?"
}
}
### ==================== 认证接口 ====================
### 登录
POST http://localhost:8080/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "123456"
}
### ==================== 查询执行接口 ====================
### 执行查询(新对话)
POST http://localhost:8080/query/execute
Content-Type: application/json
userId: 1
{
"userPrompt": "查询所有用户信息",
"model": "gemini-2.5-pro",
"database": "销售数据库"
}
### 执行查询(继续对话)
POST http://localhost:8080/query/execute
Content-Type: application/json
userId: 1
{
"userPrompt": "按订单量排序",
"model": "gemini-2.5-pro",
"database": "销售数据库",
"conversationId": "conv_12345678"
}
### ==================== 对话历史接口 ====================
### 获取用户对话列表
GET http://localhost:8080/dialog/list
userId: 1
### 获取对话详情
GET http://localhost:8080/dialog/conv_12345678
### ==================== 用户管理接口 ====================
### 查询所有用户
GET http://localhost:8080/user/list
### 添加用户
POST http://localhost:8080/user
Content-Type: application/json
{
"username": "admin",
"password": "123456",
"email": "admin@example.com",
"phonenumber": "13800138000",
"roleId": 1,
"status": 1
}
### 分页查询
GET http://localhost:8080/user/page?current=1&size=10
### 根据ID查询
GET http://localhost:8080/user/3
### 更新用户
PUT http://localhost:8080/user
Content-Type: application/json
{
"id": 3,
"username": "admin",
"email": "newemail@example.com",
"status": 1
}
### 删除用户
DELETE http://localhost:8080/user/3
### 根据用户名查询
GET http://localhost:8080/user/username/admin
### 根据手机号搜索用户
GET http://localhost:8080/user/search/phone?phoneNumber=13800138000
### 修改密码
POST http://localhost:8080/user/change-password
Content-Type: application/json
{
"userId": 1,
"oldPassword": "123456",
"newPassword": "newpassword123"
}
### ==================== 好友管理接口 ====================
### 查询用户的好友列表
GET http://localhost:8080/friend-relation/list/1
### 查询特定好友关系
GET http://localhost:8080/friend-relation/1/2
### 添加好友关系(通常由系统自动创建)
POST http://localhost:8080/friend-relation
Content-Type: application/json
{
"userId": 1,
"friendId": 2,
"friendUsername": "user2",
"onlineStatus": 1,
"remarkName": "小明"
}
### 更新好友备注
PUT http://localhost:8080/friend-relation
Content-Type: application/json
{
"id": 1,
"remarkName": "我的好朋友小明"
}
### 删除好友关系通过关系ID
DELETE http://localhost:8080/friend-relation/1
### 删除好友关系通过用户ID和好友ID
DELETE http://localhost:8080/friend-relation/1/2
### ==================== 好友请求接口 ====================
### 查询用户收到的所有好友请求
GET http://localhost:8080/friend-request/list/1
### 查询用户收到的待处理好友请求status=0
GET http://localhost:8080/friend-request/list/1/0
### 查询特定好友请求
GET http://localhost:8080/friend-request/1
### 发送好友请求
POST http://localhost:8080/friend-request
Content-Type: application/json
{
"applicantId": 1,
"recipientId": 2,
"applyMsg": "你好,我想加你为好友",
"status": 0
}
### 接受好友请求(自动创建双向好友关系)
POST http://localhost:8080/friend-request/1/accept
### 拒绝好友请求
POST http://localhost:8080/friend-request/1/reject
### 删除好友请求记录
DELETE http://localhost:8080/friend-request/1
### ==================== 数据库连接管理接口 ====================
### 查询所有数据库连接
GET http://localhost:8080/db-connection/list
### 根据创建者查询数据库连接
GET http://localhost:8080/db-connection/list/4
### 根据ID查询数据库连接
GET http://localhost:8080/db-connection/1
### 添加数据库连接
POST http://localhost:8080/db-connection
Content-Type: application/json
{
"name": "测试MySQL连接",
"dbTypeId": 1,
"url": "127.0.0.1:3306/test_db",
"username": "root",
"password": "password",
"status": "disconnected",
"createUserId": 4
}
### 更新数据库连接
PUT http://localhost:8080/db-connection
Content-Type: application/json
{
"id": 1,
"name": "更新后的连接名称",
"status": "connected"
}
### 测试数据库连接
GET http://localhost:8080/db-connection/test/1
### 删除数据库连接
DELETE http://localhost:8080/db-connection/1
### ==================== 大模型配置接口 ====================
### 查询所有大模型配置
GET http://localhost:8080/llm-config/list
### 查询可用的大模型配置
GET http://localhost:8080/llm-config/list/available
### 根据ID查询大模型配置
GET http://localhost:8080/llm-config/1
### 添加大模型配置
POST http://localhost:8080/llm-config
Content-Type: application/json
{
"name": "智谱AI",
"version": "4.0",
"apiKey": "your-api-key-here",
"apiUrl": "https://api.zhipuai.com/v4/chat/completions",
"statusId": 1,
"isDisabled": 0,
"timeout": 5000,
"createUserId": 4
}
### 更新大模型配置
PUT http://localhost:8080/llm-config
Content-Type: application/json
{
"id": 1,
"name": "智谱AI",
"version": "4.5",
"timeout": 8000
}
### 禁用/启用大模型配置
PUT http://localhost:8080/llm-config/1/toggle
### 删除大模型配置
DELETE http://localhost:8080/llm-config/1
### ==================== 数据表元数据接口 ====================
### 查询所有表元数据
GET http://localhost:8080/table-metadata/list
### 根据数据库连接ID查询表元数据
GET http://localhost:8080/table-metadata/list/1
### 根据ID查询表元数据
GET http://localhost:8080/table-metadata/1
### 添加表元数据
POST http://localhost:8080/table-metadata
Content-Type: application/json
{
"dbConnectionId": 1,
"tableName": "orders",
"description": "订单表,存储所有订单信息"
}
### 更新表元数据
PUT http://localhost:8080/table-metadata
Content-Type: application/json
{
"id": 1,
"tableName": "orders",
"description": "订单表(已更新)"
}
### 删除表元数据
DELETE http://localhost:8080/table-metadata/1
### ==================== 字段元数据接口 ====================
### 查询所有字段元数据
GET http://localhost:8080/column-metadata/list
### 根据表ID查询字段元数据
GET http://localhost:8080/column-metadata/list/1
### 根据ID查询字段元数据
GET http://localhost:8080/column-metadata/1
### 添加字段元数据
POST http://localhost:8080/column-metadata
Content-Type: application/json
{
"tableId": 1,
"columnName": "order_id",
"dataType": "bigint(20)",
"description": "订单唯一标识",
"isPrimary": 1
}
### 更新字段元数据
PUT http://localhost:8080/column-metadata
Content-Type: application/json
{
"id": 1,
"columnName": "order_id",
"description": "订单ID主键"
}
### 删除字段元数据
DELETE http://localhost:8080/column-metadata/1
### ==================== 查询日志接口 ====================
### 查询所有查询日志
GET http://localhost:8080/query-log/list
### 根据用户ID查询查询日志
GET http://localhost:8080/query-log/list/user/4
### 根据对话ID查询查询日志
GET http://localhost:8080/query-log/list/dialog/conv_12345678
### 根据ID查询查询日志
GET http://localhost:8080/query-log/1
### 添加查询日志
POST http://localhost:8080/query-log
Content-Type: application/json
{
"dialogId": "conv_12345678",
"dataSourceId": 1,
"userId": 4,
"executeResult": 1
}
### 删除查询日志
DELETE http://localhost:8080/query-log/1
### ==================== 数据库类型字典接口 ====================
### 查询所有数据库类型
GET http://localhost:8080/db-type/list
### 根据ID查询数据库类型
GET http://localhost:8080/db-type/1
### 根据类型编码查询
GET http://localhost:8080/db-type/code/mysql
### 添加数据库类型
POST http://localhost:8080/db-type
Content-Type: application/json
{
"typeName": "MySQL",
"typeCode": "mysql",
"description": "关系型数据库"
}
### 更新数据库类型
PUT http://localhost:8080/db-type
Content-Type: application/json
{
"id": 1,
"typeName": "MySQL",
"description": "关系型数据库(已更新)"
}
### 删除数据库类型
DELETE http://localhost:8080/db-type/1
### ==================== 大模型状态字典接口 ====================
### 查询所有大模型状态
GET http://localhost:8080/llm-status/list
### 根据ID查询大模型状态
GET http://localhost:8080/llm-status/1
### 根据状态编码查询
GET http://localhost:8080/llm-status/code/available
### 添加大模型状态
POST http://localhost:8080/llm-status
Content-Type: application/json
{
"statusName": "可用",
"statusCode": "available",
"description": "API成功率≥95%"
}
### 更新大模型状态
PUT http://localhost:8080/llm-status
Content-Type: application/json
{
"id": 1,
"statusName": "可用",
"description": "API成功率≥98%"
}
### 删除大模型状态
DELETE http://localhost:8080/llm-status/1
### ==================== 用户数据权限接口 ====================
### 查询所有权限
GET http://localhost:8080/user-db-permission/list
### 查询已分配权限的用户
GET http://localhost:8080/user-db-permission/list/assigned
### 查询未分配权限的用户
GET http://localhost:8080/user-db-permission/list/unassigned
### 根据ID查询权限
GET http://localhost:8080/user-db-permission/1
### 根据用户ID查询权限
GET http://localhost:8080/user-db-permission/user/4
### 添加用户权限
POST http://localhost:8080/user-db-permission
Content-Type: application/json
{
"userId": 4,
"permissionDetails": "[{\"db_connection_id\":1,\"table_ids\":[1,2,3]}]",
"lastGrantUserId": 4,
"isAssigned": 1
}
### 更新用户权限
PUT http://localhost:8080/user-db-permission
Content-Type: application/json
{
"id": 1,
"userId": 4,
"permissionDetails": "[{\"db_connection_id\":1,\"table_ids\":[1,2,3,4,5]}]",
"lastGrantUserId": 4
}
### 删除用户权限
DELETE http://localhost:8080/user-db-permission/1
### ==================== 系统操作日志接口 ====================
### 查询所有操作日志
GET http://localhost:8080/operation-log/list
### 根据用户ID查询操作日志
GET http://localhost:8080/operation-log/list/user/4
### 根据模块查询操作日志
GET http://localhost:8080/operation-log/list/module/数据源管理
### 查询失败的操作日志
GET http://localhost:8080/operation-log/list/failed
### 根据ID查询操作日志
GET http://localhost:8080/operation-log/1
### 添加操作日志
POST http://localhost:8080/operation-log
Content-Type: application/json
{
"userId": 4,
"username": "admin",
"operation": "创建数据库连接",
"module": "数据源管理",
"relatedLlm": null,
"ipAddress": "127.0.0.1",
"result": 1,
"errorMsg": null
}
### 删除操作日志
DELETE http://localhost:8080/operation-log/1
### ==================== 错误日志接口 ====================
### 查询所有错误日志
GET http://localhost:8080/error-log/list
### 根据错误类型查询
GET http://localhost:8080/error-log/list/type/1
### 根据统计周期查询
GET http://localhost:8080/error-log/list/period/today
### 根据ID查询错误日志
GET http://localhost:8080/error-log/1
### 添加错误日志
POST http://localhost:8080/error-log
Content-Type: application/json
{
"errorTypeId": 1,
"errorCount": 5,
"errorRate": 2.5,
"period": "today"
}
### 更新错误日志
PUT http://localhost:8080/error-log
Content-Type: application/json
{
"id": 1,
"errorCount": 10,
"errorRate": 5.0
}
### 删除错误日志
DELETE http://localhost:8080/error-log/1
### ==================== 错误类型字典接口 ====================
### 查询所有错误类型
GET http://localhost:8080/error-type/list
### 根据ID查询错误类型
GET http://localhost:8080/error-type/1
### 根据错误编码查询
GET http://localhost:8080/error-type/code/llm_timeout
### 添加错误类型
POST http://localhost:8080/error-type
Content-Type: application/json
{
"errorName": "模型调用超时",
"errorCode": "llm_timeout",
"description": "大模型API响应时间超过设定阈值"
}
### 更新错误类型
PUT http://localhost:8080/error-type
Content-Type: application/json
{
"id": 1,
"errorName": "模型调用超时",
"description": "大模型API响应时间超过5秒"
}
### 删除错误类型
DELETE http://localhost:8080/error-type/1
### ==================== 通知目标字典接口 ====================
### 查询所有通知目标
GET http://localhost:8080/notification-target/list
### 根据ID查询通知目标
GET http://localhost:8080/notification-target/1
### 根据目标编码查询
GET http://localhost:8080/notification-target/code/all
### 添加通知目标
POST http://localhost:8080/notification-target
Content-Type: application/json
{
"targetName": "所有用户",
"targetCode": "all",
"description": "发送给系统所有用户"
}
### 更新通知目标
PUT http://localhost:8080/notification-target
Content-Type: application/json
{
"id": 1,
"targetName": "所有用户",
"description": "发送给系统所有注册用户"
}
### 删除通知目标
DELETE http://localhost:8080/notification-target/1
### ==================== 优先级字典接口 ====================
### 查询所有优先级(按排序)
GET http://localhost:8080/priority/list
### 根据ID查询优先级
GET http://localhost:8080/priority/1
### 根据优先级编码查询
GET http://localhost:8080/priority/code/urgent
### 添加优先级
POST http://localhost:8080/priority
Content-Type: application/json
{
"priorityName": "紧急",
"priorityCode": "urgent",
"sort": 1
}
### 更新优先级
PUT http://localhost:8080/priority
Content-Type: application/json
{
"id": 1,
"priorityName": "紧急",
"sort": 0
}
### 删除优先级
DELETE http://localhost:8080/priority/1
### ==================== 通知管理接口 ====================
### 查询所有通知
GET http://localhost:8080/notification/list
### 查询已发布的通知
GET http://localhost:8080/notification/list/published
### 查询草稿
GET http://localhost:8080/notification/list/drafts
### 根据目标ID查询通知
GET http://localhost:8080/notification/list/target/1
### 根据ID查询通知
GET http://localhost:8080/notification/1
### 添加通知(草稿)
POST http://localhost:8080/notification
Content-Type: application/json
{
"title": "系统维护通知",
"content": "系统将于今晚22:00-24:00进行维护请提前保存数据",
"targetId": 1,
"priorityId": 1,
"publisherId": 4,
"isTop": 0
}
### 更新通知
PUT http://localhost:8080/notification
Content-Type: application/json
{
"id": 1,
"title": "系统维护通知(已更新)",
"content": "系统将于今晚22:00-次日00:30进行维护请提前保存数据"
}
### 发布通知
PUT http://localhost:8080/notification/1/publish
### 置顶/取消置顶
PUT http://localhost:8080/notification/1/toggle-top
### 删除通知
DELETE http://localhost:8080/notification/1
### ==================== 系统健康监控接口 ====================
### 查询所有健康记录
GET http://localhost:8080/system-health/list
### 查询最新的健康记录
GET http://localhost:8080/system-health/latest
### 查询最近10条健康记录
GET http://localhost:8080/system-health/recent/10
### 根据ID查询健康记录
GET http://localhost:8080/system-health/1
### 添加健康记录
POST http://localhost:8080/system-health
Content-Type: application/json
{
"dbDelay": 10,
"cacheDelay": 2,
"llmDelay": 150,
"storageUsage": 75.5
}
### 删除健康记录
DELETE http://localhost:8080/system-health/1

@ -0,0 +1,123 @@
services:
# MySQL 8.4 服务
mysql:
image: mysql:8.4
container_name: nlq_mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: natural_language_query_system
MYSQL_USER: nlq_user
MYSQL_PASSWORD: nlq_pass123
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
# 挂载初始化脚本MySQL会自动执行/docker-entrypoint-initdb.d/目录下的.sql文件
- ./mysql_schema_from_last.sql:/docker-entrypoint-initdb.d/init.sql
# 数据持久化
- mysql_data:/var/lib/mysql
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --mysql-native-password=ON
networks:
- nlq_network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot123456"]
interval: 10s
timeout: 5s
retries: 5
# MongoDB 8.2 服务
mongodb:
image: mongo:8.2
container_name: nlq_mongodb
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: admin123456
MONGO_INITDB_DATABASE: natural_language_query_system
TZ: Asia/Shanghai
ports:
- "27017:27017"
volumes:
# 挂载初始化脚本
- ./mongodb_schema_from_last.js:/docker-entrypoint-initdb.d/init.js
# 数据持久化
- mongodb_data:/data/db
- mongodb_config:/data/configdb
networks:
- nlq_network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
# 可选Mongo Express (MongoDB 的 Web 管理界面)
mongo-express:
image: mongo-express:latest
container_name: nlq_mongo_express
restart: always
ports:
- "8081:8081"
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: admin
ME_CONFIG_MONGODB_ADMINPASSWORD: admin123456
ME_CONFIG_MONGODB_URL: mongodb://admin:admin123456@mongodb:27017/
ME_CONFIG_BASICAUTH_USERNAME: admin
ME_CONFIG_BASICAUTH_PASSWORD: admin
depends_on:
- mongodb
networks:
- nlq_network
# 可选Adminer (MySQL 的 Web 管理界面)
adminer:
image: adminer:latest
container_name: nlq_adminer
restart: always
ports:
- "8082:8080"
environment:
ADMINER_DEFAULT_SERVER: mysql
depends_on:
- mysql
networks:
- nlq_network
# Redis 缓存服务
redis:
image: redis:7-alpine
container_name: nlq_redis
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- nlq_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# 数据卷(数据持久化)
volumes:
mysql_data:
driver: local
mongodb_data:
driver: local
mongodb_config:
driver: local
redis_data:
driver: local
# 网络配置
networks:
nlq_network:
driver: bridge

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,777 @@
import React, { useState, useEffect } from 'react';
// 页面组件导入
import { LoginPage } from './components/LoginPage';
import { Sidebar } from './components/Sidebar';
import { QueryPage } from './components/QueryPage';
import { HistoryPage } from './components/HistoryPage';
import { NotificationsPage } from './components/NotificationsPage';
import { AccountPage } from './components/AccountPage';
import { FriendsPageWithRealAPI } from './components/FriendsPageWithRealAPI';
import { SysAdminSidebar } from './components/SysAdminSidebar';
import { DataAdminSidebar } from './components/DataAdminSidebar';
import { SysAdminPage } from './components/SysAdminPage';
import { DataAdminPage } from './components/DataAdminPage';
import { TopHeader } from './components/TopHeader';
import { ComparisonModal } from './components/ComparisonModal';
import { Modal } from './components/Modal';
// 模拟数据导入
import {
MOCK_INITIAL_CONVERSATION,
MOCK_SAVED_QUERIES,
MOCK_NOTIFICATIONS,
MOCK_QUERY_SHARES,
MOCK_FRIENDS_LIST
} from './constants';
// API 导入
import { userApi } from './services/api';
// 类型定义导入
import {
Conversation,
MessageRole,
Page,
QueryResultData,
UserRole,
SysAdminPageType,
DataAdminPageType,
QueryShare,
Notification
} from './types';
/**
*
* Sidebar
*/
// 普通用户侧边栏项目 - 基础数据查询与个人中心功能
const normalUserSidebarItems = [
{ href: 'query', label: '数据查询' },
{ href: 'history', label: '收藏夹' },
{ href: 'notifications', label: '通知中心' },
{ href: 'friends', label: '好友管理' },
{ href: 'account', label: '账户管理' },
] as const;
// 数据管理员侧边栏项目 - 包含数据管理与基础功能
const dataAdminSidebarItems = [
{ href: 'query', label: '数据查询' },
{ href: 'history', label: '收藏夹' },
{ href: 'datasource', label: '数据源管理' },
{ href: 'dashboard', label: '数据源概览' },
{ href: 'user-permission', label: '用户权限管理' },
{ href: 'notification-management', label: '通知管理(数据员)' },
{ href: 'connection-log', label: '数据源连接日志' },
{ href: 'notifications', label: '通知中心' },
{ href: 'account', label: '我的账户' },
{ href: 'friends', label: '好友管理' },
] as const;
// 系统管理员侧边栏项目 - 系统配置与管理功能
const sysAdminSidebarItems = [
{ href: 'dashboard', label: '系统概览' },
{ href: 'user-management', label: '用户管理' },
{ href: 'account', label: '我的账户' },
{ href: 'system-log', label: '系统日志' },
{ href: 'llm-config', label: '大模型配置' },
{ href: 'notification-management', label: '通知管理' },
] as const;
/**
* -
*
*/
const App: React.FC = () => {
// ===== 全局核心状态 =====
// 当前用户角色null表示未登录
const [userRole, setUserRole] = useState<UserRole | null>(null);
// 当前用户信息
const [currentUser, setCurrentUser] = useState({ name: '', avatarUrl: '' });
// 所有对话列表
const [conversations, setConversations] = useState<Conversation[]>([MOCK_INITIAL_CONVERSATION]);
// 当前激活的对话ID
const [currentConversationId, setCurrentConversationId] = useState<string>(MOCK_INITIAL_CONVERSATION.id);
// 历史对话面板显示状态仅query页面生效
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
// 普通用户当前激活页面
const [activePage, setActivePage] = useState<Page>('query');
// 已保存的查询结果列表
const [savedQueries, setSavedQueries] = useState<QueryResultData[]>(MOCK_SAVED_QUERIES);
// 新对话初始提示语(用于重新运行查询场景)
const [initialPrompt, setInitialPrompt] = useState<string | undefined>(undefined);
// 控制弹窗显示/隐藏
const [isCompareModalOpen, setIsCompareModalOpen] = useState(false);
// 存储要对比的查询
const [compareQueries, setCompareQueries] = useState<{ oldQuery: QueryResultData; newQuery: QueryResultData } | null>(null);
// 待对比的查询ID组合旧查询ID, 新查询ID
const [comparisonQueryIds, setComparisonQueryIds] = useState<[string, string] | null>(null);
// 对话不存在提示弹窗显示状态
const [notFoundModalOpen, setNotFoundModalOpen] = useState(false);
// 查询分享记录列表
const [queryShares, setQueryShares] = useState<QueryShare[]>(MOCK_QUERY_SHARES);
// 通知消息列表
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
// ===== 管理员专属状态 =====
// 系统管理员当前激活页面
const [activeSysAdminPage, setActiveSysAdminPage] = useState<SysAdminPageType>('dashboard');
// 数据管理员当前激活页面
const [activeDataAdminPage, setActiveDataAdminPage] = useState<DataAdminPageType>('dashboard');
// 当前激活的对话详情通过currentConversationId从列表中匹配
const currentConversation = conversations.find(c => c.id === currentConversationId);
// ===== 辅助函数 =====
/**
*
*
* @returns
*/
const getSidebarPageName = () => {
if (userRole === 'normal-user') {
// 普通用户query页面不显示侧边栏名称
if (activePage === 'query') return '';
return normalUserSidebarItems.find(item => item.href === activePage)?.label || '';
}
if (userRole === 'data-admin') {
// 数据管理员query页面不显示侧边栏名称
if (activeDataAdminPage === 'query') return '';
return dataAdminSidebarItems.find(item => item.href === activeDataAdminPage)?.label || '';
}
if (userRole === 'sys-admin') {
return sysAdminSidebarItems.find(item => item.href === activeSysAdminPage)?.label || '';
}
return '';
};
/**
*
* query
* @returns
*/
const getHeaderTitle = () => {
// 判定是否为query页面跨所有角色
const isQueryPage = (
(userRole === 'normal-user' && activePage === 'query') ||
(userRole === 'data-admin' && activeDataAdminPage === 'query') ||
(userRole === 'sys-admin' && activeSysAdminPage === 'query')
);
// query页面优先显示对话标题无则显示默认文本
if (isQueryPage) {
return currentConversation?.title || '新对话';
}
// 非query页面显示侧边栏菜单名称
return getSidebarPageName();
};
// 顶部导航栏最终显示标题
const headerTitle = getHeaderTitle();
// ===== 事件处理函数 =====
/**
*
* 使sessionStorage
* @param role -
*/
const handleLogin = async (role: UserRole) => {
const savedRole = sessionStorage.getItem('userRole') as UserRole;
const roleToSet = role || savedRole;
if (roleToSet) {
setUserRole(roleToSet);
// 加载用户信息
await loadUserInfo();
}
};
/**
*
*/
const loadUserInfo = async () => {
try {
const userId = Number(sessionStorage.getItem('userId') || '1');
const userData = await userApi.getById(userId);
setCurrentUser({
name: userData.username,
avatarUrl: userData.avatarUrl || '/default-avatar.png',
});
} catch (error) {
console.error('加载用户信息失败:', error);
// 使用默认值
setCurrentUser({ name: '用户', avatarUrl: '/default-avatar.png' });
}
};
/**
*
* sessionStorage
*/
useEffect(() => {
handleLogin(userRole);
// 监听头像更新事件
const handleAvatarUpdate = (event: CustomEvent) => {
setCurrentUser(prev => ({
...prev,
avatarUrl: event.detail.avatarUrl
}));
};
window.addEventListener('userAvatarUpdated', handleAvatarUpdate as EventListener);
return () => {
window.removeEventListener('userAvatarUpdated', handleAvatarUpdate as EventListener);
};
}, []);
/**
* 退
* sessionStorage
*/
const handleLogout = () => {
sessionStorage.removeItem('userRole');
setUserRole(null);
setActivePage('query');
setActiveSysAdminPage('dashboard');
setActiveDataAdminPage('dashboard');
};
/**
*
*
* @param role - user/ai
* @param content -
*/
const handleAddMessage = (role: MessageRole, content: string | QueryResultData) => {
// 优先使用传入的目标对话ID无则使用当前激活对话ID
const convId = currentConversationId;
if (!convId) return;
setConversations(prev => prev.map(conv => {
// 只更新当前激活的对话
if (conv.id === currentConversationId) {
const newMessages = [...conv.messages, { role, content }];
let newTitle = conv.title;
// 首次用户消息对话初始状态截取前20字作为对话标题
if (conv.messages.length === 1 && role === 'user' && typeof content === 'string') {
newTitle = content.substring(0, 20);
}
return { ...conv, title: newTitle, messages: newMessages };
}
return conv;
}));
};
/**
*
* query
*/
const handleNewConversation = () => {
// 判断对话是否为空仅包含AI初始问候语
const isEmptyConv = (conv: Conversation) => {
return conv.messages.length === 1 &&
conv.messages[0].role === 'ai' &&
typeof conv.messages[0].content === 'string' &&
conv.messages[0].content.includes('您好!我是数据查询助手');
};
// 查找现有空对话
const existingEmptyConv = conversations.find(isEmptyConv);
if (existingEmptyConv) {
// 复用空对话
setCurrentConversationId(existingEmptyConv.id);
} else {
// 创建新对话
const newConv: Conversation = {
id: 'conv-' + Date.now(), // 时间戳生成唯一ID
title: '新对话',
messages: [{
role: 'ai',
content: '您好!我是数据查询助手,您可以通过自然语言描述您的查询需求(例如:"展示2023年各季度的订单量"),我会为您生成相应的结果。'
}],
createTime: new Date().toISOString(),
};
// 添加到对话列表头部
setConversations(prev => [newConv, ...prev]);
setCurrentConversationId(newConv.id);
}
// 关闭历史对话面板
setIsHistoryOpen(false);
// 切换到query页面区分角色
if (userRole === 'data-admin') {
setActiveDataAdminPage('query');
} else {
setActivePage('query');
}
};
/**
*
* query
* @param id - ID
*/
const handleSwitchConversation = (id: string) => {
setCurrentConversationId(id);
setIsHistoryOpen(true);
setActivePage('query');
};
/**
*
*
* @param deleteId - ID
*/
const handleDeleteConversation = (deleteId: string) => {
const updatedConversations = conversations.filter(conv => conv.id !== deleteId);
// 若删除的是当前激活的对话
if (currentConversationId === deleteId) {
if (updatedConversations.length > 0) {
// 切换到剩余对话的首个
setCurrentConversationId(updatedConversations[0].id);
} else {
// 无剩余对话时创建新对话
const newConv: Conversation = {
id: 'conv-' + Date.now(),
title: '新对话',
messages: [{
role: 'ai',
content: '您好!我是数据查询助手,您可以通过自然语言描述您的查询需求...'
}],
createTime: new Date().toISOString(),
};
updatedConversations.unshift(newConv);
setCurrentConversationId(newConv.id);
}
}
setConversations(updatedConversations);
};
/**
*
*
* @param query -
*/
const handleSaveQuery = (query: QueryResultData) => {
setSavedQueries(prev => {
if (prev.some(q => q.id === query.id)) return prev;
return [query, ...prev];
});
};
/**
*
*
* @param id - ID
*/
const handleDeleteSavedQuery = (id: string) => {
setSavedQueries(prev => prev.filter(q => q.id !== id));
};
/**
*
*
* @param conversationId - ID
*/
const handleViewInChat = (conversationId: string) => {
const convExists = conversations.some(c => c.id === conversationId);
if (convExists) {
setCurrentConversationId(conversationId);
// 切换到query页面区分角色
if (userRole === 'data-admin') {
setActiveDataAdminPage('query');
} else {
setActivePage('query');
}
} else {
setNotFoundModalOpen(true);
}
};
/**
*
*
* @param prompt -
*/
const handleRerunQuery = (prompt: string) => {
handleNewConversation();
setInitialPrompt(prompt);
};
/**
*
*
* @param queryId1 - ID
* @param queryId2 - ID
*/
const handleCompareQueries = (queryId1: string, queryId2: string) => {
const query1 = savedQueries.find(q => q.id === queryId1);
const query2 = savedQueries.find(q => q.id === queryId2);
if (!query1 || !query2) return;
// 按查询时间排序,确保旧查询在前
const date1 = new Date(query1.queryTime);
const date2 = new Date(query2.queryTime);
const oldQuery = date1 < date2 ? query1 : query2;
const newQuery = date1 < date2 ? query2 : query1;
//设置弹窗数据并打开弹窗
setCompareQueries({ oldQuery, newQuery });
setIsCompareModalOpen(true);
};
/**
*
*
* @param queryId - ID
* @param friendId - ID
*/
const handleShareQuery = (queryId: string, friendId: string) => {
const queryToShare = savedQueries.find(q => q.id === queryId);
const friend = MOCK_FRIENDS_LIST.find(f => f.id === friendId);
if (queryToShare && friend) {
// 创建分享记录
const newShare: QueryShare = {
id: `share-${Date.now()}`,
sender: {
id: sessionStorage.getItem('userId') || '1',
name: currentUser.name,
email: '',
avatarUrl: currentUser.avatarUrl,
isOnline: true,
remark: ''
}, // 发送者为当前用户
recipientId: friend.id, // 接收者为选中的好友
querySnapshot: queryToShare, // 分享的查询快照
timestamp: new Date().toISOString(),
status: 'unread', // 初始状态为未读
};
setQueryShares(prev => [newShare, ...prev]);
// 生成分享通知(模拟)
const newNotification: Notification = {
id: `notif-${Date.now()}`,
type: 'share',
title: `${currentUser.name} 分享了一个查询给你`,
content: `"${queryToShare.userPrompt}"`,
timestamp: new Date().toISOString(),
isRead: false,
isPinned: false,
fromUser: { name: currentUser.name, avatarUrl: currentUser.avatarUrl },
relatedShareId: newShare.id,
};
setNotifications(prev => [newNotification, ...prev]);
}
};
/**
*
*
* @param shareId - ID
*/
const handleMarkShareAsRead = (shareId: string) => {
setQueryShares(prev => prev.map(s => s.id === shareId ? { ...s, status: 'read' } : s));
};
/**
*
*
* @param shareId - ID
*/
const handleDeleteShare = (shareId: string) => {
setQueryShares(prev => prev.filter(s => s.id !== shareId));
};
/**
*
*
*/
const handleNotificationClick = () => {
if (userRole === 'normal-user') {
setActivePage('notifications');
} else if (userRole === 'data-admin') {
setActiveDataAdminPage('notifications');
}
};
/**
*
*
*/
const handleAvatarClick = () => {
if (userRole === 'normal-user') {
setActivePage('account');
} else if (userRole === 'data-admin') {
setActiveDataAdminPage('account');
} else if (userRole === 'sys-admin') {
setActiveSysAdminPage('account');
}
};
// ===== 页面渲染函数 =====
/**
*
*
* @param page -
* @returns
*/
const renderNormalUserPage = (page: Page) => {
switch (page) {
case 'query':
return (
<QueryPage
currentConversation={currentConversation}
onToggleHistory={() => setIsHistoryOpen(!isHistoryOpen)}
isHistoryOpen={isHistoryOpen}
onAddMessage={handleAddMessage}
onSaveQuery={handleSaveQuery}
onShareQuery={handleShareQuery}
savedQueries={savedQueries}
initialPrompt={initialPrompt}
onClearInitialPrompt={() => setInitialPrompt(undefined)}
conversations={conversations}
currentConversationId={currentConversationId || ''}
onSwitchConversation={handleSwitchConversation}
onNewConversation={handleNewConversation}
onDeleteConversation={handleDeleteConversation}
/>
);
case 'history':
return (
<HistoryPage
savedQueries={savedQueries}
conversations={conversations}
onDelete={handleDeleteSavedQuery}
onViewInChat={handleViewInChat}
onRerun={handleRerunQuery}
onCompare={handleCompareQueries}
/>
);
case 'notifications':
return <NotificationsPage />;
case 'account':
return <AccountPage />;
case 'friends':
return (
<FriendsPageWithRealAPI
savedQueries={savedQueries}
shares={queryShares}
onMarkShareAsRead={handleMarkShareAsRead}
onDeleteShare={handleDeleteShare}
onRerunQuery={handleRerunQuery}
onSaveQuery={handleSaveQuery}
/>
);
default:
return <div>Page not found</div>;
}
};
// ===== 角色路由渲染 =====
// 未登录状态 - 渲染登录页面
if (!userRole) {
return <LoginPage onLogin={handleLogin} />;
}
// 系统管理员 - 渲染系统管理页面
if (userRole === 'sys-admin') {
return (
<div className="flex h-screen bg-neutral">
<SysAdminSidebar
activePage={activeSysAdminPage}
setActivePage={setActiveSysAdminPage}
onLogout={handleLogout}
/>
<main className="flex-1 flex flex-col overflow-hidden">
<TopHeader
user={{
name: currentUser.name,
role: userRole,
avatarUrl: currentUser.avatarUrl,
}}
notificationCount={notifications.filter(n => !n.isRead).length}
notifications={notifications}
onNotificationClick={handleNotificationClick}
onAvatarClick={handleAvatarClick}
currentConversationName={headerTitle}
/>
<SysAdminPage
activePage={activeSysAdminPage}
setActivePage={setActiveSysAdminPage}
/>
</main>
</div>
);
}
// 数据管理员 - 渲染数据管理页面
if (userRole === 'data-admin') {
return (
<>
<div className="flex h-screen bg-neutral">
<DataAdminSidebar
activePage={activeDataAdminPage}
setActivePage={setActiveDataAdminPage}
onLogout={handleLogout}
/>
<main className="flex-1 flex flex-col overflow-hidden">
<TopHeader
user={{
name: currentUser.name,
role: userRole,
avatarUrl: currentUser.avatarUrl,
}}
notificationCount={notifications.filter(n => !n.isRead).length}
notifications={notifications}
onNotificationClick={handleNotificationClick}
showHistoryToggle={activeDataAdminPage === 'query'} // 仅query页面显示历史面板切换按钮
onToggleHistory={() => setIsHistoryOpen(!isHistoryOpen)}
onAvatarClick={handleAvatarClick}
onNewConversation={handleNewConversation}
currentConversationName={headerTitle}
/>
<DataAdminPage
activePage={activeDataAdminPage}
setActivePage={setActiveDataAdminPage}
currentConversation={currentConversation}
onToggleHistory={() => setIsHistoryOpen(!isHistoryOpen)}
isHistoryOpen={isHistoryOpen}
onAddMessage={handleAddMessage}
onSaveQuery={handleSaveQuery}
onShareQuery={handleShareQuery}
savedQueries={savedQueries}
initialPrompt={initialPrompt}
onClearInitialPrompt={() => setInitialPrompt(undefined)}
conversations={conversations}
currentConversationId={currentConversationId || ''}
onSwitchConversation={handleSwitchConversation}
onNewConversation={handleNewConversation}
onDelete={handleDeleteSavedQuery}
onViewInChat={handleViewInChat}
onRerun={handleRerunQuery}
onCompare={handleCompareQueries}
shares={queryShares}
onMarkShareAsRead={handleMarkShareAsRead}
onDeleteShare={handleDeleteShare}
/>
</main>
</div>
{/* 对话不存在提示弹窗 */}
<Modal
isOpen={notFoundModalOpen}
onClose={() => setNotFoundModalOpen(false)}
title="操作提示"
>
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100 mb-4">
<i className="fa fa-exclamation-triangle text-yellow-600 text-2xl"></i>
</div>
<p className="text-gray-700"></p>
<p className="text-sm text-gray-500 mt-2"></p>
<div className="mt-6">
<button
onClick={() => setNotFoundModalOpen(false)}
className="px-6 py-2 bg-primary text-white rounded-lg text-sm"
>
</button>
</div>
</div>
</Modal>
{/* 2. 对比弹窗(全局浮层,独立于其他弹窗!) */}
{compareQueries && (
<ComparisonModal
isOpen={isCompareModalOpen}
onClose={() => {
setIsCompareModalOpen(false);
setCompareQueries(null);
}}
oldQuery={compareQueries.oldQuery}
newQuery={compareQueries.newQuery}
/>
)}
</>
);
}
// 普通用户 - 渲染基础功能页面
return (
<>
<div className="flex h-screen bg-neutral">
<Sidebar
activePage={activePage}
setActivePage={setActivePage}
onLogout={handleLogout}
/>
<main className="flex-1 flex flex-col overflow-hidden">
<TopHeader
user={{
name: currentUser.name,
role: userRole,
avatarUrl: currentUser.avatarUrl,
}}
notificationCount={notifications.filter(n => !n.isRead).length}
notifications={notifications}
onNotificationClick={handleNotificationClick}
showHistoryToggle={activePage === 'query'} // 仅query页面显示历史面板切换按钮
onToggleHistory={() => setIsHistoryOpen(!isHistoryOpen)}
onAvatarClick={handleAvatarClick}
onNewConversation={handleNewConversation}
currentConversationName={headerTitle}
/>
{renderNormalUserPage(activePage)}
</main>
</div>
{/* 对话不存在提示弹窗 */}
<Modal
isOpen={notFoundModalOpen}
onClose={() => setNotFoundModalOpen(false)}
title="操作提示"
>
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100 mb-4">
<i className="fa fa-exclamation-triangle text-yellow-600 text-2xl"></i>
</div>
<p className="text-gray-700"></p>
<p className="text-sm text-gray-500 mt-2"></p>
<div className="mt-6">
<button
onClick={() => setNotFoundModalOpen(false)}
className="px-6 py-2 bg-primary text-white rounded-lg text-sm"
>
</button>
</div>
</div>
</Modal>
{/* 2. 对比弹窗(全局浮层,独立于其他弹窗!) */}
{compareQueries && (
<ComparisonModal
isOpen={isCompareModalOpen}
onClose={() => {
setIsCompareModalOpen(false);
setCompareQueries(null);
}}
oldQuery={compareQueries.oldQuery}
newQuery={compareQueries.newQuery}
/>
)}
</>
);
};
export default App;

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1l5dsPVkxm5nl4yN-ckKSgRGs0nlklvz9
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

@ -0,0 +1,351 @@
import React, { useState, useRef, useEffect } from 'react';
import { UserProfile } from '../types';
import { Modal } from './Modal';
import { userApi, ChangePasswordRequest } from '../services/api';
const InfoField: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div>
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className="mt-1 text-sm text-gray-900">{value}</dd>
</div>
);
const maskPhoneNumber = (phone: string) => {
if (phone.length === 11) {
return `${phone.substring(0, 3)}****${phone.substring(7)}`;
}
return phone;
};
export const AccountPage: React.FC = () => {
const [user, setUser] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [isEditModalOpen, setEditModalOpen] = useState(false);
const [isPasswordModalOpen, setPasswordModalOpen] = useState(false);
const [isPhoneModalOpen, setPhoneModalOpen] = useState(false);
const [editForm, setEditForm] = useState({ name: '', email: '', phoneNumber: '' });
const [passwordForm, setPasswordForm] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
const [avatarPreview, setAvatarPreview] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadUserProfile();
// sessionStorage是每个标签页独立的不需要监听storage事件
}, []);
const loadUserProfile = async () => {
try {
setLoading(true);
// 每次加载时都从localStorage获取最新的userId
const userId = Number(sessionStorage.getItem('userId') || '1');
const userData = await userApi.getById(userId);
const profile: UserProfile = {
id: String(userData.id),
userId: String(userData.id),
name: userData.username,
email: userData.email,
phoneNumber: userData.phonenumber,
avatarUrl: userData.avatarUrl || '/default-avatar.png',
registrationDate: (userData as any).createTime?.split('T')[0] || '',
accountStatus: userData.status === 1 ? 'normal' : 'disabled',
preferences: {
defaultModel: '',
defaultDatabase: '',
},
};
setUser(profile);
setAvatarPreview(profile.avatarUrl);
} catch (error) {
console.error('加载用户信息失败:', error);
} finally {
setLoading(false);
}
};
const handleOpenEditModal = () => {
if (user) {
setEditForm({
name: user.name,
email: user.email,
phoneNumber: user.phoneNumber,
});
setEditModalOpen(true);
}
};
const handleSaveProfile = async () => {
try {
const userId = Number(sessionStorage.getItem('userId') || '1');
await userApi.update({
id: userId,
username: editForm.name,
email: editForm.email,
phonenumber: editForm.phoneNumber,
avatarUrl: avatarPreview,
});
await loadUserProfile();
setEditModalOpen(false);
alert('个人信息更新成功!');
} catch (error) {
console.error('更新个人信息失败:', error);
alert('更新失败,请重试');
}
};
const handleAvatarChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = async () => {
const base64Avatar = reader.result as string;
setAvatarPreview(base64Avatar);
// 立即保存到后端
try {
const userId = Number(sessionStorage.getItem('userId') || '1');
await userApi.update({
id: userId,
avatarUrl: base64Avatar,
});
await loadUserProfile();
} catch (error) {
console.error('更新头像失败:', error);
alert('更新头像失败');
}
};
reader.readAsDataURL(file);
}
};
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert('两次输入的新密码不一致!');
return;
}
if (passwordForm.newPassword.length < 6) {
alert('新密码长度不能少于6位');
return;
}
if (!passwordForm.oldPassword) {
alert('请输入当前密码!');
return;
}
try {
const userId = Number(sessionStorage.getItem('userId') || '1');
const changePasswordRequest: ChangePasswordRequest = {
userId: userId,
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword,
};
await userApi.changePassword(changePasswordRequest);
alert('密码修改成功!');
setPasswordModalOpen(false);
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' });
} catch (error) {
console.error('修改密码失败:', error);
alert(error instanceof Error ? error.message : '修改失败,请检查当前密码是否正确');
}
};
if (loading) {
return (
<section className="p-6 overflow-y-auto bg-neutral flex justify-center items-center h-full">
<div className="text-gray-500">...</div>
</section>
);
}
if (!user) {
return (
<section className="p-6 overflow-y-auto bg-neutral flex justify-center items-center h-full">
<div className="text-gray-500"></div>
</section>
);
}
return (
<section className="p-6 overflow-y-auto bg-neutral">
<div className="flex justify-between items-center mb-6 max-w-4xl mx-auto">
</div>
<div className="max-w-4xl mx-auto space-y-6">
{/* Personal Info Card */}
<div className="bg-white p-8 rounded-xl shadow-sm border">
<div className="flex flex-col md:flex-row items-start gap-8">
{/* Left side: Avatar */}
<div className="flex flex-col items-center flex-shrink-0 w-full md:w-48">
<img src={avatarPreview || user.avatarUrl} alt="User Avatar" className="w-40 h-40 rounded-full object-cover mb-4 ring-4 ring-white shadow-lg" />
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarChange}
accept="image/*"
className="hidden"
/>
<button onClick={() => fileInputRef.current?.click()} className="px-4 py-2 border rounded-lg text-sm w-full hover:bg-gray-50 transition-colors">
<i className="fa fa-upload mr-2"></i>
</button>
</div>
{/* Right side: Info */}
<div className="flex-1 w-full">
<div className="border-b pb-4 mb-6">
<h3 className="text-xl font-bold text-gray-900"></h3>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-6">
<InfoField label="用户名" value={user.name} />
<InfoField label="用户ID" value={user.userId} />
<InfoField label="邮箱" value={user.email} />
<InfoField label="手机号码" value={maskPhoneNumber(user.phoneNumber)} />
<InfoField label="注册时间" value={user.registrationDate} />
<InfoField
label="账户状态"
value={
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
user.accountStatus === 'normal'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{user.accountStatus === 'normal' ? '正常' : '禁用'}
</span>
}
/>
</dl>
<div className="mt-8 flex justify-end">
<button onClick={handleOpenEditModal} className="px-6 py-2.5 bg-primary text-white rounded-lg font-semibold hover:shadow-lg transition-all duration-200">
<i className="fa fa-pencil mr-2"></i>
</button>
</div>
</div>
</div>
</div>
{/* Security Settings Card */}
<div className="bg-white p-8 rounded-xl shadow-sm border">
<h3 className="text-xl font-bold text-gray-900 border-b pb-4 mb-6"></h3>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<p className="font-medium text-gray-800"></p>
<p className="text-sm text-gray-500 mt-1">2025-05-10</p>
</div>
<button onClick={() => setPasswordModalOpen(true)} className="px-4 py-2 border rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
</button>
</div>
<div className="flex justify-between items-center">
<div>
<p className="font-medium text-gray-800"></p>
<p className="text-sm text-gray-500 mt-1">{maskPhoneNumber(user.phoneNumber)}</p>
</div>
<button onClick={() => setPhoneModalOpen(true)} className="px-4 py-2 border rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
</button>
</div>
</div>
</div>
</div>
{/* Edit Profile Modal */}
<Modal isOpen={isEditModalOpen} onClose={() => setEditModalOpen(false)} title="编辑个人信息">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm(prev => ({...prev, name: e.target.value}))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary/30 focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm(prev => ({...prev, email: e.target.value}))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary/30 focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="tel"
value={editForm.phoneNumber}
onChange={(e) => setEditForm(prev => ({...prev, phoneNumber: e.target.value}))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary/30 focus:border-primary"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setEditModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-100 transition-colors"></button>
<button onClick={handleSaveProfile} className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-shadow"></button>
</div>
</Modal>
{/* Password Change Modal */}
<Modal isOpen={isPasswordModalOpen} onClose={() => setPasswordModalOpen(false)} title="修改密码">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
placeholder="请输入当前密码"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm(prev => ({...prev, oldPassword: e.target.value}))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
placeholder="请输入新密码"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm(prev => ({...prev, newPassword: e.target.value}))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
placeholder="请再次输入新密码"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm(prev => ({...prev, confirmPassword: e.target.value}))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => {
setPasswordModalOpen(false);
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' });
}} className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-100 transition-colors"></button>
<button onClick={handleChangePassword} className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-shadow"></button>
</div>
</Modal>
{/* Phone Change Modal */}
<Modal isOpen={isPhoneModalOpen} onClose={() => setPhoneModalOpen(false)} title="更换绑定手机">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input type="tel" placeholder="请输入新手机号" className="w-full px-4 py-2 border border-gray-300 rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="flex space-x-2">
<input type="text" placeholder="请输入验证码" className="w-full px-4 py-2 border border-gray-300 rounded-lg" />
<button className="px-4 py-2 border rounded-lg text-sm flex-shrink-0 hover:bg-gray-50"></button>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button onClick={() => setPhoneModalOpen(false)} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,40 @@
import React from 'react';
import { Message, QueryResultData } from '../types';
import { QueryResult } from './QueryResult';
interface ChatMessageProps {
message: Message;
onSaveQuery: (query: QueryResultData) => void;
onShareQuery: (queryId: string, friendId: string) => void;
savedQueries: QueryResultData[];
}
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, onSaveQuery, onShareQuery, savedQueries }) => {
const { role, content } = message;
const isUser = role === 'user';
const wrapperClass = `flex items-start gap-3 ${isUser ? 'flex-row-reverse' : ''}`;
const avatarClass = `w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg ${isUser ? 'bg-primary text-white' : 'bg-primary/10 text-primary'}`;
const iconClass = `fa ${isUser ? 'fa-user' : 'fa-robot'}`;
const bubbleClass = `p-4 shadow-sm max-w-[85%] md:max-w-[80%] text-sm rounded-lg ${isUser ? 'bg-primary text-white rounded-tr-none' : 'bg-white border border-gray-200 rounded-tl-none'}`;
return (
<div className={wrapperClass}>
<div className={avatarClass}>
<i className={iconClass}></i>
</div>
<div className={bubbleClass}>
{typeof content === 'string' ? (
<p>{content}</p>
) : (
<QueryResult
result={content}
onSaveQuery={onSaveQuery}
onShareQuery={onShareQuery}
savedQueries={savedQueries}
/>
)}
</div>
</div>
);
};

@ -0,0 +1,384 @@
import React, { useState, useRef, useEffect } from 'react';
import { Modal } from './Modal';
import { Friend, QueryResultData } from '../types';
// 聊天消息接口定义:描述单条聊天消息的结构规范
interface Message {
id: string; // 消息唯一标识(时间戳+随机字符串生成)
content: string; // 消息文本内容
isSent: boolean; // 发送者标识true当前用户发送false好友发送
timestamp: Date; // 消息发送时间
isRead: boolean; // 消息已读状态true已读false未读
}
// 聊天模态框属性接口:定义组件接收的外部参数
interface ChatModalProps {
isOpen: boolean; // 控制聊天弹窗显示/隐藏
onClose: () => void; // 弹窗关闭回调函数
friend: Friend; // 聊天对象(好友)的基础信息
savedQueries: QueryResultData[]; // 可分享的查询记录列表(来自收藏夹)
currentUnreadCount: number; // 当前好友的未读消息数
updateUnreadCount: (friendId: string, count: number) => void; // 更新未读消息数的回调
messages: Message[]; // 聊天记录列表(从父组件传入,双向绑定)
updateMessages: (newMessages: Message[]) => void; // 更新聊天记录的回调(通知父组件同步)
}
export const ChatModal: React.FC<ChatModalProps> = ({
isOpen, onClose, friend, savedQueries,
currentUnreadCount, updateUnreadCount,
messages,
updateMessages
}) => {
// 消息输入框内容状态:绑定输入框的值
const [inputText, setInputText] = useState<string>('');
// 消息展示容器的DOM引用核心用于滚动控制定位到消息底部
const messagesContainerRef = useRef<HTMLDivElement>(null);
// 输入框的DOM引用用于手动控制输入框聚焦
const inputRef = useRef<HTMLInputElement>(null);
// 分享查询弹窗的显示状态false=关闭true=打开
const [isShareModalOpen, setShareModalOpen] = useState(false);
// 选中待分享的查询记录IDnull=未选中,存储选中查询的唯一标识
const [selectedQueryId, setSelectedQueryId] = useState<string | null>(null);
// 模拟好友回复的随机文本池:避免回复内容重复,提升交互自然度
const randomReplies = [
'收到,我看看~',
'好的,我研究一下',
'这个问题我得仔细瞧瞧',
'明白,我马上处理',
'哦,这个我知道怎么弄',
'稍等,我查一下资料',
];
// ===== 终极修复:每次打开弹窗都滚到底(不管关闭多少次)=====
useEffect(() => {
// 只在弹窗打开时执行isOpen为true
if (isOpen) {
// 延迟200ms给Modal动画+DOM重新渲染留足时间关闭再打开时DOM要重新生成
const scrollTimer = setTimeout(() => {
// 用requestAnimationFrame确保DOM完全更新后再滚动解决scrollHeight计算不准确的问题
requestAnimationFrame(() => {
if (messagesContainerRef.current) {
const container = messagesContainerRef.current;
// 强制滚到底部和HTML的window.scrollTo逻辑完全一致
container.scrollTop = container.scrollHeight;
// 双重保险如果第一次没滚到距离底部超过10px再补一次滚动
if (Math.abs(container.scrollHeight - (container.scrollTop + container.clientHeight)) > 10) {
container.scrollTop = container.scrollHeight;
}
}
});
}, 200);
// 弹窗关闭时清除定时器:避免定时器残留导致无效滚动或内存泄漏
return () => clearTimeout(scrollTimer);
}
}, [isOpen]); // 只依赖isOpen状态确保每次打开弹窗都触发滚动不受其他状态影响
// ===== 其他初始化逻辑(不变)=====
useEffect(() => {
if (isOpen) {
// 1. 打开弹窗时,清空当前好友的未读消息数(通知父组件更新)
updateUnreadCount(friend.id, 0);
// 2. 标记好友发送的未读消息为"已读"(遍历消息列表,修改未读状态后同步给父组件)
const updatedMessages = messages.map(msg =>
!msg.isSent && !msg.isRead ? { ...msg, isRead: true } : msg
);
updateMessages(updatedMessages);
// 延迟200ms聚焦输入框和滚动延迟同步确保DOM渲染完成后再聚焦
setTimeout(() => inputRef.current?.focus(), 200);
} else {
// 关闭弹窗时,清空输入框内容(避免下次打开残留上次输入)
setInputText('');
}
}, [isOpen, friend.id, updateUnreadCount, messages, updateMessages]);
// ===== 发送消息(不变,保留滚动)=====
const handleSendMessage = () => {
// 1. 获取输入框内容(去首尾空格,避免发送空消息)
const currentValue = inputRef.current?.value.trim() || '';
if (!currentValue) return; // 内容为空则不执行发送逻辑
// 2. 生成当前用户发送的新消息唯一ID由时间戳+随机字符串组成,确保不重复)
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const newMessage: Message = {
id: uniqueId,
content: currentValue,
isSent: true,
timestamp: new Date(),
isRead: false,
};
// 3. 更新消息列表,拼接新消息后通知父组件同步
const updatedMessages = [...messages, newMessage];
updateMessages(updatedMessages);
// 4. 清空输入框同时更新状态和DOM适配中文输入法等特殊场景
setInputText('');
if (inputRef.current) inputRef.current.value = '';
// 发送后自动滚到底部:确保用户能看到自己发送的最新消息
requestAnimationFrame(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
});
// 模拟好友1秒后回复消息延迟1000ms模拟实时聊天交互
setTimeout(() => {
// 1. 先标记用户发送的消息为"已读"(模拟好友已查看)
const messagesWithRead = updatedMessages.map(msg =>
msg.isSent && !msg.isRead ? { ...msg, isRead: true } : msg
);
// 2. 生成好友回复消息随机选择回复文本生成唯一ID
const replyId = `${Date.now()}-reply-${Math.random().toString(36).slice(2, 8)}`;
const randomReply = randomReplies[Math.floor(Math.random() * randomReplies.length)];
const replyMessage: Message = {
id: replyId,
content: randomReply,
isSent: false,
timestamp: new Date(),
isRead: false,
};
// 3. 一次更新:同时包含"已读标记"和"好友回复",同步给父组件
const finalMessages = [...messagesWithRead, replyMessage];
updateMessages(finalMessages);
// 回复后自动滚到底部:确保用户能看到好友的最新回复
requestAnimationFrame(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
});
}, 1000);
};
// ===== 事件:打开"分享查询"弹窗 =====
const handleOpenShareModal = () => {
setSelectedQueryId(null); // 重置选中状态避免残留上次选中的查询ID
setShareModalOpen(true); // 显示分享查询弹窗
};
// ===== 事件:确认分享查询给好友 =====
const handleConfirmShare = () => {
// 1. 校验:未选择查询记录时,不执行分享逻辑
if (!selectedQueryId) return;
// 根据选中的ID找到对应的查询记录
const selectedQuery = savedQueries.find(q => q.id === selectedQueryId);
if (!selectedQuery) return;
// 2. 生成"分享查询"的消息(作为一条聊天消息发送)
const shareId = `${Date.now()}-share-${Math.random().toString(36).slice(2, 8)}`;
const shareMessage: Message = {
id: shareId,
content: `分享了查询:${selectedQuery.userPrompt}`,
isSent: true,
timestamp: new Date(),
isRead: false,
};
// 3. 更新消息列表,添加分享消息后通知父组件同步
const updatedMessages = [...messages, shareMessage];
updateMessages(updatedMessages);
// 4. 关闭分享弹窗,清空输入框
setInputText('');
setShareModalOpen(false);
// 分享后自动滚到底部:确保分享消息显示在最新位置
requestAnimationFrame(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
});
// 模拟好友1.5秒后回复分享内容延迟1500ms适配分享场景的交互节奏
setTimeout(() => {
// 1. 先标记分享消息为"已读"(模拟好友已查看分享)
const messagesWithRead = updatedMessages.map(msg =>
msg.isSent && !msg.isRead ? { ...msg, isRead: true } : msg
);
// 2. 生成好友回复消息(替换关键词,让回复更贴合分享场景)
const replyShareId = `${Date.now()}-share-reply-${Math.random().toString(36).slice(2, 8)}`;
const randomShareReply = randomReplies[Math.floor(Math.random() * randomReplies.length)].replace('看看', '研究');
const replyMessage: Message = {
id: replyShareId,
content: randomShareReply || '我看到了,这个查询很有用!',
isSent: false,
timestamp: new Date(),
isRead: false,
};
// 3. 一次更新:同时包含"已读标记"和"好友回复",同步给父组件
const finalMessages = [...messagesWithRead, replyMessage];
updateMessages(finalMessages);
// 回复后自动滚到底部:确保好友的回复显示在最新位置
requestAnimationFrame(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
});
}, 1500);
};
// ===== 事件:回车键发送消息 =====
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 监听回车键,触发发送消息逻辑
if (e.key === 'Enter') {
e.preventDefault(); // 阻止输入框默认的换行行为(单行输入框无需换行)
handleSendMessage(); // 调用发送消息函数
}
};
// ===== 渲染(不变)=====
return (
<>
{/* 1. 聊天主弹窗:展示聊天记录、输入框、分享按钮 */}
<Modal isOpen={isOpen} onClose={onClose} title={`${friend.name} 聊天中`}
contentClassName="max-w-3xl max-h-[90vh] min-h-[60vh]">
<div className="h-[80vh] min-h-[500px] flex flex-col">
{/* 消息展示区域:可滚动,自动定位到底部 */}
<div
ref={messagesContainerRef}
className="flex-1 p-4 bg-gray-100 rounded-t-lg overflow-y-auto space-y-4"
>
{/* 渲染所有聊天消息遍历messages数组每条消息对应一个DOM节点 */}
{messages.map((message) => (
<div
key={message.id}
// 布局自己的消息右对齐flex-row-reverse好友的消息左对齐
className={`flex items-start gap-3 ${message.isSent ? 'flex-row-reverse' : ''}`}
>
{/* 头像:自己用默认头像,好友用其专属头像(无则用默认) */}
<img
src={message.isSent
? "https://i.pravatar.cc/150?u=zhang-san"
: friend.avatarUrl || '/default-avatar.png'
}
alt={message.isSent ? '自己' : friend.name || '好友'}
className="w-8 h-8 rounded-full mt-1 flex-shrink-0"
/>
{/* 消息内容+时间戳容器:对齐方式与消息一致 */}
<div className={`flex flex-col ${message.isSent ? 'items-end' : 'items-start'}`}>
{/* 消息气泡:区分自己和好友的样式(颜色、圆角、边框) */}
<div
className={`
shadow-sm text-sm p-3 rounded-xl
inline-block whitespace-pre-wrap
min-w-[60px]
box-border
${
message.isSent
? 'bg-primary text-white'
: 'bg-white text-gray-800'
}
`}
style={{
maxWidth: '%',
whiteSpace: 'pre-wrap',
lineHeight: '1.5',
verticalAlign: 'top',
}}
>
<p style={{ margin: 0 }}>{message.content}</p>
</div>
{/* 时间戳+已读状态:小字体弱化显示,区分自己和好友的文本颜色 */}
<div className={`flex items-center mt-1 text-xs ${
message.isSent ? 'text-primary-200' : 'text-gray-400'
}`}>
<span>{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
<span className="ml-2 text-gray-500">{message.isRead ? '已读' : '未读'}</span>
</div>
</div>
</div>
))}
</div>
{/* 消息输入区域:包含分享按钮、输入框、发送按钮 */}
<div className="p-4 border-t">
<div className="flex items-center space-x-2">
{/* 分享查询按钮:点击打开分享弹窗 */}
<button
onClick={handleOpenShareModal}
className="p-2 w-10 h-10 flex items-center justify-center text-gray-600 rounded-full text-xl hover:bg-gray-100 transition-colors"
title="分享查询结果"
>
<i className="fa fa-share-alt"></i>
</button>
{/* 消息输入框:绑定输入状态,支持回车发送 */}
<input
ref={inputRef}
type="text"
placeholder="输入消息..."
value={inputText}
onChange={(e) => setInputText(e.target.value.trimStart())}
onKeyDown={handleKeyDown}
className="flex-1 px-4 py-3 border border-gray-300 rounded-full focus:ring-2 focus:ring-primary/30 outline-none"
readOnly={false}
/>
{/* 发送按钮:空消息时禁用,点击发送消息 */}
<button
onClick={handleSendMessage}
disabled={!inputText.trim() && !inputRef.current?.value.trim()}
className="px-4 py-3 bg-primary text-white rounded-full hover:bg-primary/90 disabled:bg-gray-300 transition-colors"
aria-label="发送"
>
<i className="fa fa-paper-plane text-lg"></i>
</button>
</div>
</div>
</div>
</Modal>
{/* 2. 分享查询弹窗:选择要分享的查询记录 */}
<Modal isOpen={isShareModalOpen} onClose={() => setShareModalOpen(false)} title="分享查询结果">
<div className="space-y-4">
<p className="text-sm text-gray-600"> {friend.name}</p>
{/* 可分享查询列表:滚动展示,单选模式 */}
<div className="max-h-60 overflow-y-auto space-y-2 p-2 bg-gray-50 rounded-lg border">
{savedQueries.length > 0 ? (
savedQueries.map(q => (
<label
key={q.id}
className={`flex items-center p-3 rounded-lg cursor-pointer transition-colors ${
selectedQueryId === q.id ? 'bg-primary/20' : 'hover:bg-gray-200'
}`}
>
<input
type="radio"
name="query-share-selection"
checked={selectedQueryId === q.id}
onChange={() => setSelectedQueryId(q.id)}
className="mr-3"
/>
<span className="text-sm font-medium text-dark truncate" title={q.userPrompt}>{q.userPrompt}</span>
</label>
))
) : (
<p className="text-center text-gray-500 text-sm p-4"></p>
)}
</div>
</div>
{/* 分享弹窗底部按钮:取消/确认分享 */}
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setShareModalOpen(false)}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"
>
</button>
<button
onClick={handleConfirmShare}
disabled={!selectedQueryId}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 disabled:bg-primary/50 disabled:cursor-not-allowed"
>
</button>
</div>
</Modal>
</>
);
};

@ -0,0 +1,241 @@
import React, { useState } from 'react';
import { Modal } from './Modal';
import { useQueryCollection } from '../hooks/useQueryCollection';
export const CollectionsPage: React.FC = () => {
const userId = Number(sessionStorage.getItem('userId') || '1');
const {
collections,
loading,
error,
createCollection,
updateCollection,
deleteCollection,
} = useQueryCollection(userId);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [currentCollection, setCurrentCollection] = useState<any>(null);
const [collectionName, setCollectionName] = useState('');
const [collectionDescription, setCollectionDescription] = useState('');
const handleOpenCreateModal = () => {
setCollectionName('');
setCollectionDescription('');
setIsCreateModalOpen(true);
};
const handleOpenEditModal = (collection: any) => {
setCurrentCollection(collection);
setCollectionName(collection.collectionName);
setCollectionDescription(collection.description || '');
setIsEditModalOpen(true);
};
const handleOpenDeleteModal = (collection: any) => {
setCurrentCollection(collection);
setIsDeleteModalOpen(true);
};
const handleCreate = async () => {
if (!collectionName.trim()) {
alert('请输入收藏夹名称');
return;
}
const result = await createCollection(collectionName, collectionDescription);
if (result) {
setIsCreateModalOpen(false);
alert('创建成功');
} else {
alert(error || '创建失败');
}
};
const handleUpdate = async () => {
if (!collectionName.trim()) {
alert('请输入收藏夹名称');
return;
}
if (!currentCollection) return;
const result = await updateCollection(currentCollection.id, collectionName, collectionDescription);
if (result) {
setIsEditModalOpen(false);
setCurrentCollection(null);
alert('更新成功');
} else {
alert(error || '更新失败');
}
};
const handleDelete = async () => {
if (!currentCollection) return;
const result = await deleteCollection(currentCollection.id);
if (result) {
setIsDeleteModalOpen(false);
setCurrentCollection(null);
alert('删除成功');
} else {
alert(error || '删除失败');
}
};
if (loading && collections.length === 0) {
return (
<section className="p-6 space-y-6 overflow-y-auto h-full flex items-center justify-center">
<div className="text-center">
<div className="dot-flashing"></div>
<p className="mt-4 text-gray-500">...</p>
</div>
</section>
);
}
return (
<section className="p-6 space-y-6 overflow-y-auto h-full">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">
<i className="fa fa-star mr-2"></i>
</h2>
<button
onClick={handleOpenCreateModal}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
<i className="fa fa-plus mr-2"></i>
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{collections.map(collection => (
<div key={collection.id} className="bg-white rounded-xl shadow-sm border p-4 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="font-semibold text-lg text-dark">{collection.collectionName}</h3>
{collection.description && (
<p className="text-sm text-gray-500 mt-1">{collection.description}</p>
)}
</div>
<i className="fa fa-folder text-primary text-2xl"></i>
</div>
<div className="text-xs text-gray-400 mb-3">
: {new Date(collection.createTime).toLocaleDateString()}
</div>
<div className="flex space-x-2">
<button
onClick={() => handleOpenEditModal(collection)}
className="flex-1 px-3 py-1.5 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 transition-colors"
>
<i className="fa fa-edit mr-1"></i>
</button>
<button
onClick={() => handleOpenDeleteModal(collection)}
className="flex-1 px-3 py-1.5 bg-red-50 text-red-600 rounded text-sm hover:bg-red-100 transition-colors"
>
<i className="fa fa-trash mr-1"></i>
</button>
</div>
</div>
))}
</div>
{collections.length === 0 && !loading && (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-folder-open-o text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
<Modal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} title="新建收藏夹">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> *</label>
<input
type="text"
placeholder="输入收藏夹名称"
value={collectionName}
onChange={(e) => setCollectionName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
maxLength={50}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<textarea
placeholder="输入收藏夹描述"
value={collectionDescription}
onChange={(e) => setCollectionDescription(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg resize-none"
rows={3}
maxLength={200}
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setIsCreateModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleCreate} disabled={loading} className="px-4 py-2 bg-primary text-white rounded-lg text-sm disabled:bg-gray-300">
{loading ? '创建中...' : '创建'}
</button>
</div>
</Modal>
<Modal isOpen={isEditModalOpen} onClose={() => { setIsEditModalOpen(false); setCurrentCollection(null); }} title="编辑收藏夹">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> *</label>
<input
type="text"
placeholder="输入收藏夹名称"
value={collectionName}
onChange={(e) => setCollectionName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
maxLength={50}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<textarea
placeholder="输入收藏夹描述"
value={collectionDescription}
onChange={(e) => setCollectionDescription(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg resize-none"
rows={3}
maxLength={200}
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => { setIsEditModalOpen(false); setCurrentCollection(null); }} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleUpdate} disabled={loading} className="px-4 py-2 bg-primary text-white rounded-lg text-sm disabled:bg-gray-300">
{loading ? '保存中...' : '保存'}
</button>
</div>
</Modal>
<Modal isOpen={isDeleteModalOpen} onClose={() => { setIsDeleteModalOpen(false); setCurrentCollection(null); }} title="删除收藏夹">
<p className="text-sm text-gray-700">
"{currentCollection?.collectionName}"
</p>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => { setIsDeleteModalOpen(false); setCurrentCollection(null); }} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleDelete} disabled={loading} className="px-4 py-2 bg-danger text-white rounded-lg text-sm disabled:bg-gray-300">
{loading ? '删除中...' : '确认删除'}
</button>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,232 @@
import React, { useState, useMemo } from 'react';
import { Chart as ChartJS, registerables } from 'chart.js';
import { Chart } from 'react-chartjs-2';
import { QueryResultData } from '../types';
import { Modal } from './Modal'; // 引入你的Modal组件确保路径正确
// 注册ChartJS所需的所有组件
ChartJS.register(...registerables);
// 表格行差异结果类型定义:包含新增、删除、修改和共同的行数据
type DiffResult = {
added: string[][]; // 新增的行
deleted: string[][]; // 删除的行
modified: { oldRow: string[], newRow: string[] }[]; // 修改的行(包含旧行和新行)
common: string[][]; // 未变化的共同行
};
// 查找两个表格数据集之间行差异的辅助函数
// 参数:旧行数据、新行数据;返回:差异结果对象
const findRowChanges = (oldRows: string[][], newRows: string[][]): DiffResult => {
// 以每行第一个元素为键,构建旧行和新行的映射表(便于快速查找)
const oldMap = new Map(oldRows.map(row => [row[0], row]));
const newMap = new Map(newRows.map(row => [row[0], row]));
// 初始化差异结果对象
const result: DiffResult = { added: [], deleted: [], modified: [], common: [] };
// 遍历旧行映射,检查每行在新行中的状态(删除/修改/共同)
oldMap.forEach((oldRow, key) => {
if (!newMap.has(key)) {
result.deleted.push(oldRow); // 新行中无此键:标记为删除
} else {
const newRow = newMap.get(key)!;
// 行数据字符串化后不等:标记为修改
if (JSON.stringify(oldRow) !== JSON.stringify(newRow)) {
result.modified.push({ oldRow, newRow });
} else {
result.common.push(oldRow); // 行数据相同:标记为共同
}
}
});
// 遍历新行映射,检查新增的行(旧行中无此键)
newMap.forEach((newRow, key) => {
if (!oldMap.has(key)) {
result.added.push(newRow);
}
});
return result;
};
// 表格对比组件:展示两个查询结果的表格差异(新增/删除/修改行)
// 参数:旧查询结果、新查询结果、差异结果
const TableComparison: React.FC<{ oldQuery: QueryResultData; newQuery: QueryResultData, changes: DiffResult }> = ({ oldQuery, newQuery, changes }) => {
// 渲染单元格内容:若新旧单元格内容不同,高亮显示新内容并标注旧内容
const renderCell = (oldCell: string, newCell: string) => {
if (oldCell !== newCell) {
return (
<span className="bg-yellow-100 text-yellow-800 p-1 rounded">
{newCell} <span className="text-xs text-gray-500 line-through ml-1">{oldCell}</span>
</span>
);
}
return newCell;
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 旧数据表格 */}
<div>
<h3 className="font-bold mb-2"> ({new Date(oldQuery.queryTime).toLocaleString()})</h3>
<div className="overflow-x-auto border rounded-lg max-h-[40vh]">
<table className="w-full text-sm">
<thead><tr className="border-b bg-gray-50">{oldQuery.tableData.headers.map(h => <th key={h} className="p-2">{h}</th>)}</tr></thead>
<tbody>
{/* 显示删除的行(红色背景+删除线) */}
{changes.deleted.map((row, i) => <tr key={`del-${i}`} className="bg-red-100"><td colSpan={row.length} className="p-2 text-center text-red-700 line-through">{row.join(' | ')}</td></tr>)}
{/* 显示修改的旧行 */}
{changes.modified.map(({ oldRow }, i) => (
<tr key={`mod-old-${i}`} className="border-b">
{oldRow.map((cell, j) => <td key={j} className="p-2 text-center">{cell}</td>)}
</tr>
))}
{/* 显示未变化的共同行 */}
{changes.common.map((row, i) => <tr key={`com-${i}`} className="border-b">{row.map((cell, j) => <td key={j} className="p-2 text-center">{cell}</td>)}</tr>)}
</tbody>
</table>
</div>
</div>
{/* 新数据表格 */}
<div>
<h3 className="font-bold mb-2"> ({new Date(newQuery.queryTime).toLocaleString()})</h3>
<div className="overflow-x-auto border rounded-lg max-h-[40vh]">
<table className="w-full text-sm">
<thead><tr className="border-b bg-gray-50">{newQuery.tableData.headers.map(h => <th key={h} className="p-2">{h}</th>)}</tr></thead>
<tbody>
{/* 显示新增的行(绿色背景) */}
{changes.added.map((row, i) => <tr key={`add-${i}`} className="bg-green-100">{row.map((cell, j) => <td key={j} className="p-2 text-center text-green-700">{cell}</td>)}</tr>)}
{/* 显示修改的新行(高亮变化的单元格) */}
{changes.modified.map(({ oldRow, newRow }, i) => (
<tr key={`mod-new-${i}`} className="border-b">
{newRow.map((cell, j) => <td key={j} className="p-2 text-center">{renderCell(oldRow[j], cell)}</td>)}
</tr>
))}
{/* 显示未变化的共同行 */}
{changes.common.map((row, i) => <tr key={`com-${i}`} className="border-b">{row.map((cell, j) => <td key={j} className="p-2 text-center">{cell}</td>)}</tr>)}
</tbody>
</table>
</div>
</div>
</div>
);
};
// 图表对比组件:在同一图表中展示新旧查询结果的图表数据
// 参数:旧查询结果、新查询结果
const ChartComparison: React.FC<{ oldQuery: QueryResultData; newQuery: QueryResultData }> = ({ oldQuery, newQuery }) => {
// 构建图表数据:合并新旧标签,分别配置新旧数据集样式
const chartData = {
labels: [...new Set([...oldQuery.chartData.labels, ...newQuery.chartData.labels])], // 合并去重标签
datasets: [
{
...oldQuery.chartData.datasets[0],
label: `${oldQuery.chartData.datasets[0].label} (旧)`,
backgroundColor: 'rgba(22, 93, 255, 0.3)',
borderColor: 'rgba(22, 93, 255, 0.5)',
borderDash: [5, 5], // 旧数据用虚线
},
{
...newQuery.chartData.datasets[0],
label: `${newQuery.chartData.datasets[0].label} (新)`,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)', // 新数据用实线
}
],
};
return (
<div className="bg-white p-4 rounded-lg border">
<div className="chart-container h-64">
<Chart
type={newQuery.chartData.type}
data={chartData}
options={{ responsive: true, maintainAspectRatio: false }}
/>
</div>
</div>
);
};
// 弹窗式对比组件属性接口:新增弹窗控制参数,保留原有对比参数
interface ComparisonModalProps {
oldQuery: QueryResultData;
newQuery: QueryResultData;
isOpen: boolean; // 控制弹窗显示/隐藏
onClose: () => void; // 弹窗关闭回调
}
// 弹窗式对比组件用Modal包裹原有对比内容适配弹窗形态
export const ComparisonModal: React.FC<ComparisonModalProps> = ({ oldQuery, newQuery, isOpen, onClose }) => {
// 视图切换状态:表格视图/图表视图
const [activeView, setActiveView] = useState<'table' | 'chart'>('table');
// 计算表格行差异使用useMemo避免重复计算
const changes = useMemo(() => findRowChanges(oldQuery.tableData.rows, newQuery.tableData.rows), [oldQuery, newQuery]);
// 对比摘要统计项:新增/删除/变更行数量
const summaryItems = [
{ icon: 'fa-plus-circle', color: 'text-green-500', value: changes.added.length, label: '新增行' },
{ icon: 'fa-minus-circle', color: 'text-red-500', value: changes.deleted.length, label: '删除行' },
{ icon: 'fa-pencil-square-o', color: 'text-yellow-500', value: changes.modified.length, label: '变更行' },
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="查询快照对比" // 弹窗标题
contentClassName="max-w-3xl max-h-[90vh] min-h-[60vh]" // 弹窗尺寸适配占屏幕90%宽高最大宽度6xl
>
{/* 弹窗内容区域:限制高度+滚动 */}
<div className="p-4 space-y-6 max-h-[calc(100%-2rem)] overflow-y-auto">
{/* 对比说明 */}
<div>
<p className="text-gray-600 text-sm"> "{oldQuery.userPrompt}" </p>
</div>
{/* 对比摘要卡片 */}
<div className="bg-white p-4 rounded-xl shadow-sm border">
<h3 className="font-bold mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{summaryItems.map(item => (
<div key={item.label} className="flex items-center p-3 bg-gray-50 rounded-lg">
<i className={`fa ${item.icon} ${item.color} text-2xl mr-3`}></i>
<div>
<p className="text-xl font-bold">{item.value}</p>
<p className="text-sm text-gray-500">{item.label}</p>
</div>
</div>
))}
</div>
</div>
{/* 对比视图切换区域 */}
<div className="bg-white p-4 rounded-xl shadow-sm border">
<div className="border-b mb-3">
<div className="flex space-x-6">
<button
onClick={() => setActiveView('table')}
className={`py-2 text-sm ${activeView === 'table' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'}`}
>
</button>
<button
onClick={() => setActiveView('chart')}
className={`py-2 text-sm ${activeView === 'chart' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'}`}
>
</button>
</div>
</div>
{/* 根据当前视图状态渲染对应的对比组件 */}
{activeView === 'table'
? <TableComparison oldQuery={oldQuery} newQuery={newQuery} changes={changes}/>
: <ChartComparison oldQuery={oldQuery} newQuery={newQuery} />
}
</div>
</div>
</Modal>
);
};

@ -0,0 +1,123 @@
import React from 'react';
import { DataAdminPageType, Page, Conversation, QueryResultData, MessageRole, QueryShare } from '../types';
import { DataSourceManagementPage } from './data-admin/DataSourceManagementPage';
import { UserPermissionPage } from './data-admin/UserPermissionPage';
import { ConnectionLogPage } from './data-admin/ConnectionLogPage';
import { QueryPage } from './QueryPage';
import { HistoryPage } from './HistoryPage';
import { AccountPage } from './AccountPage';
import { FriendsPageWithRealAPI } from './FriendsPageWithRealAPI';
import { NotificationsPage } from './NotificationsPage';
import { DataAdminNotificationPage } from './data-admin/DataAdminNotificationPage';
import { ComparisonModal } from './ComparisonModal';
import { DataAdminDashboardPage } from './data-admin/DataAdminDashboardPage';
interface DataAdminPageProps {
activePage: DataAdminPageType;
setActivePage: (page: DataAdminPageType) => void;
// Props for QueryPage and others
currentConversation: Conversation | undefined;
onToggleHistory: () => void;
isHistoryOpen: boolean;
onAddMessage: (role: MessageRole, content: string | QueryResultData) => void;
onSaveQuery: (query: QueryResultData) => void;
onShareQuery: (queryId: string, friendId: string) => void;
savedQueries: QueryResultData[];
initialPrompt?: string;
onClearInitialPrompt: () => void;
// Props for HistoryPage
conversations: Conversation[];
currentConversationId: string;
onSwitchConversation: (id: string) => void;
onNewConversation: () => void;
onDelete: (id: string) => void;
onViewInChat: (conversationId: string) => void;
onRerun: (prompt: string) => void;
onCompare: (queryId1: string, queryId2: string) => void;
// Props for FriendsPage
shares: QueryShare[];
onMarkShareAsRead: (shareId: string) => void;
onDeleteShare: (shareId: string) => void;
onDeleteConversation: (id: string) => void;
}
export const DataAdminPage: React.FC<DataAdminPageProps> = (props) => {
const { activePage } = props;
// Duplicated from App.tsx to render shared pages
const renderNormalUserPage = (page: Page) => {
switch (page) {
case 'query':
return (
<QueryPage
currentConversation={props.currentConversation}
onToggleHistory={props.onToggleHistory}
isHistoryOpen={props.isHistoryOpen}
onAddMessage={props.onAddMessage}
onSaveQuery={props.onSaveQuery}
onShareQuery={props.onShareQuery}
savedQueries={props.savedQueries}
initialPrompt={props.initialPrompt}
onClearInitialPrompt={props.onClearInitialPrompt}
conversations={props.conversations}
currentConversationId={props.currentConversationId}
onSwitchConversation={props.onSwitchConversation}
onNewConversation={props.onNewConversation}
onDeleteConversation={props.onDeleteConversation}
/>
);
case 'history':
return (
<HistoryPage
savedQueries={props.savedQueries}
conversations={props.conversations}
onDelete={props.onDelete}
onViewInChat={props.onViewInChat}
onRerun={props.onRerun}
onCompare={props.onCompare}
/>
);
case 'notifications':
return <NotificationsPage />;
case 'account':
return <AccountPage />;
case 'friends':
return (
<FriendsPageWithRealAPI
savedQueries={props.savedQueries}
shares={props.shares}
onMarkShareAsRead={props.onMarkShareAsRead}
onDeleteShare={props.onDeleteShare}
onRerunQuery={props.onRerun}
onSaveQuery={props.onSaveQuery}
/>
);
default:
// This case handles 'comparison', which is rendered by the parent switch
return <div>Page not found</div>;
}
};
switch (activePage) {
// Admin specific pages
case 'dashboard':
return <DataAdminDashboardPage setActivePage={props.setActivePage} />;
case 'datasource':
return <DataSourceManagementPage />;
case 'user-permission':
return <UserPermissionPage />;
case 'notification-management':
return <DataAdminNotificationPage />;
case 'connection-log':
return <ConnectionLogPage />;
case 'query':
case 'history':
case 'notifications':
case 'account':
case 'friends':
return renderNormalUserPage(activePage);
default:
const exhaustiveCheck: never = activePage;
return <div>Page not found</div>;
}
};

@ -0,0 +1,93 @@
import React from 'react';
import { DataAdminPageType } from '../types';
interface SidebarItemProps {
href: DataAdminPageType;
icon: string;
label: string;
isActive: boolean;
onClick: (page: DataAdminPageType) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ href, icon, label, isActive, onClick }) => {
const activeClass = 'bg-primary/10 text-primary border-l-4 border-primary';
const inactiveClass = 'text-gray-600 hover:bg-gray-50';
return (
<li>
<a
href={`#${href}`}
className={`flex items-center px-6 py-3 text-sm transition-colors duration-200 ${isActive ? activeClass : inactiveClass}`}
onClick={(e) => {
e.preventDefault();
onClick(href);
}}
>
<i className={`fa ${icon} w-6`}></i>
<span>{label}</span>
</a>
</li>
);
};
interface SidebarProps {
activePage: DataAdminPageType;
setActivePage: (page: DataAdminPageType) => void;
onLogout: () => void;
}
export const DataAdminSidebar: React.FC<SidebarProps> = ({ activePage, setActivePage, onLogout }) => {
const queryItems = [
{ href: 'query', icon: 'fa-search', label: '数据查询' },
{ href: 'history', icon: 'fa-star', label: '收藏夹' },
] as const;
const managementItems = [
{ href: 'dashboard', icon: 'fa-tachometer', label: '仪表盘' },
{ href: 'datasource', icon: 'fa-plug', label: '数据源管理' },
{ href: 'user-permission', icon: 'fa-key', label: '用户权限管理' },
{ href: 'notification-management', icon: 'fa-bullhorn', label: '通知管理' },
{ href: 'connection-log', icon: 'fa-link', label: '连接日志' },
] as const;
const personalItems = [
{ href: 'notifications', icon: 'fa-bell', label: '通知中心' },
{ href: 'account', icon: 'fa-user', label: '账户管理' },
{ href: 'friends', icon: 'fa-users', label: '好友管理' },
] as const;
return (
<aside className="w-64 bg-white shadow-md h-screen flex-shrink-0 flex flex-col hidden lg:flex">
<div className="p-4 border-b flex items-center space-x-2">
<i className="fa fa-database text-primary text-2xl"></i>
<h1 className="text-lg font-bold"></h1>
</div>
<nav className="py-4 flex-grow">
<ul>
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase"></li>
{queryItems.map(item => (
<SidebarItem key={item.href} {...item} isActive={activePage === item.href} onClick={setActivePage} />
))}
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase mt-4"></li>
{managementItems.map(item => (
<SidebarItem key={item.href} {...item} isActive={activePage === item.href} onClick={setActivePage} />
))}
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase mt-4"></li>
{personalItems.map(item => (
<SidebarItem key={item.href} {...item} isActive={activePage === item.href} onClick={setActivePage} />
))}
</ul>
</nav>
<div className="border-t p-4">
<button
onClick={onLogout}
className="flex items-center text-gray-600 hover:text-danger transition-colors w-full">
<i className="fa fa-sign-out w-6"></i>
<span>退</span>
</button>
</div>
</aside>
);
};

@ -0,0 +1,63 @@
import React, { useState, useRef, useEffect } from 'react';
import { ModelOption } from '../types';
interface DropdownProps {
options: ModelOption[];
selected: string;
setSelected: (option: string) => void;
icon: string;
}
export const Dropdown: React.FC<DropdownProps> = ({ options, selected, setSelected, icon }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (option: ModelOption) => {
if (option.disabled) return;
setSelected(option.name);
setIsOpen(false);
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center px-4 py-2 bg-primary text-white rounded-lg text-sm w-40 hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"
>
<i className={`fa ${icon} mr-2`}></i>
<span className="truncate">{selected}</span>
</button>
{isOpen && (
<div className="absolute left-0 bottom-full mb-1 w-48 bg-white border border-gray-300 rounded-lg shadow-lg z-10 max-h-60 overflow-y-auto">
{options.map(option => (
<button
key={option.name}
onClick={() => handleSelect(option)}
disabled={option.disabled}
title={option.description}
className={`w-full text-left px-4 py-2 text-sm transition-colors
${selected === option.name ? 'bg-primary/10 text-primary' : ''}
${option.disabled
? 'text-gray-400 cursor-not-allowed bg-gray-100'
: 'hover:bg-gray-100'
}
`}
>
{option.name}
</button>
))}
</div>
)}
</div>
);
};

@ -0,0 +1,728 @@
import React, { useState, useMemo, useEffect } from 'react';
import { MOCK_FRIENDS_LIST, MOCK_FRIEND_REQUESTS } from '../constants';
import { Friend, FriendRequest, QueryResultData, QueryShare } from '../types';
import { Modal } from './Modal';
import { ChatModal } from './ChatModal';
import { QueryResult } from './QueryResult';
// 补充Message接口定义若types文件已包含可删除
interface Message {
id: string;
content: string;
isSent: boolean;
timestamp: Date;
isRead: boolean;
}
// 标签类型定义
type ActiveTab = 'friends' | 'requests' | 'shares';
// 1. 好友卡片组件(新增备注显示+备注/主页按钮)
const FriendCard: React.FC<{
friend: Friend;
onChat: (friend: Friend) => void;
onDelete: (friend: Friend) => void;
onRemark: (friend: Friend) => void; // 新增:触发备注弹窗
onViewProfile: (friend: Friend) => void; // 新增:触发查看主页
unreadCount: number;
}> = ({ friend, onChat, onDelete, onRemark, onViewProfile, unreadCount }) => (
<div className="p-4 bg-white rounded-xl shadow-sm border flex items-center justify-between transition-shadow hover:shadow-md w-full">
<div className="flex items-center space-x-4">
<div className="relative">
<img
src={friend.avatarUrl}
alt={friend.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
{/* 在线状态标记 */}
{friend.isOnline && (
<span className="absolute bottom-0 right-0 block h-3 w-3 rounded-full bg-green-400 ring-2 ring-white"></span>
)}
</div>
<div>
<div className="flex items-center">
{/* 有备注显示备注,无备注显示原名(作为主要标识) */}
<p className="font-semibold text-dark">
{friend.remark || friend.name}
</p>
{/* 未读消息提示 */}
{unreadCount > 0 && (
<span className="ml-2 bg-primary text-white text-xs rounded-full px-2 py-0.5">
{unreadCount}
</span>
)}
</div>
{/* 有备注时才显示原名(作为补充),无备注则不显示 */}
{friend.remark && (
<p className="text-sm text-gray-500 italic">{friend.name}</p>
)}
</div>
</div>
<div className="flex space-x-2">
{/* 聊天按钮(原有) */}
<button
onClick={() => onChat(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="聊天"
>
<i className="fa fa-comment-o"></i>
</button>
{/* 新增:备注按钮 */}
<button
onClick={() => onRemark(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="备注好友"
>
<i className="fa fa-pencil"></i>
</button>
{/* 新增:访问主页按钮 */}
<button
onClick={() => onViewProfile(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="查看好友主页"
>
<i className="fa fa-user-o"></i>
</button>
{/* 删除好友按钮(原有) */}
<button
onClick={() => onDelete(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-danger/10 hover:text-danger transition-colors"
title="删除好友"
>
<i className="fa fa-trash-o"></i>
</button>
</div>
</div>
);
// 2. 好友请求卡片组件(原有功能不变)
const RequestCard: React.FC<{
request: FriendRequest;
onAccept: (request: FriendRequest) => void;
onReject: (requestId: string) => void;
}> = ({ request, onAccept, onReject }) => (
<div className="p-4 bg-white rounded-xl shadow-sm border flex items-center justify-between">
<div className="flex items-center space-x-4">
<img
src={request.fromUser.avatarUrl}
alt={request.fromUser.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
<div>
<p className="font-semibold text-dark">{request.fromUser.name}</p>
<p className="text-sm text-gray-500">{request.timestamp}</p>
</div>
</div>
<div className="flex space-x-2">
<button onClick={() => onAccept(request)} className="px-3 py-1 bg-primary text-white rounded-lg text-sm"></button>
<button onClick={() => onReject(request.id)} className="px-3 py-1 bg-gray-200 rounded-lg text-sm"></button>
</div>
</div>
);
// 3. 分享记录卡片组件(原有功能不变)
const ShareRecordCard: React.FC<{
share: QueryShare;
onView: () => void;
onRerun: () => void;
onDelete: () => void;
onSave: () => void;
}> = ({ share, onView, onRerun, onDelete, onSave }) => (
<div className={`p-4 rounded-xl shadow-sm border flex items-start justify-between transition-shadow hover:shadow-md ${
share.status === 'unread' ? 'bg-primary/5' : 'bg-white'
}`}>
<div className="flex items-start space-x-4">
<img
src={share.sender.avatarUrl}
alt={share.sender.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
<div>
<p className="font-semibold text-dark font-bold">{share.sender.name}<span className="text-xs text-gray-500 font-normal ml-1">:</span> </p>
<p className="text-md text-primary text-sm mt-1">"{share.querySnapshot.userPrompt}"</p>
<p className="text-xs text-gray-400 mt-2">{new Date(share.timestamp).toLocaleString()}</p>
</div>
</div>
<div className="flex flex-col space-y-2 items-end">
<div className="flex space-x-2">
<button onClick={onSave} className="px-3 py-1 border border-gray-300 rounded-lg text-xs hover:bg-gray-50">
<i className="fa fa-save mr-1"></i>
</button>
<button onClick={onView} className="px-3 py-1 border border-gray-300 rounded-lg text-xs hover:bg-gray-50">
<i className="fa fa-eye mr-1"></i>
</button>
<button onClick={onRerun} className="px-3 py-1 bg-primary text-white rounded-lg text-xs hover:shadow-md">
<i className="fa fa-paper-plane mr-1"></i>
</button>
<button onClick={onDelete} className="px-3 py-1 bg-red-500 hover:bg-red-600 text-white rounded-lg text-xs transition-colors">
<i className="fa fa-trash-o mr-1"></i>
</button>
</div>
</div>
</div>
);
// 4. 标签按钮组件(原有功能不变)
const TabButton: React.FC<{
tab: ActiveTab;
label: string;
count?: number;
activeTab: ActiveTab;
onTabChange: (tab: ActiveTab) => void;
}> = ({ tab, label, count, activeTab, onTabChange }) => (
<button
onClick={() => onTabChange(tab)}
className={`py-2 px-4 text-sm font-medium transition-colors duration-200 relative ${
activeTab === tab ? 'border-b-2 border-primary text-primary' : 'text-gray-500 hover:text-dark'
}`}
>
{label}
{count !== undefined && count > 0 && (
<span className="ml-1 text-xs">({count})</span>
)}
</button>
);
// 页面Props定义原有功能不变
interface FriendsPageProps {
savedQueries: QueryResultData[];
shares: QueryShare[];
onMarkShareAsRead: (shareId: string) => void;
onDeleteShare: (shareId: string) => void;
onRerunQuery: (prompt: string) => void;
onSaveQuery: (query: QueryResultData) => void;
}
export const FriendsPage: React.FC<FriendsPageProps> = ({
savedQueries, shares, onMarkShareAsRead,
onDeleteShare, onRerunQuery, onSaveQuery
}) => {
// 页面状态
const [friends, setFriends] = useState<Friend[]>(
// 初始化好友数据给原有Mock数据添加默认空备注
MOCK_FRIENDS_LIST.map(friend => ({ ...friend, remark: '' }))
);
const [requests, setRequests] = useState<FriendRequest[]>(MOCK_FRIEND_REQUESTS);
const [isAddFriendModalOpen, setAddFriendModalOpen] = useState(false);
const [chattingWith, setChattingWith] = useState<Friend | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [friendToDelete, setFriendToDelete] = useState<Friend | null>(null);
const [activeTab, setActiveTab] = useState<ActiveTab>('friends');
const [viewingShare, setViewingShare] = useState<QueryShare | null>(null);
const [activeModal, setActiveModal] = useState<string | null>(null);
// 新增:备注相关状态
const [isRemarkModalOpen, setIsRemarkModalOpen] = useState(false);
const [currentRemarkFriend, setCurrentRemarkFriend] = useState<Friend | null>(null);
const [remarkInput, setRemarkInput] = useState('');
// 新增:好友主页相关状态
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [currentProfileFriend, setCurrentProfileFriend] = useState<Friend | null>(null);
// 未读消息状态
const [unreadMessages, setUnreadMessages] = useState<Record<string, number>>({});
// 聊天记录状态
const [friendMessages, setFriendMessages] = useState<Record<string, Message[]>>(() => ({
'friend-1': [
{
id: `${Date.now()}-init-1`,
content: '你好!最近在忙什么?',
isSent: false,
timestamp: new Date(Date.now() - 3600000),
isRead: true,
},
{
id: `${Date.now()}-init-2`,
content: '我在处理一个数据查询,挺复杂的~',
isSent: true,
timestamp: new Date(Date.now() - 3500000),
isRead: false,
}
],
'friend-2': [
{
id: `${Date.now()}-init-3`,
content: '在吗?上次分享的查询结果很有用!',
isSent: false,
timestamp: new Date(Date.now() - 86400000),
isRead: true,
}
]
}));
// 初始化未读消息
useEffect(() => {
setUnreadMessages({
'friend-1': 2,
'friend-2': 1
});
}, []);
// 新增:打开备注弹窗时初始化输入值
useEffect(() => {
if (currentRemarkFriend) {
setRemarkInput(currentRemarkFriend.remark || '');
}
}, [currentRemarkFriend]);
// 更新未读消息数量
const updateUnreadCount = (friendId: string, count: number) => {
setUnreadMessages(prev => ({
...prev,
[friendId]: count
}));
};
// 更新聊天记录
const updateFriendMessages = (friendId: string, newMessages: Message[]) => {
setFriendMessages(prev => ({
...prev,
[friendId]: newMessages
}));
};
// 打开聊天
const handleOpenChat = (friend: Friend) => {
setChattingWith(friend);
if (!friendMessages[friend.id]) {
setFriendMessages(prev => ({
...prev,
[friend.id]: [
{
id: `${Date.now()}-init-empty`,
content: `你好!开始和${friend.name}聊天吧~`,
isSent: false,
timestamp: new Date(),
isRead: true,
}
]
}));
}
};
// 模拟新消息(测试用)
const mockNewMessage = (friendId: string) => {
setUnreadMessages(prev => ({
...prev,
[friendId]: (prev[friendId] || 0) + 1
}));
const friend = friends.find(f => f.id === friendId);
if (friend) {
const randomTexts = [
"你好!在忙吗?",
"这个查询结果我觉得很有用",
"我们下次什么时候再讨论一下?",
"你之前分享的内容帮了我大忙",
"有空可以看看我刚发的查询"
];
const randomText = randomTexts[Math.floor(Math.random() * randomTexts.length)];
const newMessage: Message = {
id: `mock-${Date.now()}`,
content: randomText,
isSent: false,
timestamp: new Date(),
isRead: false
};
setFriendMessages(prev => ({
...prev,
[friendId]: prev[friendId]
? [...prev[friendId], newMessage]
: [newMessage]
}));
}
};
// 筛选好友
const filteredFriends = useMemo(() =>
friends.filter(friend =>
friend.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(friend.remark && friend.remark.toLowerCase().includes(searchTerm.toLowerCase())) // 支持按备注搜索
),
[friends, searchTerm]
);
// 删除好友逻辑
const leteRequest = (friend: Friend) => setFriendToDelete(friend);
const handleConfirmDelete = () => {
if (friendToDelete) {
setFriends(prev => prev.filter(f => f.id !== friendToDelete.id));
setUnreadMessages(prev => {
const newUnread = { ...prev };
delete newUnread[friendToDelete.id];
return newUnread;
});
setFriendMessages(prev => {
const newMessages = { ...prev };
delete newMessages[friendToDelete.id];
return newMessages;
});
setFriendToDelete(null);
}
};
// 好友请求处理
const handleAcceptRequest = (request: FriendRequest) => {
const newFriend: Friend = {
id: `friend-${Date.now()}`,
name: request.fromUser.name,
avatarUrl: request.fromUser.avatarUrl,
isOnline: false,
remark: '',
email:''
};
setFriends(prev => [newFriend, ...prev]);
setRequests(prev => prev.filter(req => req.id !== request.id));
};
const handleRejectRequest = (requestId: string) => {
setRequests(prev => prev.filter(req => req.id !== requestId));
};
// 分享记录处理
const handleViewShare = (share: QueryShare) => {
setViewingShare(share);
onMarkShareAsRead(share.id);
};
const handleRerunShare = (share: QueryShare) => {
onRerunQuery(share.querySnapshot.userPrompt);
};
const handleSaveShare = (share: QueryShare) => {
onSaveQuery(share.querySnapshot);
setActiveModal('saveSuccess');
};
const [shareToDelete, setShareToDelete] = useState<QueryShare | null>(null);
// 新增:备注功能相关函数
const handleOpenRemarkModal = (friend: Friend) => {
setCurrentRemarkFriend(friend);
setIsRemarkModalOpen(true);
};
const handleSaveRemark = () => {
if (currentRemarkFriend) {
setFriends(prev => prev.map(friend =>
friend.id === currentRemarkFriend.id
? { ...friend, remark: remarkInput.trim() }
: friend
));
setIsRemarkModalOpen(false);
setCurrentRemarkFriend(null);
}
};
// 新增:好友主页功能相关函数
const handleOpenProfileModal = (friend: Friend) => {
console.log('打开主页的好友数据:', friend);
setCurrentProfileFriend(friend);
setIsProfileModalOpen(true);
};
// 新增:处理分享删除请求(触发确认弹窗)
const handleDeleteShareRequest = (share: QueryShare) => {
setShareToDelete(share);
};
// 新增:确认删除分享
const handleConfirmDeleteShare = () => {
if (shareToDelete) {
onDeleteShare(shareToDelete.id);
setShareToDelete(null);
}
};
return (
<section className="p-6 flex flex-col space-y-6 overflow-y-auto bg-neutral h-full">
{/* 页面标题栏 */}
<div className="flex justify-between items-center flex-shrink-0">
<h2 className="text-2xl font-bold">
<i className="fa fa-users mr-2"></i>
</h2>
<div className="flex space-x-2">
<button
onClick={() => mockNewMessage('friend-2')}
className="px-3 py-1 bg-gray-200 rounded-lg text-xs"
>
2
</button>
<button
onClick={() => setAddFriendModalOpen(true)}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
<i className="fa fa-plus mr-2"></i>
</button>
</div>
</div>
{/* 标签栏 */}
<div className="border-b bg-white rounded-t-xl shadow-sm -mb-6 sticky top-0 z-10">
<div className="flex space-x-4 px-6">
<TabButton
tab="friends"
label="好友列表"
count={friends.length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<TabButton
tab="requests"
label="好友请求"
count={requests.length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<TabButton
tab="shares"
label="分享记录"
count={shares.filter(s => s.status === 'unread').length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
</div>
</div>
{/* 内容区域 */}
<div className="flex-grow pt-0">
{/* 1. 好友列表标签 */}
{activeTab === 'friends' && (
<div>
{/* 搜索框(支持搜索名称/备注) */}
<div className="mb-3">
<div className="relative w-full">
<i className="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="搜索好友(名称/备注)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{/* 好友列表 */}
{filteredFriends.length > 0 ? (
<div className="space-y-3">
{filteredFriends.map(friend => (
<FriendCard
key={friend.id}
friend={friend}
onChat={handleOpenChat}
onDelete={handleDeleteShareRequest}
onRemark={handleOpenRemarkModal} // 绑定备注按钮事件
onViewProfile={handleOpenProfileModal} // 绑定主页按钮事件
unreadCount={unreadMessages[friend.id] || 0}
/>
))}
</div>
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-user-times text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
{/* 2. 好友请求标签(原有不变) */}
{activeTab === 'requests' && (
<div className="space-y-3">
{requests.length > 0 ? (
requests.map(req => (
<RequestCard
key={req.id}
request={req}
onAccept={handleAcceptRequest}
onReject={handleRejectRequest}
/>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-inbox text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
{/* 3. 分享记录标签(原有不变) */}
{activeTab === 'shares' && (
<div className="space-y-3">
{shares.length > 0 ? (
shares.map(share => (
<ShareRecordCard
key={share.id}
share={share}
onView={() => handleViewShare(share)}
onRerun={() => handleRerunShare(share)}
onDelete={() => handleDeleteShareRequest(share)}
onSave={() => handleSaveShare(share)}
/>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-share-alt-square text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
</div>
{/* 原有模态框:添加好友 */}
<Modal isOpen={isAddFriendModalOpen} onClose={() => setAddFriendModalOpen(false)} title="添加好友">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
placeholder="输入好友的邮箱地址"
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setAddFriendModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={() => setAddFriendModalOpen(false)} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</Modal>
{/* 原有模态框:删除好友确认 */}
<Modal isOpen={!!friendToDelete} onClose={() => setFriendToDelete(null)} title="删除好友">
<p className="text-sm text-gray-700"> "{friendToDelete?.name}" </p>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setFriendToDelete(null)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleConfirmDelete} className="px-4 py-2 bg-danger text-white rounded-lg text-sm"></button>
</div>
</Modal>
{/* 原有模态框:聊天窗口 */}
{chattingWith && (
<ChatModal
friend={chattingWith}
isOpen={!!chattingWith}
onClose={() => setChattingWith(null)}
savedQueries={savedQueries}
currentUnreadCount={unreadMessages[chattingWith.id] || 0}
updateUnreadCount={updateUnreadCount}
messages={friendMessages[chattingWith.id] || []}
updateMessages={(newMessages) => updateFriendMessages(chattingWith.id, newMessages)}
/>
)}
{/* 原有模态框:查看分享结果 */}
<Modal isOpen={!!viewingShare} onClose={() => setViewingShare(null)} title={`查看分享: "${viewingShare?.querySnapshot.userPrompt}"`}>
{viewingShare && (
<div className="max-h-[70vh] overflow-y-auto -m-6 p-6">
<QueryResult result={viewingShare.querySnapshot} showActions={{ save: false, share: false, export: true }} />
</div>
)}
</Modal>
{/* 原有模态框:保存成功提示 */}
<Modal isOpen={activeModal === 'saveSuccess'} onClose={() => setActiveModal(null)} hideTitle>
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i className="fa fa-check text-green-500 text-3xl"></i>
</div>
<h3 className="text-xl font-bold text-gray-800 mb-2"></h3>
<p className="text-sm text-gray-500"></p>
<button onClick={() => setActiveModal(null)} className="mt-4 px-6 py-2 bg-primary text-white rounded-lg"></button>
</div>
</Modal>
{/* 新增:备注好友模态框 */}
<Modal isOpen={isRemarkModalOpen} onClose={() => setIsRemarkModalOpen(false)} title="备注好友">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{currentRemarkFriend?.name}
</label>
<input
type="text"
placeholder="输入备注(可选)"
value={remarkInput}
onChange={(e) => setRemarkInput(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
maxLength={20} // 限制备注长度
/>
<p className="text-xs text-gray-400 mt-1">20</p>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setIsRemarkModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleSaveRemark} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</Modal>
{/* 新增:好友主页模态框 */}
<Modal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} title="好友主页" hideTitle={false}>
{currentProfileFriend && (
<div className="space-y-6">
{/* 好友头像+基本信息 */}
<div className="text-center">
<div className="w-24 h-24 rounded-full mx-auto overflow-hidden border-4 border-primary/20">
<img
src={currentProfileFriend.avatarUrl}
alt={currentProfileFriend.name}
className="w-full h-full object-cover"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
</div>
<h3 className="text-xl font-bold mt-4">{currentProfileFriend.name}</h3>
{currentProfileFriend.remark && (
<p className="text-gray-500 italic">{currentProfileFriend.remark}</p>
)}
<p className="text-gray-600 mt-2 flex items-center justify-center">
<i className="fa fa-envelope-o mr-1 text-primary"></i>
{currentProfileFriend?.email || '未设置邮箱'}
</p>
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs ${
currentProfileFriend.isOnline
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{currentProfileFriend.isOnline ? '在线' : '离线'}
</span>
</div>
{/* 操作按钮 */}
<div className="flex flex-col space-y-2">
<button
onClick={() => {
setIsProfileModalOpen(false);
handleOpenChat(currentProfileFriend);
}}
className="w-full py-2 bg-primary text-white rounded-lg text-sm flex items-center justify-center"
>
<i className="fa fa-comment-o mr-2"></i>
</button>
<button
onClick={() => {
setIsProfileModalOpen(false);
handleOpenRemarkModal(currentProfileFriend);
}}
className="w-full py-2 border border-gray-300 rounded-lg text-sm flex items-center justify-center"
>
<i className="fa fa-pencil mr-2"></i>
</button>
</div>
</div>
)}
</Modal>
{/* 新增:删除分享确认模态框 */}
<Modal isOpen={!!shareToDelete} onClose={() => setShareToDelete(null)} title="删除分享">
<p className="text-sm text-gray-700">
"{shareToDelete?.sender.name}"
</p>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setShareToDelete(null)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleConfirmDeleteShare} className="px-4 py-2 bg-danger text-white rounded-lg text-sm"></button>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,776 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Friend, FriendRequest, QueryResultData, QueryShare } from '../types';
import { Modal } from './Modal';
import { ChatModal } from './ChatModal';
import { QueryResult } from './QueryResult';
import {
userApi,
friendRelationApi,
friendRequestApi,
friendChatApi,
queryShareApi,
userSearchApi
} from '../services/api';
interface Message {
id: string;
content: string;
isSent: boolean;
timestamp: Date;
isRead: boolean;
}
type ActiveTab = 'friends' | 'requests' | 'shares';
const FriendCard: React.FC<{
friend: Friend;
onChat: (friend: Friend) => void;
onDelete: (friend: Friend) => void;
onRemark: (friend: Friend) => void;
onViewProfile: (friend: Friend) => void;
unreadCount: number;
}> = ({ friend, onChat, onDelete, onRemark, onViewProfile, unreadCount }) => (
<div className="p-4 bg-white rounded-xl shadow-sm border flex items-center justify-between transition-shadow hover:shadow-md w-full">
<div className="flex items-center space-x-4">
<div className="relative">
<img
src={friend.avatarUrl}
alt={friend.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
{friend.isOnline && (
<span className="absolute bottom-0 right-0 block h-3 w-3 rounded-full bg-green-400 ring-2 ring-white"></span>
)}
</div>
<div>
<div className="flex items-center">
<p className="font-semibold text-dark">
{friend.remark || friend.name}
</p>
{unreadCount > 0 && (
<span className="ml-2 bg-primary text-white text-xs rounded-full px-2 py-0.5">
{unreadCount}
</span>
)}
</div>
{friend.remark && (
<p className="text-sm text-gray-500 italic">{friend.name}</p>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => onChat(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="聊天"
>
<i className="fa fa-comment-o"></i>
</button>
<button
onClick={() => onRemark(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="备注好友"
>
<i className="fa fa-pencil"></i>
</button>
<button
onClick={() => onViewProfile(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="查看好友主页"
>
<i className="fa fa-user-o"></i>
</button>
<button
onClick={() => onDelete(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-danger/10 hover:text-danger transition-colors"
title="删除好友"
>
<i className="fa fa-trash-o"></i>
</button>
</div>
</div>
);
const RequestCard: React.FC<{
request: FriendRequest;
onAccept: (request: FriendRequest) => void;
onReject: (requestId: string) => void;
}> = ({ request, onAccept, onReject }) => (
<div className="p-4 bg-white rounded-xl shadow-sm border flex items-center justify-between">
<div className="flex items-center space-x-4">
<img
src={request.fromUser.avatarUrl}
alt={request.fromUser.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
<div>
<p className="font-semibold text-dark">{request.fromUser.name}</p>
<p className="text-sm text-gray-500">{request.timestamp}</p>
</div>
</div>
<div className="flex space-x-2">
<button onClick={() => onAccept(request)} className="px-3 py-1 bg-primary text-white rounded-lg text-sm"></button>
<button onClick={() => onReject(request.id)} className="px-3 py-1 bg-gray-200 rounded-lg text-sm"></button>
</div>
</div>
);
const ShareRecordCard: React.FC<{
share: QueryShare;
onView: () => void;
onRerun: () => void;
onDelete: () => void;
onSave: () => void;
}> = ({ share, onView, onRerun, onDelete, onSave }) => (
<div className={`p-4 rounded-xl shadow-sm border flex items-start justify-between transition-shadow hover:shadow-md ${
share.status === 'unread' ? 'bg-primary/5' : 'bg-white'
}`}>
<div className="flex items-start space-x-4">
<img
src={share.sender.avatarUrl}
alt={share.sender.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
<div>
<p className="font-semibold text-dark font-bold">{share.sender.name}<span className="text-xs text-gray-500 font-normal ml-1">:</span> </p>
<p className="text-md text-primary text-sm mt-1">"{share.querySnapshot.userPrompt}"</p>
<p className="text-xs text-gray-400 mt-2">{new Date(share.timestamp).toLocaleString()}</p>
</div>
</div>
<div className="flex flex-col space-y-2 items-end">
<div className="flex space-x-2">
<button onClick={onSave} className="px-3 py-1 border border-gray-300 rounded-lg text-xs hover:bg-gray-50">
<i className="fa fa-save mr-1"></i>
</button>
<button onClick={onView} className="px-3 py-1 border border-gray-300 rounded-lg text-xs hover:bg-gray-50">
<i className="fa fa-eye mr-1"></i>
</button>
<button onClick={onRerun} className="px-3 py-1 bg-primary text-white rounded-lg text-xs hover:shadow-md">
<i className="fa fa-paper-plane mr-1"></i>
</button>
<button onClick={onDelete} className="px-3 py-1 bg-red-500 hover:bg-red-600 text-white rounded-lg text-xs transition-colors">
<i className="fa fa-trash-o mr-1"></i>
</button>
</div>
</div>
</div>
);
const TabButton: React.FC<{
tab: ActiveTab;
label: string;
count?: number;
activeTab: ActiveTab;
onTabChange: (tab: ActiveTab) => void;
}> = ({ tab, label, count, activeTab, onTabChange }) => (
<button
onClick={() => onTabChange(tab)}
className={`py-2 px-4 text-sm font-medium transition-colors duration-200 relative ${
activeTab === tab ? 'border-b-2 border-primary text-primary' : 'text-gray-500 hover:text-dark'
}`}
>
{label}
{count !== undefined && count > 0 && (
<span className="ml-1 text-xs">({count})</span>
)}
</button>
);
interface FriendsPageProps {
savedQueries: QueryResultData[];
shares: QueryShare[];
onMarkShareAsRead: (shareId: string) => void;
onDeleteShare: (shareId: string) => void;
onRerunQuery: (prompt: string) => void;
onSaveQuery: (query: QueryResultData) => void;
}
export const FriendsPageWithAPI: React.FC<FriendsPageProps> = ({
savedQueries, shares, onMarkShareAsRead,
onDeleteShare, onRerunQuery, onSaveQuery
}) => {
const userId = Number(sessionStorage.getItem('userId') || '1');
const [friends, setFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<FriendRequest[]>([]);
const [isAddFriendModalOpen, setAddFriendModalOpen] = useState(false);
const [chattingWith, setChattingWith] = useState<Friend | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [friendToDelete, setFriendToDelete] = useState<Friend | null>(null);
const [activeTab, setActiveTab] = useState<ActiveTab>('friends');
const [viewingShare, setViewingShare] = useState<QueryShare | null>(null);
const [activeModal, setActiveModal] = useState<string | null>(null);
const [isRemarkModalOpen, setIsRemarkModalOpen] = useState(false);
const [currentRemarkFriend, setCurrentRemarkFriend] = useState<Friend | null>(null);
const [remarkInput, setRemarkInput] = useState('');
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [currentProfileFriend, setCurrentProfileFriend] = useState<Friend | null>(null);
const [unreadMessages, setUnreadMessages] = useState<Record<string, number>>({});
const [friendMessages, setFriendMessages] = useState<Record<string, Message[]>>({});
const [emailSearch, setEmailSearch] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
loadFriends();
loadFriendRequests();
}, []);
const loadFriends = async () => {
try {
const relations = await friendRelationApi.getByUser(userId);
const friendsData = await Promise.all(
relations.map(async (rel) => {
const user = await userApi.getById(rel.friendId);
return {
id: String(rel.friendId),
name: user.username,
email: user.email,
avatarUrl: user.avatarUrl || '/default-avatar.png',
isOnline: rel.onlineStatus === 1,
remark: rel.remark || '',
} as Friend;
})
);
setFriends(friendsData);
friendsData.forEach(async (friend) => {
const unread = await friendChatApi.getUnreadByFriend(Number(friend.id));
if (unread.length > 0) {
setUnreadMessages(prev => ({ ...prev, [friend.id]: unread.length }));
}
});
} catch (error) {
console.error('加载好友列表失败:', error);
}
};
const loadFriendRequests = async () => {
try {
const reqs = await friendRequestApi.getByRecipientAndStatus(userId, 0);
const requestsData = await Promise.all(
reqs.map(async (req) => {
const user = await userApi.getById(req.sendUserId);
return {
id: String(req.id),
fromUser: {
id: String(user.id),
name: user.username,
avatarUrl: user.avatarUrl || '/default-avatar.png',
},
timestamp: new Date(req.createTime).toLocaleString(),
} as FriendRequest;
})
);
setRequests(requestsData);
} catch (error) {
console.error('加载好友请求失败:', error);
}
};
const handleSearchFriend = async () => {
if (!emailSearch.trim()) return;
setIsSearching(true);
try {
const results = await userSearchApi.searchByEmail(emailSearch);
setSearchResults(results.filter(u => u.id !== userId));
} catch (error) {
console.error('搜索用户失败:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
};
const handleSendFriendRequest = async (targetUserId: number) => {
try {
await friendRequestApi.create({
sendUserId: userId,
recipientId: targetUserId,
status: 0,
});
alert('好友请求已发送');
setSearchResults([]);
setEmailSearch('');
} catch (error) {
console.error('发送好友请求失败:', error);
alert('发送失败,请稍后重试');
}
};
useEffect(() => {
if (currentRemarkFriend) {
setRemarkInput(currentRemarkFriend.remark || '');
}
}, [currentRemarkFriend]);
const updateUnreadCount = (friendId: string, count: number) => {
setUnreadMessages(prev => ({
...prev,
[friendId]: count
}));
};
const updateFriendMessages = (friendId: string, newMessages: Message[]) => {
setFriendMessages(prev => ({
...prev,
[friendId]: newMessages
}));
};
const handleOpenChat = async (friend: Friend) => {
setChattingWith(friend);
try {
const chats = await friendChatApi.getByUserAndFriend(userId, Number(friend.id));
const messages: Message[] = chats.map(chat => ({
id: chat.id,
content: chat.content,
isSent: chat.sendUserId === userId,
timestamp: new Date(chat.sendTime),
isRead: chat.isRead,
}));
setFriendMessages(prev => ({
...prev,
[friend.id]: messages
}));
} catch (error) {
console.error('加载聊天记录失败:', error);
}
};
const filteredFriends = useMemo(() =>
friends.filter(friend =>
friend.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(friend.remark && friend.remark.toLowerCase().includes(searchTerm.toLowerCase()))
),
[friends, searchTerm]
);
const handleDeleteRequest = (friend: Friend) => setFriendToDelete(friend);
const handleConfirmDelete = async () => {
if (friendToDelete) {
try {
await friendRelationApi.deleteByUserAndFriend(userId, Number(friendToDelete.id));
setFriends(prev => prev.filter(f => f.id !== friendToDelete.id));
setFriendToDelete(null);
} catch (error) {
console.error('删除好友失败:', error);
alert('删除失败,请稍后重试');
}
}
};
const handleAcceptRequest = async (request: FriendRequest) => {
try {
await friendRequestApi.update({
id: Number(request.id),
status: 1,
});
await friendRelationApi.create({
userId: userId,
friendId: Number(request.fromUser.id),
onlineStatus: 0,
});
await friendRelationApi.create({
userId: Number(request.fromUser.id),
friendId: userId,
onlineStatus: 0,
});
setRequests(prev => prev.filter(req => req.id !== request.id));
loadFriends();
} catch (error) {
console.error('接受好友请求失败:', error);
alert('操作失败,请稍后重试');
}
};
const handleRejectRequest = async (requestId: string) => {
try {
await friendRequestApi.update({
id: Number(requestId),
status: 2,
});
setRequests(prev => prev.filter(req => req.id !== requestId));
} catch (error) {
console.error('拒绝好友请求失败:', error);
alert('操作失败,请稍后重试');
}
};
const handleViewShare = (share: QueryShare) => {
setViewingShare(share);
onMarkShareAsRead(share.id);
};
const handleRerunShare = (share: QueryShare) => {
onRerunQuery(share.querySnapshot.userPrompt);
};
const handleSaveShare = (share: QueryShare) => {
onSaveQuery(share.querySnapshot);
setActiveModal('saveSuccess');
};
const [shareToDelete, setShareToDelete] = useState<QueryShare | null>(null);
const handleOpenRemarkModal = (friend: Friend) => {
setCurrentRemarkFriend(friend);
setIsRemarkModalOpen(true);
};
const handleSaveRemark = async () => {
if (currentRemarkFriend) {
try {
const relation = await friendRelationApi.getByUserAndFriend(userId, Number(currentRemarkFriend.id));
await friendRelationApi.update({
id: relation.id,
remark: remarkInput.trim(),
});
setFriends(prev => prev.map(friend =>
friend.id === currentRemarkFriend.id
? { ...friend, remark: remarkInput.trim() }
: friend
));
setIsRemarkModalOpen(false);
setCurrentRemarkFriend(null);
} catch (error) {
console.error('保存备注失败:', error);
alert('保存失败,请稍后重试');
}
}
};
const handleOpenProfileModal = (friend: Friend) => {
setCurrentProfileFriend(friend);
setIsProfileModalOpen(true);
};
const handleDeleteShareRequest = (share: QueryShare) => {
setShareToDelete(share);
};
const handleConfirmDeleteShare = () => {
if (shareToDelete) {
onDeleteShare(shareToDelete.id);
setShareToDelete(null);
}
};
return (
<section className="p-6 flex flex-col space-y-6 overflow-y-auto bg-neutral h-full">
<div className="flex justify-between items-center flex-shrink-0">
<h2 className="text-2xl font-bold">
<i className="fa fa-users mr-2"></i>
</h2>
<div className="flex space-x-2">
<button
onClick={() => setAddFriendModalOpen(true)}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
<i className="fa fa-plus mr-2"></i>
</button>
</div>
</div>
<div className="border-b bg-white rounded-t-xl shadow-sm -mb-6 sticky top-0 z-10">
<div className="flex space-x-4 px-6">
<TabButton
tab="friends"
label="好友列表"
count={friends.length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<TabButton
tab="requests"
label="好友请求"
count={requests.length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<TabButton
tab="shares"
label="分享记录"
count={shares.filter(s => s.status === 'unread').length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
</div>
</div>
<div className="flex-grow pt-0">
{activeTab === 'friends' && (
<div>
<div className="mb-3">
<div className="relative w-full">
<i className="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="搜索好友(名称/备注)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{filteredFriends.length > 0 ? (
<div className="space-y-3">
{filteredFriends.map(friend => (
<FriendCard
key={friend.id}
friend={friend}
onChat={handleOpenChat}
onDelete={handleDeleteRequest}
onRemark={handleOpenRemarkModal}
onViewProfile={handleOpenProfileModal}
unreadCount={unreadMessages[friend.id] || 0}
/>
))}
</div>
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-user-times text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
{activeTab === 'requests' && (
<div className="space-y-3">
{requests.length > 0 ? (
requests.map(req => (
<RequestCard
key={req.id}
request={req}
onAccept={handleAcceptRequest}
onReject={handleRejectRequest}
/>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-inbox text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
{activeTab === 'shares' && (
<div className="space-y-3">
{shares.length > 0 ? (
shares.map(share => (
<ShareRecordCard
key={share.id}
share={share}
onView={() => handleViewShare(share)}
onRerun={() => handleRerunShare(share)}
onDelete={() => handleDeleteShareRequest(share)}
onSave={() => handleSaveShare(share)}
/>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-share-alt-square text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
</div>
<Modal isOpen={isAddFriendModalOpen} onClose={() => { setAddFriendModalOpen(false); setSearchResults([]); setEmailSearch(''); }} title="添加好友">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="flex space-x-2">
<input
type="email"
placeholder="输入好友的邮箱地址"
value={emailSearch}
onChange={(e) => setEmailSearch(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg"
/>
<button
onClick={handleSearchFriend}
disabled={isSearching}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm disabled:bg-gray-300"
>
{isSearching ? '搜索中...' : '搜索'}
</button>
</div>
</div>
{searchResults.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700"></p>
{searchResults.map(user => (
<div key={user.id} className="flex items-center justify-between p-2 border rounded">
<div className="flex items-center space-x-2">
<img src={user.avatarUrl || '/default-avatar.png'} alt={user.username} className="w-8 h-8 rounded-full" />
<div>
<p className="font-medium">{user.username}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
</div>
<button
onClick={() => handleSendFriendRequest(user.id)}
className="px-3 py-1 bg-primary text-white rounded text-sm"
>
</button>
</div>
))}
</div>
)}
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => { setAddFriendModalOpen(false); setSearchResults([]); setEmailSearch(''); }} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
</div>
</Modal>
<Modal isOpen={!!friendToDelete} onClose={() => setFriendToDelete(null)} title="删除好友">
<p className="text-sm text-gray-700"> "{friendToDelete?.name}" </p>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setFriendToDelete(null)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleConfirmDelete} className="px-4 py-2 bg-danger text-white rounded-lg text-sm"></button>
</div>
</Modal>
{chattingWith && (
<ChatModal
friend={chattingWith}
isOpen={!!chattingWith}
onClose={() => setChattingWith(null)}
savedQueries={savedQueries}
currentUnreadCount={unreadMessages[chattingWith.id] || 0}
updateUnreadCount={updateUnreadCount}
messages={friendMessages[chattingWith.id] || []}
updateMessages={(newMessages) => updateFriendMessages(chattingWith.id, newMessages)}
/>
)}
<Modal isOpen={!!viewingShare} onClose={() => setViewingShare(null)} title={`查看分享: "${viewingShare?.querySnapshot.userPrompt}"`}>
{viewingShare && (
<div className="max-h-[70vh] overflow-y-auto -m-6 p-6">
<QueryResult result={viewingShare.querySnapshot} showActions={{ save: false, share: false, export: true }} />
</div>
)}
</Modal>
<Modal isOpen={activeModal === 'saveSuccess'} onClose={() => setActiveModal(null)} hideTitle>
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i className="fa fa-check text-green-500 text-3xl"></i>
</div>
<h3 className="text-xl font-bold text-gray-800 mb-2"></h3>
<p className="text-sm text-gray-500"></p>
<button onClick={() => setActiveModal(null)} className="mt-4 px-6 py-2 bg-primary text-white rounded-lg"></button>
</div>
</Modal>
<Modal isOpen={isRemarkModalOpen} onClose={() => setIsRemarkModalOpen(false)} title="备注好友">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{currentRemarkFriend?.name}
</label>
<input
type="text"
placeholder="输入备注(可选)"
value={remarkInput}
onChange={(e) => setRemarkInput(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
maxLength={20}
/>
<p className="text-xs text-gray-400 mt-1">20</p>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setIsRemarkModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleSaveRemark} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</Modal>
<Modal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} title="好友主页" hideTitle={false}>
{currentProfileFriend && (
<div className="space-y-6">
<div className="text-center">
<div className="w-24 h-24 rounded-full mx-auto overflow-hidden border-4 border-primary/20">
<img
src={currentProfileFriend.avatarUrl}
alt={currentProfileFriend.name}
className="w-full h-full object-cover"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
</div>
<h3 className="text-xl font-bold mt-4">{currentProfileFriend.name}</h3>
{currentProfileFriend.remark && (
<p className="text-gray-500 italic">{currentProfileFriend.remark}</p>
)}
<p className="text-gray-600 mt-2 flex items-center justify-center">
<i className="fa fa-envelope-o mr-1 text-primary"></i>
{currentProfileFriend?.email || '未设置邮箱'}
</p>
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs ${
currentProfileFriend.isOnline
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{currentProfileFriend.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex flex-col space-y-2">
<button
onClick={() => {
setIsProfileModalOpen(false);
handleOpenChat(currentProfileFriend);
}}
className="w-full py-2 bg-primary text-white rounded-lg text-sm flex items-center justify-center"
>
<i className="fa fa-comment-o mr-2"></i>
</button>
<button
onClick={() => {
setIsProfileModalOpen(false);
handleOpenRemarkModal(currentProfileFriend);
}}
className="w-full py-2 border border-gray-300 rounded-lg text-sm flex items-center justify-center"
>
<i className="fa fa-pencil mr-2"></i>
</button>
</div>
</div>
)}
</Modal>
<Modal isOpen={!!shareToDelete} onClose={() => setShareToDelete(null)} title="删除分享">
<p className="text-sm text-gray-700">
"{shareToDelete?.sender.name}"
</p>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setShareToDelete(null)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleConfirmDeleteShare} className="px-4 py-2 bg-danger text-white rounded-lg text-sm"></button>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,883 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Friend, FriendRequest as FriendRequestType, QueryResultData, QueryShare } from '../types';
import { Modal } from './Modal';
import { ChatModal } from './ChatModal';
import { QueryResult } from './QueryResult';
import { friendRelationApi, friendRequestApi, userApi, userSearchApi } from '../services/api';
// Message接口定义
interface Message {
id: string;
content: string;
isSent: boolean;
timestamp: Date;
isRead: boolean;
}
// 标签类型定义
type ActiveTab = 'friends' | 'requests' | 'shares';
// 1. 好友卡片组件
const FriendCard: React.FC<{
friend: Friend;
onChat: (friend: Friend) => void;
onDelete: (friend: Friend) => void;
onRemark: (friend: Friend) => void;
onViewProfile: (friend: Friend) => void;
unreadCount: number;
}> = ({ friend, onChat, onDelete, onRemark, onViewProfile, unreadCount }) => (
<div className="p-4 bg-white rounded-xl shadow-sm border flex items-center justify-between transition-shadow hover:shadow-md w-full">
<div className="flex items-center space-x-4">
<div className="relative">
<img
src={friend.avatarUrl}
alt={friend.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
{friend.isOnline && (
<span className="absolute bottom-0 right-0 block h-3 w-3 rounded-full bg-green-400 ring-2 ring-white"></span>
)}
</div>
<div>
<div className="flex items-center">
<p className="font-semibold text-dark">
{friend.remark || friend.name}
</p>
{unreadCount > 0 && (
<span className="ml-2 bg-primary text-white text-xs rounded-full px-2 py-0.5">
{unreadCount}
</span>
)}
</div>
{friend.remark && (
<p className="text-sm text-gray-500 italic">{friend.name}</p>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => onChat(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="聊天"
>
<i className="fa fa-comment-o"></i>
</button>
<button
onClick={() => onRemark(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="备注好友"
>
<i className="fa fa-pencil"></i>
</button>
<button
onClick={() => onViewProfile(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-primary/10 hover:text-primary transition-colors"
title="查看好友主页"
>
<i className="fa fa-user-o"></i>
</button>
<button
onClick={() => onDelete(friend)}
className="p-2 w-8 h-8 flex items-center justify-center bg-gray-100 text-gray-600 rounded-full text-sm hover:bg-danger/10 hover:text-danger transition-colors"
title="删除好友"
>
<i className="fa fa-trash-o"></i>
</button>
</div>
</div>
);
// 2. 好友请求卡片组件
const RequestCard: React.FC<{
request: FriendRequestType;
onAccept: (request: FriendRequestType) => void;
onReject: (requestId: string) => void;
}> = ({ request, onAccept, onReject }) => (
<div className="p-4 bg-white rounded-xl shadow-sm border flex items-center justify-between">
<div className="flex items-center space-x-4">
<img
src={request.fromUser.avatarUrl}
alt={request.fromUser.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
<div>
<p className="font-semibold text-dark">{request.fromUser.name}</p>
<p className="text-sm text-gray-500">{request.timestamp}</p>
</div>
</div>
<div className="flex space-x-2">
<button onClick={() => onAccept(request)} className="px-3 py-1 bg-primary text-white rounded-lg text-sm"></button>
<button onClick={() => onReject(request.id)} className="px-3 py-1 bg-gray-200 rounded-lg text-sm"></button>
</div>
</div>
);
// 3. 分享记录卡片组件
const ShareRecordCard: React.FC<{
share: QueryShare;
onView: () => void;
onRerun: () => void;
onDelete: () => void;
onSave: () => void;
}> = ({ share, onView, onRerun, onDelete, onSave }) => (
<div className={`p-4 rounded-xl shadow-sm border flex items-start justify-between transition-shadow hover:shadow-md ${
share.status === 'unread' ? 'bg-primary/5' : 'bg-white'
}`}>
<div className="flex items-start space-x-4">
<img
src={share.sender.avatarUrl}
alt={share.sender.name}
className="w-12 h-12 rounded-full"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
<div>
<p className="font-semibold text-dark font-bold">{share.sender.name}<span className="text-xs text-gray-500 font-normal ml-1">:</span> </p>
<p className="text-md text-primary text-sm mt-1">"{share.querySnapshot.userPrompt}"</p>
<p className="text-xs text-gray-400 mt-2">{new Date(share.timestamp).toLocaleString()}</p>
</div>
</div>
<div className="flex flex-col space-y-2 items-end">
<div className="flex space-x-2">
<button onClick={onSave} className="px-3 py-1 border border-gray-300 rounded-lg text-xs hover:bg-gray-50">
<i className="fa fa-save mr-1"></i>
</button>
<button onClick={onView} className="px-3 py-1 border border-gray-300 rounded-lg text-xs hover:bg-gray-50">
<i className="fa fa-eye mr-1"></i>
</button>
<button onClick={onRerun} className="px-3 py-1 bg-primary text-white rounded-lg text-xs hover:shadow-md">
<i className="fa fa-paper-plane mr-1"></i>
</button>
<button onClick={onDelete} className="px-3 py-1 bg-red-500 hover:bg-red-600 text-white rounded-lg text-xs transition-colors">
<i className="fa fa-trash-o mr-1"></i>
</button>
</div>
</div>
</div>
);
// 4. 标签按钮组件
const TabButton: React.FC<{
tab: ActiveTab;
label: string;
count?: number;
activeTab: ActiveTab;
onTabChange: (tab: ActiveTab) => void;
}> = ({ tab, label, count, activeTab, onTabChange }) => (
<button
onClick={() => onTabChange(tab)}
className={`py-2 px-4 text-sm font-medium transition-colors duration-200 relative ${
activeTab === tab ? 'border-b-2 border-primary text-primary' : 'text-gray-500 hover:text-dark'
}`}
>
{label}
{count !== undefined && count > 0 && (
<span className="ml-1 text-xs">({count})</span>
)}
</button>
);
// 页面Props定义
interface FriendsPageProps {
savedQueries: QueryResultData[];
shares: QueryShare[];
onMarkShareAsRead: (shareId: string) => void;
onDeleteShare: (shareId: string) => void;
onRerunQuery: (prompt: string) => void;
onSaveQuery: (query: QueryResultData) => void;
}
export const FriendsPageWithRealAPI: React.FC<FriendsPageProps> = ({
savedQueries, shares, onMarkShareAsRead,
onDeleteShare, onRerunQuery, onSaveQuery
}) => {
// 页面状态
const [friends, setFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<FriendRequestType[]>([]);
const [loading, setLoading] = useState(true);
const [isAddFriendModalOpen, setAddFriendModalOpen] = useState(false);
const [chattingWith, setChattingWith] = useState<Friend | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [friendToDelete, setFriendToDelete] = useState<Friend | null>(null);
const [activeTab, setActiveTab] = useState<ActiveTab>('friends');
const [viewingShare, setViewingShare] = useState<QueryShare | null>(null);
const [activeModal, setActiveModal] = useState<string | null>(null);
// 备注相关状态
const [isRemarkModalOpen, setIsRemarkModalOpen] = useState(false);
const [currentRemarkFriend, setCurrentRemarkFriend] = useState<Friend | null>(null);
const [remarkInput, setRemarkInput] = useState('');
// 好友主页相关状态
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [currentProfileFriend, setCurrentProfileFriend] = useState<Friend | null>(null);
// 添加好友相关状态
const [searchKey, setSearchKey] = useState('');
const [searchType, setSearchType] = useState<'email' | 'phone'>('email');
const [searchResult, setSearchResult] = useState<any>(null);
const [isSearching, setIsSearching] = useState(false);
// 未读消息状态
const [unreadMessages, setUnreadMessages] = useState<Record<string, number>>({});
// 聊天记录状态
const [friendMessages, setFriendMessages] = useState<Record<string, Message[]>>({});
// 加载好友列表
const loadFriends = async () => {
try {
setLoading(true);
const userId = Number(sessionStorage.getItem('userId') || '1');
const relations = await friendRelationApi.getByUser(userId);
// 获取每个好友的详细信息
const friendsData = await Promise.all(
relations.map(async (relation) => {
const friendUser = await userApi.getById(relation.friendId);
return {
id: String(relation.id),
name: friendUser.username,
avatarUrl: friendUser.avatarUrl || '/default-avatar.png',
isOnline: friendUser.status === 1,
remark: relation.remarkName || '',
email: friendUser.email,
relationId: relation.id,
friendId: relation.friendId
} as Friend;
})
);
setFriends(friendsData);
} catch (error) {
console.error('加载好友列表失败:', error);
alert('加载好友列表失败');
} finally {
setLoading(false);
}
};
// 加载好友请求
const loadRequests = async () => {
try {
const userId = Number(sessionStorage.getItem('userId') || '1');
const requestsData = await friendRequestApi.getByRecipientAndStatus(userId, 0);
// 获取每个申请人的详细信息
const requestsWithUser = await Promise.all(
requestsData.map(async (req) => {
const applicantUser = await userApi.getById(req.applicantId);
return {
id: String(req.id),
fromUser: {
id: String(applicantUser.id),
name: applicantUser.username,
avatarUrl: applicantUser.avatarUrl || '/default-avatar.png',
},
timestamp: new Date(req.createTime).toLocaleString(),
requestId: req.id
} as FriendRequestType;
})
);
setRequests(requestsWithUser);
} catch (error) {
console.error('加载好友请求失败:', error);
}
};
// 初始化加载
useEffect(() => {
loadFriends();
loadRequests();
// sessionStorage是每个标签页独立的不需要监听storage事件
}, []);
// 打开备注弹窗时初始化输入值
useEffect(() => {
if (currentRemarkFriend) {
setRemarkInput(currentRemarkFriend.remark || '');
}
}, [currentRemarkFriend]);
// 更新未读消息数量
const updateUnreadCount = (friendId: string, count: number) => {
setUnreadMessages(prev => ({
...prev,
[friendId]: count
}));
};
// 更新聊天记录
const updateFriendMessages = (friendId: string, newMessages: Message[]) => {
setFriendMessages(prev => ({
...prev,
[friendId]: newMessages
}));
};
// 打开聊天
const handleOpenChat = (friend: Friend) => {
setChattingWith(friend);
if (!friendMessages[friend.id]) {
setFriendMessages(prev => ({
...prev,
[friend.id]: []
}));
}
};
// 筛选好友
const filteredFriends = useMemo(() =>
friends.filter(friend =>
friend.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(friend.remark && friend.remark.toLowerCase().includes(searchTerm.toLowerCase()))
),
[friends, searchTerm]
);
// 搜索用户
const handleSearchUser = async () => {
if (!searchKey.trim()) {
alert('请输入邮箱或手机号');
return;
}
setIsSearching(true);
try {
if (searchType === 'email') {
const users = await userSearchApi.searchByEmail(searchKey);
if (users && users.length > 0) {
setSearchResult(users[0]);
} else {
alert('未找到该用户');
setSearchResult(null);
}
} else {
const user = await userSearchApi.searchByPhoneNumber(searchKey);
setSearchResult(user);
}
} catch (error) {
console.error('搜索用户失败:', error);
alert(error instanceof Error ? error.message : '搜索失败');
setSearchResult(null);
} finally {
setIsSearching(false);
}
};
// 发送好友请求
const handleSendFriendRequest = async () => {
if (!searchResult) {
alert('请先搜索用户');
return;
}
try {
const userId = Number(sessionStorage.getItem('userId') || '1');
await friendRequestApi.create({
applicantId: userId,
recipientId: searchResult.id,
applyMsg: '你好,我想加你为好友',
status: 0
});
alert('好友请求已发送');
setAddFriendModalOpen(false);
setSearchKey('');
setSearchResult(null);
} catch (error) {
console.error('发送好友请求失败:', error);
alert('发送好友请求失败');
}
};
// 删除好友逻辑
const handleDeleteRequest = (friend: Friend) => setFriendToDelete(friend);
const handleConfirmDelete = async () => {
if (friendToDelete) {
try {
const userId = Number(sessionStorage.getItem('userId') || '1');
// 删除好友关系
await friendRelationApi.deleteByUserAndFriend(userId, (friendToDelete as any).friendId);
// 更新本地状态
setFriends(prev => prev.filter(f => f.id !== friendToDelete.id));
setUnreadMessages(prev => {
const newUnread = { ...prev };
delete newUnread[friendToDelete.id];
return newUnread;
});
setFriendMessages(prev => {
const newMessages = { ...prev };
delete newMessages[friendToDelete.id];
return newMessages;
});
setFriendToDelete(null);
alert('已删除好友');
} catch (error) {
console.error('删除好友失败:', error);
alert('删除好友失败');
}
}
};
// 好友请求处理
const handleAcceptRequest = async (request: FriendRequestType) => {
try {
await friendRequestApi.accept((request as any).requestId);
alert('已接受好友请求');
// 重新加载好友列表和请求列表
await loadFriends();
await loadRequests();
} catch (error) {
console.error('接受好友请求失败:', error);
alert(error instanceof Error ? error.message : '接受好友请求失败');
}
};
const handleRejectRequest = async (requestId: string) => {
try {
await friendRequestApi.reject(Number(requestId));
alert('已拒绝好友请求');
// 重新加载请求列表
await loadRequests();
} catch (error) {
console.error('拒绝好友请求失败:', error);
alert('拒绝好友请求失败');
}
};
// 分享记录处理
const handleViewShare = (share: QueryShare) => {
setViewingShare(share);
onMarkShareAsRead(share.id);
};
const handleRerunShare = (share: QueryShare) => {
onRerunQuery(share.querySnapshot.userPrompt);
};
const handleSaveShare = (share: QueryShare) => {
onSaveQuery(share.querySnapshot);
setActiveModal('saveSuccess');
};
const [shareToDelete, setShareToDelete] = useState<QueryShare | null>(null);
// 备注功能相关函数
const handleOpenRemarkModal = (friend: Friend) => {
setCurrentRemarkFriend(friend);
setIsRemarkModalOpen(true);
};
const handleSaveRemark = async () => {
if (currentRemarkFriend) {
try {
// 更新好友备注
await friendRelationApi.update({
id: (currentRemarkFriend as any).relationId,
remarkName: remarkInput.trim()
});
// 更新本地状态
setFriends(prev => prev.map(friend =>
friend.id === currentRemarkFriend.id
? { ...friend, remark: remarkInput.trim() }
: friend
));
setIsRemarkModalOpen(false);
setCurrentRemarkFriend(null);
alert('备注已保存');
} catch (error) {
console.error('保存备注失败:', error);
alert('保存备注失败');
}
}
};
// 好友主页功能相关函数
const handleOpenProfileModal = (friend: Friend) => {
setCurrentProfileFriend(friend);
setIsProfileModalOpen(true);
};
// 处理分享删除请求
const handleDeleteShareRequest = (share: QueryShare) => {
setShareToDelete(share);
};
// 确认删除分享
const handleConfirmDeleteShare = () => {
if (shareToDelete) {
onDeleteShare(shareToDelete.id);
setShareToDelete(null);
}
};
if (loading) {
return (
<section className="p-6 flex justify-center items-center h-full">
<div className="text-gray-500">...</div>
</section>
);
}
return (
<section className="p-6 flex flex-col space-y-6 overflow-y-auto bg-neutral h-full">
{/* 页面标题栏 */}
<div className="flex justify-between items-center flex-shrink-0">
<h2 className="text-2xl font-bold"></h2>
<div className="flex space-x-2">
<button
onClick={() => setAddFriendModalOpen(true)}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
<i className="fa fa-plus mr-2"></i>
</button>
</div>
</div>
{/* 标签栏 */}
<div className="border-b bg-white rounded-t-xl shadow-sm -mb-6 sticky top-0 z-10">
<div className="flex space-x-4 px-6">
<TabButton
tab="friends"
label="好友列表"
count={friends.length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<TabButton
tab="requests"
label="好友请求"
count={requests.length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<TabButton
tab="shares"
label="分享记录"
count={shares.filter(s => s.status === 'unread').length}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
</div>
</div>
{/* 内容区域 */}
<div className="flex-grow pt-0">
{/* 1. 好友列表标签 */}
{activeTab === 'friends' && (
<div>
{/* 搜索框 */}
<div className="mb-3">
<div className="relative w-full">
<i className="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="搜索好友(名称/备注)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{/* 好友列表 */}
{filteredFriends.length > 0 ? (
<div className="space-y-3">
{filteredFriends.map(friend => (
<FriendCard
key={friend.id}
friend={friend}
onChat={handleOpenChat}
onDelete={handleDeleteRequest}
onRemark={handleOpenRemarkModal}
onViewProfile={handleOpenProfileModal}
unreadCount={unreadMessages[friend.id] || 0}
/>
))}
</div>
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-user-times text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
{/* 2. 好友请求标签 */}
{activeTab === 'requests' && (
<div className="space-y-3">
{requests.length > 0 ? (
requests.map(req => (
<RequestCard
key={req.id}
request={req}
onAccept={handleAcceptRequest}
onReject={handleRejectRequest}
/>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-inbox text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
{/* 3. 分享记录标签 */}
{activeTab === 'shares' && (
<div className="space-y-3">
{shares.length > 0 ? (
shares.map(share => (
<ShareRecordCard
key={share.id}
share={share}
onView={() => handleViewShare(share)}
onRerun={() => handleRerunShare(share)}
onDelete={() => handleDeleteShareRequest(share)}
onSave={() => handleSaveShare(share)}
/>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-share-alt-square text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
)}
</div>
{/* 添加好友模态框 */}
<Modal isOpen={isAddFriendModalOpen} onClose={() => {
setAddFriendModalOpen(false);
setSearchKey('');
setSearchResult(null);
}} title="添加好友">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<div className="flex space-x-4">
<label className="flex items-center cursor-pointer">
<input
type="radio"
name="searchType"
value="email"
checked={searchType === 'email'}
onChange={(e) => setSearchType('email')}
className="mr-2"
/>
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
name="searchType"
value="phone"
checked={searchType === 'phone'}
onChange={(e) => setSearchType('phone')}
className="mr-2"
/>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{searchType === 'email' ? '用户邮箱' : '手机号码'}
</label>
<div className="flex space-x-2">
<input
type={searchType === 'email' ? 'email' : 'tel'}
placeholder={searchType === 'email' ? '输入好友的邮箱地址' : '输入好友的手机号'}
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg"
/>
<button
onClick={handleSearchUser}
disabled={isSearching}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md disabled:opacity-50"
>
{isSearching ? '搜索中...' : '搜索'}
</button>
</div>
</div>
{searchResult && (
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<img
src={searchResult.avatarUrl || '/default-avatar.png'}
alt={searchResult.username}
className="w-12 h-12 rounded-full"
/>
<div>
<p className="font-semibold">{searchResult.username}</p>
<p className="text-sm text-gray-500">{searchResult.email}</p>
</div>
</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => {
setAddFriendModalOpen(false);
setSearchKey('');
setSearchResult(null);
}}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm"
>
</button>
<button
onClick={handleSendFriendRequest}
disabled={!searchResult}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm disabled:opacity-50"
>
</button>
</div>
</Modal>
{/* 删除好友确认模态框 */}
<Modal isOpen={!!friendToDelete} onClose={() => setFriendToDelete(null)} title="删除好友">
<p className="text-sm text-gray-700"> "{friendToDelete?.name}" </p>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setFriendToDelete(null)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleConfirmDelete} className="px-4 py-2 bg-danger text-white rounded-lg text-sm"></button>
</div>
</Modal>
{/* 聊天窗口 */}
{chattingWith && (
<ChatModal
friend={chattingWith}
isOpen={!!chattingWith}
onClose={() => setChattingWith(null)}
savedQueries={savedQueries}
currentUnreadCount={unreadMessages[chattingWith.id] || 0}
updateUnreadCount={updateUnreadCount}
messages={friendMessages[chattingWith.id] || []}
updateMessages={(newMessages) => updateFriendMessages(chattingWith.id, newMessages)}
/>
)}
{/* 查看分享结果 */}
<Modal isOpen={!!viewingShare} onClose={() => setViewingShare(null)} title={`查看分享: "${viewingShare?.querySnapshot.userPrompt}"`}>
{viewingShare && (
<div className="max-h-[70vh] overflow-y-auto -m-6 p-6">
<QueryResult result={viewingShare.querySnapshot} showActions={{ save: false, share: false, export: true }} />
</div>
)}
</Modal>
{/* 保存成功提示 */}
<Modal isOpen={activeModal === 'saveSuccess'} onClose={() => setActiveModal(null)} hideTitle>
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i className="fa fa-check text-green-500 text-3xl"></i>
</div>
<h3 className="text-xl font-bold text-gray-800 mb-2"></h3>
<p className="text-sm text-gray-500"></p>
<button onClick={() => setActiveModal(null)} className="mt-4 px-6 py-2 bg-primary text-white rounded-lg"></button>
</div>
</Modal>
{/* 备注好友模态框 */}
<Modal isOpen={isRemarkModalOpen} onClose={() => setIsRemarkModalOpen(false)} title="备注好友">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{currentRemarkFriend?.name}
</label>
<input
type="text"
placeholder="输入备注(可选)"
value={remarkInput}
onChange={(e) => setRemarkInput(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
maxLength={20}
/>
<p className="text-xs text-gray-400 mt-1">20</p>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setIsRemarkModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleSaveRemark} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</Modal>
{/* 好友主页模态框 */}
<Modal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} title="好友主页" hideTitle={false}>
{currentProfileFriend && (
<div className="space-y-6">
<div className="text-center">
<div className="w-24 h-24 rounded-full mx-auto overflow-hidden border-4 border-primary/20">
<img
src={currentProfileFriend.avatarUrl}
alt={currentProfileFriend.name}
className="w-full h-full object-cover"
onError={(e) => (e.target as HTMLImageElement).src = '/default-avatar.png'}
/>
</div>
<h3 className="text-xl font-bold mt-4">{currentProfileFriend.name}</h3>
{currentProfileFriend.remark && (
<p className="text-gray-500 italic">{currentProfileFriend.remark}</p>
)}
<p className="text-gray-600 mt-2 flex items-center justify-center">
<i className="fa fa-envelope-o mr-1 text-primary"></i>
{currentProfileFriend?.email || '未设置邮箱'}
</p>
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs ${
currentProfileFriend.isOnline
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{currentProfileFriend.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex flex-col space-y-2">
<button
onClick={() => {
setIsProfileModalOpen(false);
handleOpenChat(currentProfileFriend);
}}
className="w-full py-2 bg-primary text-white rounded-lg text-sm flex items-center justify-center"
>
<i className="fa fa-comment-o mr-2"></i>
</button>
<button
onClick={() => {
setIsProfileModalOpen(false);
handleOpenRemarkModal(currentProfileFriend);
}}
className="w-full py-2 border border-gray-300 rounded-lg text-sm flex items-center justify-center"
>
<i className="fa fa-pencil mr-2"></i>
</button>
</div>
</div>
)}
</Modal>
{/* 删除分享确认模态框 */}
<Modal isOpen={!!shareToDelete} onClose={() => setShareToDelete(null)} title="删除分享">
<p className="text-sm text-gray-700">
"{shareToDelete?.sender.name}"
</p>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setShareToDelete(null)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleConfirmDeleteShare} className="px-4 py-2 bg-danger text-white rounded-lg text-sm"></button>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,455 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Conversation, QueryResultData } from '../types';
import { Modal } from './Modal';
import { QueryResult } from './QueryResult';
import { MODEL_OPTIONS, DATABASE_OPTIONS } from '../constants'; // 引入真实选项数据
// 日期筛选类型定义全部、今天、近7天、近30天
type FilterType = 'all' | 'today' | '7days' | '30days';
// 确认弹窗操作类型定义:单条删除、重新执行、批量删除
type ConfirmAction = 'delete' | 'rerun' | 'bulkDelete';
// 历史页面属性接口定义
interface HistoryPageProps {
savedQueries: QueryResultData[];
conversations: Conversation[];
onDelete: (id: string) => void;
onViewInChat: (conversationId: string) => void;
onRerun: (prompt: string) => void;
onCompare: (queryId1: string, queryId2: string) => void;
}
// 校验日期是否在指定天数范围内(含起始日期)
const isDateInRage = (date: Date, days: number): boolean => {
const today = new Date();
const pastDate = new Date();
if (days > 0) {
pastDate.setDate(today.getDate() - (days - 1));
}
today.setHours(23, 59, 59, 999);
pastDate.setHours(0, 0, 0, 0);
return date >= pastDate && date <= today;
};
// 历史查询页面组件
export const HistoryPage: React.FC<HistoryPageProps> = ({ savedQueries, conversations, onDelete, onViewInChat, onRerun, onCompare }) => {
// 搜索关键词状态
const [searchTerm, setSearchTerm] = useState('');
// 当前激活的筛选条件状态
const [activeFilters, setActiveFilters] = useState({
date: 'all' as FilterType,
model: 'all',
database: 'all'
});
// 展开的查询分组标识状态(按用户查询提示语分组)
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
// 选中的查询记录ID集合用于批量操作或对比
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 正在查看详情的查询记录状态
const [viewingQuery, setViewingQuery] = useState<QueryResultData | null>(null);
// 确认弹窗状态管理:控制弹窗显示、操作类型及目标数据
const [confirmModalState, setConfirmModalState] = useState<{
isOpen: boolean;
action: ConfirmAction | null;
targetId: string | null;
targetPrompt: string | null;
targetIds: string[] | null;
}>({
isOpen: false,
action: null,
targetId: null,
targetPrompt: null,
targetIds: null,
});
// 根据对话ID获取对应的对话标题
const getConversationTitle = (id: string) => {
return conversations.find(c => c.id === id)?.title || '未知对话';
};
// 切换查询分组的展开/收起状态,切换时清空已选中的查询记录
const handleToggleGroup = (prompt: string) => {
setExpandedGroup(prev => (prev === prompt ? null : prompt));
setSelectedIds(new Set());
};
// 切换单个查询记录的选中状态最多支持100条选中
const handleSelectSnapshot = (id: string) => {
setSelectedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else if (newSet.size < 100) {
newSet.add(id);
}
return newSet;
});
};
// 执行两条选中查询记录的对比操作
const handleCompare = () => {
if (selectedIds.size !== 2) return;
const [id1, id2] = Array.from(selectedIds);
onCompare(id1, id2);
setSelectedIds(new Set());
};
// 打开单条查询记录的删除确认弹窗
const openDeleteConfirm = useCallback((id: string) => {
setConfirmModalState({
isOpen: true,
action: 'delete',
targetId: id,
targetPrompt: null,
targetIds: null,
});
}, []);
// 打开查询的重新执行确认弹窗
const openRerunConfirm = useCallback((prompt: string) => {
setConfirmModalState({
isOpen: true,
action: 'rerun',
targetId: null,
targetPrompt: prompt,
targetIds: null,
});
}, []);
// 打开批量删除确认弹窗
const openBulkDeleteConfirm = useCallback(() => {
setConfirmModalState({
isOpen: true,
action: 'bulkDelete',
targetId: null,
targetPrompt: null,
targetIds: Array.from(selectedIds),
});
}, [selectedIds]);
// 确认弹窗的操作执行逻辑(根据不同操作类型分发处理)
const handleConfirmAction = () => {
if (confirmModalState.action === 'delete' && confirmModalState.targetId) {
onDelete(confirmModalState.targetId);
} else if (confirmModalState.action === 'rerun' && confirmModalState.targetPrompt) {
onRerun(confirmModalState.targetPrompt);
} else if (confirmModalState.action === 'bulkDelete' && confirmModalState.targetIds) {
confirmModalState.targetIds.forEach(id => onDelete(id));
setSelectedIds(new Set());
}
setConfirmModalState({ isOpen: false, action: null, targetId: null, targetPrompt: null, targetIds: null });
};
// 取消确认弹窗操作
const handleCancelConfirm = () => {
setConfirmModalState({ isOpen: false, action: null, targetId: null, targetPrompt: null, targetIds: null });
};
// 切换筛选条件
const handleFilterChange = (type: 'date' | 'model' | 'database', value: string) => {
setActiveFilters(prev => ({
...prev,
[type]: value
}));
};
// 重置所有筛选条件
const handleResetFilters = () => {
setActiveFilters({
date: 'all',
model: 'all',
database: 'all'
});
setSearchTerm('');
};
// 按用户查询提示语分组查询记录,每组内按执行时间倒序排序
const queryGroups = useMemo(() => {
const groups: Record<string, QueryResultData[]> = {};
savedQueries.forEach(query => {
if (!groups[query.userPrompt]) {
groups[query.userPrompt] = [];
}
groups[query.userPrompt].push(query);
});
Object.values(groups).forEach(snapshots => {
snapshots.sort((a, b) => new Date(b.queryTime).getTime() - new Date(a.queryTime).getTime());
});
return groups;
}, [savedQueries]);
// 根据搜索关键词和所有筛选条件,过滤查询分组
const filteredGroups = useMemo(() => {
return Object.entries(queryGroups)
.filter(([prompt, snapshots]) => {
const matchesSearch = prompt.toLowerCase().includes(searchTerm.toLowerCase());
if (!matchesSearch) return false;
// 过滤出符合所有筛选条件的记录
const filteredSnapshots = snapshots.filter(query => {
// 日期筛选
if (activeFilters.date !== 'all') {
const queryDate = new Date(query.queryTime);
const dateMatch =
activeFilters.date === 'today' ? isDateInRage(queryDate, 1) :
activeFilters.date === '7days' ? isDateInRage(queryDate, 7) :
activeFilters.date === '30days' ? isDateInRage(queryDate, 30) :
false;
if (!dateMatch) return false;
}
// 大模型筛选使用真实model字段
if (activeFilters.model !== 'all' && query.model !== activeFilters.model) {
return false;
}
// 数据库筛选使用真实database字段
if (activeFilters.database !== 'all' && query.database !== activeFilters.database) {
return false;
}
return true;
});
return filteredSnapshots.length > 0;
});
}, [queryGroups, searchTerm, activeFilters]);
// 渲染下拉筛选组件(复用逻辑)
const renderFilterDropdown = (
label: string,
type: 'date' | 'model' | 'database',
options: { value: string; label: string }[]
) => (
<div className="relative">
<select
value={activeFilters[type]}
onChange={(e) => handleFilterChange(type, e.target.value)}
className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white font-bold focus:ring-2 focus:ring-primary/30 pr-6 appearance-none"
style={{
// 自定义箭头样式,确保箭头左移
backgroundImage: 'url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23333\' stroke-width=\'2\' stroke-linecap=\'round\' stroke-linejoin=\'round\'%3e%3cpolyline points=\'6 9 12 15 18 9\'%3e%3c/polyline%3e%3c/svg%3e")',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 0.5rem center', // 箭头左移到右侧0.5rem位置
backgroundSize: '1em'
}}
>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
// 大模型筛选选项从MODEL_OPTIONS提取去重并添加"全部"
const modelOptions = useMemo(() => {
const uniqueModels = Array.from(new Set(MODEL_OPTIONS.map(option => option.name)));
return [
{ value: 'all', label: '全部大模型' },
...uniqueModels.map(model => ({ value: model, label: model }))
];
}, []);
// 数据库筛选选项从DATABASE_OPTIONS提取去重并添加"全部"
const databaseOptions = useMemo(() => {
const uniqueDatabases = Array.from(new Set(DATABASE_OPTIONS.map(option => option.name)));
return [
{ value: 'all', label: '全部数据库' },
...uniqueDatabases.map(db => ({ value: db, label: db }))
];
}, []);
// 日期筛选选项
const dateOptions = [
{ value: 'all', label: '全部日期' },
{ value: 'today', label: '今天' },
{ value: '7days', label: '近7天' },
{ value: '30days', label: '近30天' }
];
// 根据确认弹窗的操作类型,生成对应的弹窗内容(标题、提示信息、按钮文本及样式)
const confirmModalContent = useMemo(() => {
if (confirmModalState.action === 'delete') {
return {
title: '确认删除查询记录?',
message: '此操作将永久删除该条查询快照。请确认是否继续?',
buttonText: '确认删除',
buttonClass: 'bg-red-600 hover:bg-red-700',
};
}
if (confirmModalState.action === 'rerun') {
return {
title: '确认重新执行查询?',
message: `您确定要重新执行查询:"${confirmModalState.targetPrompt}" 吗? 这将消耗新的计算资源。`,
buttonText: '重新执行',
buttonClass: 'bg-primary hover:bg-primary/90',
};
}
if (confirmModalState.action === 'bulkDelete' && confirmModalState.targetIds) {
return {
title: '确认批量删除?',
message: `您确定要永久删除选中的 ${confirmModalState.targetIds.length} 条查询记录吗?此操作不可恢复。`,
buttonText: '批量删除',
buttonClass: 'bg-red-600 hover:bg-red-700',
};
}
return { title: '', message: '', buttonText: '', buttonClass: '' };
}, [confirmModalState.action, confirmModalState.targetPrompt, confirmModalState.targetIds]);
return (
<section className="p-6 space-y-6 overflow-y-auto h-full">
<div className="bg-white p-4 rounded-xl shadow-sm border sticky top-0 z-10">
<div className="flex flex-col md:flex-row gap-4 items-center">
{/* 搜索框 - 左侧缩小 */}
<div className="relative w-full md:w-1/3">
<i className="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="按查询内容搜索...(右侧下拉框可筛选)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* 筛选区域 - 右侧横向分布 */}
<div className="flex items-center space-x-4 flex-grow justify-end">
{renderFilterDropdown('大模型', 'model', modelOptions)}
{renderFilterDropdown('数据库', 'database', databaseOptions)}
{renderFilterDropdown('日期', 'date', dateOptions)}
{/* 重置按钮 */}
<button
onClick={handleResetFilters}
className="px-3 py-1.5 text-sm rounded-md border border-gray-300 hover:bg-gray-50 transition-colors"
>
<i className="fa fa-refresh mr-1"></i>
</button>
{/* 批量删除按钮*/}
<button
onClick={openBulkDeleteConfirm}
disabled={selectedIds.size === 0}
className="px-4 py-1.5 text-sm rounded-md transition-colors
disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed
enabled:bg-red-600 enabled:text-white enabled:hover:bg-red-700"
>
<i className="fa fa-trash-o mr-1"></i>
</button>
</div>
</div>
</div>
<div className="space-y-4">
{filteredGroups.length > 0 ? (
filteredGroups.map(([prompt, snapshots]) => (
<div key={prompt} className="bg-white rounded-xl shadow-sm overflow-hidden border border-gray-200">
<div className="p-4 cursor-pointer hover:bg-gray-50 flex justify-between items-center" onClick={() => handleToggleGroup(prompt)}>
<div>
<p className="font-semibold text-dark truncate max-w-lg" title={prompt}>{prompt}</p>
<p className="text-xs text-gray-500 mt-1">{snapshots.length} </p>
</div>
<div className="flex items-center space-x-2">
{snapshots.length > 1 && expandedGroup === prompt && (
<button
onClick={(e) => { e.stopPropagation(); handleCompare(); }}
disabled={selectedIds.size !== 2}
className="px-3 py-1 bg-primary text-white rounded-md text-xs disabled:bg-primary/50 disabled:cursor-not-allowed"
>
({selectedIds.size}/2)
</button>
)}
<button className="text-gray-500"><i className={`fa fa-chevron-down transition-transform ${expandedGroup === prompt ? 'rotate-180' : ''}`}></i></button>
</div>
</div>
{expandedGroup === prompt && (
<div className="p-4 border-t border-gray-200 bg-neutral space-y-3">
{snapshots.map(query => (
<div key={query.id} className={`p-3 rounded-lg flex items-center justify-between ${selectedIds.has(query.id) ? 'bg-primary/10' : 'bg-white'}`}>
<div className="flex flex-col">
<div className="flex items-center">
<input
type="checkbox"
checked={selectedIds.has(query.id)}
onChange={() => handleSelectSnapshot(query.id)}
className="mr-4 h-4 w-4 text-primary focus:ring-primary/50 border-gray-300 rounded"
/>
<p className="text-sm font-medium">: {new Date(query.queryTime).toLocaleString()}</p>
</div>
{/* 展示真实的查询详情信息 */}
<div className="flex flex-wrap gap-x-6 gap-y-1 mt-1 text-xs text-gray-500 ml-8">
<span>: {query.executionTime}</span>
<span>: {query.model}</span>
<span>: {query.database}</span>
<span>: "{query.conversationId}"</span>
</div>
</div>
<div className="flex space-x-3 text-xs">
<button onClick={(e) => { e.stopPropagation(); setViewingQuery(query); }} className="text-primary hover:underline"></button>
<button onClick={(e) => { e.stopPropagation(); onViewInChat(query.conversationId); }} className="text-primary hover:underline"></button>
<button onClick={(e) => { e.stopPropagation(); openRerunConfirm(query.userPrompt); }} className="text-primary hover:underline"></button>
<button onClick={(e) => { e.stopPropagation(); openDeleteConfirm(query.id); }} className="text-danger hover:underline"></button>
</div>
</div>
))}
</div>
)}
</div>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-search-minus text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
{/* 查询详情弹窗 */}
<Modal
isOpen={!!viewingQuery}
onClose={() => setViewingQuery(null)}
>
{viewingQuery && (
<div className="max-h-[70vh] overflow-y-auto -m-6 p-6 pt-0">
<QueryResult
result={viewingQuery}
showActions={{ save: false, share: true, export: true }}
/>
</div>
)}
</Modal>
{/* 通用确认弹窗(支持单删/批量删/重新执行) */}
<Modal
isOpen={confirmModalState.isOpen}
onClose={handleCancelConfirm}
title={confirmModalContent.title}
>
<p className="text-gray-700 mb-6">{confirmModalContent.message}</p>
<div className="flex justify-end space-x-3">
<button
onClick={handleCancelConfirm}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
</button>
<button
onClick={handleConfirmAction}
className={`px-4 py-2 text-white rounded-lg text-sm hover:shadow-md transition-all duration-200 ${confirmModalContent.buttonClass}`}
>
{confirmModalContent.buttonText}
</button>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,477 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Conversation, QueryResultData } from '../types';
import { Modal } from './Modal';
import { QueryResult } from './QueryResult';
import { MODEL_OPTIONS, DATABASE_OPTIONS } from '../constants';
import { queryLogApi } from '../services/api';
type FilterType = 'all' | 'today' | '7days' | '30days';
type ConfirmAction = 'delete' | 'rerun' | 'bulkDelete';
interface HistoryPageProps {
savedQueries: QueryResultData[];
conversations: Conversation[];
onDelete: (id: string) => void;
onViewInChat: (conversationId: string) => void;
onRerun: (prompt: string) => void;
onCompare: (queryId1: string, queryId2: string) => void;
}
const isDateInRage = (date: Date, days: number): boolean => {
const today = new Date();
const pastDate = new Date();
if (days > 0) {
pastDate.setDate(today.getDate() - (days - 1));
}
today.setHours(23, 59, 59, 999);
pastDate.setHours(0, 0, 0, 0);
return date >= pastDate && date <= today;
};
export const HistoryPageWithAPI: React.FC<HistoryPageProps> = ({
savedQueries, conversations, onDelete, onViewInChat, onRerun, onCompare
}) => {
const userId = Number(sessionStorage.getItem('userId') || '1');
const [queryLogs, setQueryLogs] = useState<QueryResultData[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [activeFilters, setActiveFilters] = useState({
date: 'all' as FilterType,
model: 'all',
database: 'all'
});
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [viewingQuery, setViewingQuery] = useState<QueryResultData | null>(null);
const [confirmModalState, setConfirmModalState] = useState<{
isOpen: boolean;
action: ConfirmAction | null;
targetId: string | null;
targetPrompt: string | null;
targetIds: string[] | null;
}>({
isOpen: false,
action: null,
targetId: null,
targetPrompt: null,
targetIds: null,
});
useEffect(() => {
loadQueryHistory();
}, []);
const loadQueryHistory = async () => {
try {
setLoading(true);
const logs = await queryLogApi.getByUser(userId);
const queryData: QueryResultData[] = logs.map(log => {
const result = JSON.parse(log.queryResult || '{}');
return {
id: String(log.id),
userPrompt: log.userPrompt,
sqlQuery: log.sqlQuery,
conversationId: log.dialogId,
queryTime: log.queryTime,
executionTime: log.executionTime,
database: String(log.dbConnectionId),
model: String(log.llmConfigId),
tableData: result.tableData || { headers: [], rows: [] },
chartData: result.chartData,
};
});
setQueryLogs(queryData);
} catch (error) {
console.error('加载查询历史失败:', error);
} finally {
setLoading(false);
}
};
const getConversationTitle = (id: string) => {
return conversations.find(c => c.id === id)?.title || '未知对话';
};
const handleToggleGroup = (prompt: string) => {
setExpandedGroup(prev => (prev === prompt ? null : prompt));
setSelectedIds(new Set());
};
const handleSelectSnapshot = (id: string) => {
setSelectedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else if (newSet.size < 100) {
newSet.add(id);
}
return newSet;
});
};
const handleCompare = () => {
if (selectedIds.size !== 2) return;
const [id1, id2] = Array.from(selectedIds);
onCompare(id1, id2);
setSelectedIds(new Set());
};
const openDeleteConfirm = (id: string) => {
setConfirmModalState({
isOpen: true,
action: 'delete',
targetId: id,
targetPrompt: null,
targetIds: null,
});
};
const openRerunConfirm = (prompt: string) => {
setConfirmModalState({
isOpen: true,
action: 'rerun',
targetId: null,
targetPrompt: prompt,
targetIds: null,
});
};
const openBulkDeleteConfirm = () => {
setConfirmModalState({
isOpen: true,
action: 'bulkDelete',
targetId: null,
targetPrompt: null,
targetIds: Array.from(selectedIds),
});
};
const handleConfirmAction = async () => {
try {
if (confirmModalState.action === 'delete' && confirmModalState.targetId) {
await queryLogApi.delete(Number(confirmModalState.targetId));
setQueryLogs(prev => prev.filter(q => q.id !== confirmModalState.targetId));
onDelete(confirmModalState.targetId);
} else if (confirmModalState.action === 'rerun' && confirmModalState.targetPrompt) {
onRerun(confirmModalState.targetPrompt);
} else if (confirmModalState.action === 'bulkDelete' && confirmModalState.targetIds) {
await Promise.all(
confirmModalState.targetIds.map(id => queryLogApi.delete(Number(id)))
);
setQueryLogs(prev => prev.filter(q => !confirmModalState.targetIds!.includes(q.id)));
confirmModalState.targetIds.forEach(id => onDelete(id));
setSelectedIds(new Set());
}
} catch (error) {
console.error('操作失败:', error);
alert('操作失败,请稍后重试');
}
setConfirmModalState({ isOpen: false, action: null, targetId: null, targetPrompt: null, targetIds: null });
};
const handleCancelConfirm = () => {
setConfirmModalState({ isOpen: false, action: null, targetId: null, targetPrompt: null, targetIds: null });
};
const handleFilterChange = (type: 'date' | 'model' | 'database', value: string) => {
setActiveFilters(prev => ({
...prev,
[type]: value
}));
};
const handleResetFilters = () => {
setActiveFilters({
date: 'all',
model: 'all',
database: 'all'
});
setSearchTerm('');
};
const queryGroups = useMemo(() => {
const groups: Record<string, QueryResultData[]> = {};
queryLogs.forEach(query => {
if (!groups[query.userPrompt]) {
groups[query.userPrompt] = [];
}
groups[query.userPrompt].push(query);
});
Object.values(groups).forEach(snapshots => {
snapshots.sort((a, b) => new Date(b.queryTime).getTime() - new Date(a.queryTime).getTime());
});
return groups;
}, [queryLogs]);
const filteredGroups = useMemo(() => {
return Object.entries(queryGroups)
.filter(([prompt, snapshots]) => {
const matchesSearch = prompt.toLowerCase().includes(searchTerm.toLowerCase());
if (!matchesSearch) return false;
const filteredSnapshots = snapshots.filter(query => {
if (activeFilters.date !== 'all') {
const queryDate = new Date(query.queryTime);
const dateMatch =
activeFilters.date === 'today' ? isDateInRage(queryDate, 1) :
activeFilters.date === '7days' ? isDateInRage(queryDate, 7) :
activeFilters.date === '30days' ? isDateInRage(queryDate, 30) :
false;
if (!dateMatch) return false;
}
if (activeFilters.model !== 'all' && query.model !== activeFilters.model) {
return false;
}
if (activeFilters.database !== 'all' && query.database !== activeFilters.database) {
return false;
}
return true;
});
return filteredSnapshots.length > 0;
});
}, [queryGroups, searchTerm, activeFilters]);
const renderFilterDropdown = (
label: string,
type: 'date' | 'model' | 'database',
options: { value: string; label: string }[]
) => (
<div className="relative">
<select
value={activeFilters[type]}
onChange={(e) => handleFilterChange(type, e.target.value)}
className="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white font-bold focus:ring-2 focus:ring-primary/30 pr-6 appearance-none"
style={{
backgroundImage: 'url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23333\' stroke-width=\'2\' stroke-linecap=\'round\' stroke-linejoin=\'round\'%3e%3cpolyline points=\'6 9 12 15 18 9\'%3e%3c/polyline%3e%3c/svg%3e")',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 0.5rem center',
backgroundSize: '1em'
}}
>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
const modelOptions = useMemo(() => {
const uniqueModels = Array.from(new Set(MODEL_OPTIONS.map(option => option.name)));
return [
{ value: 'all', label: '全部大模型' },
...uniqueModels.map(model => ({ value: model, label: model }))
];
}, []);
const databaseOptions = useMemo(() => {
const uniqueDatabases = Array.from(new Set(DATABASE_OPTIONS.map(option => option.name)));
return [
{ value: 'all', label: '全部数据库' },
...uniqueDatabases.map(db => ({ value: db, label: db }))
];
}, []);
const dateOptions = [
{ value: 'all', label: '全部日期' },
{ value: 'today', label: '今天' },
{ value: '7days', label: '近7天' },
{ value: '30days', label: '近30天' }
];
const confirmModalContent = useMemo(() => {
if (confirmModalState.action === 'delete') {
return {
title: '确认删除查询记录?',
message: '此操作将永久删除该条查询快照。请确认是否继续?',
buttonText: '确认删除',
buttonClass: 'bg-red-600 hover:bg-red-700',
};
}
if (confirmModalState.action === 'rerun') {
return {
title: '确认重新执行查询?',
message: `您确定要重新执行查询:"${confirmModalState.targetPrompt}" 吗? 这将消耗新的计算资源。`,
buttonText: '重新执行',
buttonClass: 'bg-primary hover:bg-primary/90',
};
}
if (confirmModalState.action === 'bulkDelete' && confirmModalState.targetIds) {
return {
title: '确认批量删除?',
message: `您确定要永久删除选中的 ${confirmModalState.targetIds.length} 条查询记录吗?此操作不可恢复。`,
buttonText: '批量删除',
buttonClass: 'bg-red-600 hover:bg-red-700',
};
}
return { title: '', message: '', buttonText: '', buttonClass: '' };
}, [confirmModalState.action, confirmModalState.targetPrompt, confirmModalState.targetIds]);
if (loading) {
return (
<section className="p-6 space-y-6 overflow-y-auto h-full flex items-center justify-center">
<div className="text-center">
<div className="dot-flashing"></div>
<p className="mt-4 text-gray-500">...</p>
</div>
</section>
);
}
return (
<section className="p-6 space-y-6 overflow-y-auto h-full">
<div className="bg-white p-4 rounded-xl shadow-sm border sticky top-0 z-10">
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="relative w-full md:w-1/3">
<i className="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="按查询内容搜索...(右侧下拉框可筛选)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30"
/>
</div>
<div className="flex items-center space-x-4 flex-grow justify-end">
{renderFilterDropdown('大模型', 'model', modelOptions)}
{renderFilterDropdown('数据库', 'database', databaseOptions)}
{renderFilterDropdown('日期', 'date', dateOptions)}
<button
onClick={handleResetFilters}
className="px-3 py-1.5 text-sm rounded-md border border-gray-300 hover:bg-gray-50 transition-colors"
>
<i className="fa fa-refresh mr-1"></i>
</button>
<button
onClick={openBulkDeleteConfirm}
disabled={selectedIds.size === 0}
className="px-4 py-1.5 text-sm rounded-md transition-colors
disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed
enabled:bg-red-600 enabled:text-white enabled:hover:bg-red-700"
>
<i className="fa fa-trash-o mr-1"></i>
</button>
</div>
</div>
</div>
<div className="space-y-4">
{filteredGroups.length > 0 ? (
filteredGroups.map(([prompt, snapshots]) => (
<div key={prompt} className="bg-white rounded-xl shadow-sm overflow-hidden border border-gray-200">
<div className="p-4 cursor-pointer hover:bg-gray-50 flex justify-between items-center" onClick={() => handleToggleGroup(prompt)}>
<div>
<p className="font-semibold text-dark truncate max-w-lg" title={prompt}>{prompt}</p>
<p className="text-xs text-gray-500 mt-1">{snapshots.length} </p>
</div>
<div className="flex items-center space-x-2">
{snapshots.length > 1 && expandedGroup === prompt && (
<button
onClick={(e) => { e.stopPropagation(); handleCompare(); }}
disabled={selectedIds.size !== 2}
className="px-3 py-1 bg-primary text-white rounded-md text-xs disabled:bg-primary/50 disabled:cursor-not-allowed"
>
({selectedIds.size}/2)
</button>
)}
<button className="text-gray-500"><i className={`fa fa-chevron-down transition-transform ${expandedGroup === prompt ? 'rotate-180' : ''}`}></i></button>
</div>
</div>
{expandedGroup === prompt && (
<div className="p-4 border-t border-gray-200 bg-neutral space-y-3">
{snapshots.map(query => (
<div key={query.id} className={`p-3 rounded-lg flex items-center justify-between ${selectedIds.has(query.id) ? 'bg-primary/10' : 'bg-white'}`}>
<div className="flex flex-col">
<div className="flex items-center">
<input
type="checkbox"
checked={selectedIds.has(query.id)}
onChange={() => handleSelectSnapshot(query.id)}
className="mr-4 h-4 w-4 text-primary focus:ring-primary/50 border-gray-300 rounded"
/>
<p className="text-sm font-medium">: {new Date(query.queryTime).toLocaleString()}</p>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 mt-1 text-xs text-gray-500 ml-8">
<span>: {query.executionTime}</span>
<span>: {query.model}</span>
<span>: {query.database}</span>
<span>: "{query.conversationId}"</span>
</div>
</div>
<div className="flex space-x-3 text-xs">
<button onClick={(e) => { e.stopPropagation(); setViewingQuery(query); }} className="text-primary hover:underline"></button>
<button onClick={(e) => { e.stopPropagation(); onViewInChat(query.conversationId); }} className="text-primary hover:underline"></button>
<button onClick={(e) => { e.stopPropagation(); openRerunConfirm(query.userPrompt); }} className="text-primary hover:underline"></button>
<button onClick={(e) => { e.stopPropagation(); openDeleteConfirm(query.id); }} className="text-danger hover:underline"></button>
</div>
</div>
))}
</div>
)}
</div>
))
) : (
<div className="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm">
<i className="fa fa-search-minus text-4xl mb-3 text-gray-400"></i>
<p></p>
</div>
)}
</div>
<Modal
isOpen={!!viewingQuery}
onClose={() => setViewingQuery(null)}
>
{viewingQuery && (
<div className="max-h-[70vh] overflow-y-auto -m-6 p-6 pt-0">
<QueryResult
result={viewingQuery}
showActions={{ save: false, share: true, export: true }}
/>
</div>
)}
</Modal>
<Modal
isOpen={confirmModalState.isOpen}
onClose={handleCancelConfirm}
title={confirmModalContent.title}
>
<p className="text-gray-700 mb-6">{confirmModalContent.message}</p>
<div className="flex justify-end space-x-3">
<button
onClick={handleCancelConfirm}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
</button>
<button
onClick={handleConfirmAction}
className={`px-4 py-2 text-white rounded-lg text-sm hover:shadow-md transition-all duration-200 ${confirmModalContent.buttonClass}`}
>
{confirmModalContent.buttonText}
</button>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { Conversation } from '../types';
interface HistorySidebarProps {
isOpen: boolean;
onClose: () => void;
conversations: Conversation[];
currentConversationId: string;
onSwitchConversation: (id: string) => void;
onNewConversation: () => void;
onDeleteConversation: (id: string) => void;
}
export const HistorySidebar: React.FC<HistorySidebarProps> = ({
isOpen,
onClose,
conversations,
currentConversationId,
onSwitchConversation,
onNewConversation,
onDeleteConversation
}) => {
// 新增:删除确认弹窗状态
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const sidebarClasses = `
bg-white border-l border-gray-200 h-full flex flex-col
transition-transform duration-300 ease-in-out
fixed top-0 right-0 z-40 transform
${isOpen ? 'translate-x-0' : 'translate-x-full'}
w-80 shadow-lg
`;
const innerContentClasses = `w-80 h-full flex flex-col overflow-hidden`;
return (
<>
{isOpen && <div onClick={onClose} className="fixed inset-0 bg-black/30 z-30"></div>}
<aside className={sidebarClasses}>
<div className={innerContentClasses}>
<div className="p-4 border-b flex items-center justify-between flex-shrink-0">
<h3 className="font-bold"></h3>
<button onClick={onClose} className="p-2 text-gray-500 hover:text-primary" title="关闭对话列表">
<i className="fa fa-times"></i>
</button>
</div>
<div className="px-4 pb-2 pt-4 flex-shrink-0">
<button onClick={onNewConversation} className="w-full flex items-center justify-center px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<i className="fa fa-plus mr-2"></i>
</button>
</div>
<div className="p-4 overflow-y-auto flex-grow space-y-3">
{conversations.length > 0 ? conversations.map(conv => {
const isActive = conv.id === currentConversationId;
return (
<div
key={conv.id}
onClick={() => onSwitchConversation(conv.id)}
className={`p-3 pb-4 border rounded-lg hover:bg-gray-50 cursor-pointer text-sm transition-shadow duration-200 hover:shadow-md relative ${isActive ? 'bg-primary/10 border-l-2 border-primary border' : 'border-gray-200'}`}
>
<div className="flex justify-between items-start">
<p className="truncate max-w-[160px] font-medium">{conv.title || '新对话'}</p>
<span className="text-xs text-gray-400 flex-shrink-0 max-w-[60px] truncate">
{new Date(conv.createTime).toLocaleDateString()}
</span>
</div>
<div className="mt-1 text-xs text-gray-500 max-w-[200px] truncate">
{conv.messages.length > 1 ? `${conv.messages.length - 1}条消息` : '空对话'}
</div>
{/* 删除按钮:点击显示确认弹窗 */}
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTargetId(conv.id);
setShowDeleteConfirm(true);
}}
className="absolute bottom-2 right-2 text-gray-400 hover:text-red-500 p-1 z-10"
title="删除对话"
>
<i className="fa fa-trash-o text-xs"></i>
</button>
</div>
);
}) : (
<div className="text-center text-gray-400 text-sm py-4"></div>
)}
</div>
</div>
</aside>
{/* 新增:删除确认弹窗 */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-xs p-6">
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600 text-sm mb-6">
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeleteTargetId(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50"
>
</button>
<button
onClick={() => {
if (deleteTargetId) {
onDeleteConversation(deleteTargetId);
}
setShowDeleteConfirm(false);
setDeleteTargetId(null);
}}
className="px-4 py-2 bg-red-500 text-white rounded-md text-sm hover:bg-red-600"
>
</button>
</div>
</div>
</div>
)}
</>
);
};

@ -0,0 +1,197 @@
import React, { useState } from 'react';
import { UserRole } from '../types';
import { authApi, LoginRequest } from '../services/api';
interface LoginPageProps {
onLogin: (role: UserRole) => void;
}
const roles: { id: UserRole; name: string; icon: string }[] = [
{ id: 'sys-admin', name: '系统管理员', icon: 'fa-shield' },
{ id: 'data-admin', name: '数据管理员', icon: 'fa-database' },
{ id: 'normal-user', name: '普通用户', icon: 'fa-user' },
];
export const LoginPage: React.FC<LoginPageProps> = ({ onLogin }) => {
const [isForgotModalOpen, setForgotModalOpen] = useState(false);
const [resetEmailSent, setResetEmailSent] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setError(null);
if (!username.trim() || !password.trim()) {
setError('请输入用户名和密码');
return;
}
setIsLoading(true);
try {
const loginRequest: LoginRequest = {
username: username.trim(),
password: password,
};
const response = await authApi.login(loginRequest);
// 完全根据后端返回的roleId决定角色不再允许前端选择
// 1=系统管理员, 2=数据管理员, 3=普通用户
let role: UserRole = 'normal-user';
if (response.roleId === 1) {
role = 'sys-admin';
} else if (response.roleId === 2) {
role = 'data-admin';
} else if (response.roleId === 3) {
role = 'normal-user';
} else {
throw new Error('未知的用户角色');
}
// 保存用户角色到sessionStorage
sessionStorage.setItem('userRole', role);
// 刷新页面以确保所有组件状态都被清理(处理切换账户的情况)
window.location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败,请检查用户名和密码');
} finally {
setIsLoading(false);
}
};
const handleRequestReset = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setResetEmailSent(true);
setTimeout(() => {
handleCloseForgotModal();
}, 3000);
};
const handleCloseForgotModal = () => {
setForgotModalOpen(false);
setTimeout(() => setResetEmailSent(false), 300);
};
const illustrationSvg = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbD0iI0VBRUlGRiIgZD0iTTQzMyAxNDFhMTQgMTQgMCAwIDAtMTQgMTQuMDQ0djE5OC45MTJBMTQgMTQgMCAwIDAgNDMzIDM2OGgxNFYxNDFoLTE0eiIvPjxwYXRoIGZpbGw9IiNDRUQ4RkYiIGQ9Ik0zMzUgMTg0YTQgNCAwIDAgMC00IDR2MTM2YTQgNCAwIDAgMCA4IDBWMTg4YTQgNCAwIDAgMC00LTR6TTI1NSA5N2E0IDQgMCAwIDAtNCA0djI1MGE0IDQgMCAwIDAgOCAwdl0yNTBhNCA0IDAgMCAwLTQtNHpNMzc1IDIyN2E0IDQgMCAwIDAtNCA0djg5YTQgNCAwIDAgMCA4IDB2LTg5YTQgNCAwIDAgMC00LTR6Ii8+PHBhdGggZmlsbD0iI0VBRUlGRiIgZD0iTTgxIDIwMWExNCAxNCAwIDAgMC0xNCAxNC4wNDR2MTM3LjkyQTE0IDE0IDAgMCAwIDgxIDM2OGgxNFYyMDFIODF6Ii8+PHBhdGggZmlsbD0iI0NFRDhGRiIgZD0iTTE5NSA2MWExNCAxNCAwIDAgMC0xNCAxNC4wNDR2MjgwLjkyQTE0IDE0IDAgMCAwIDE5NSAzNjhIMTlWMjYwaDE2NHYtNTZIMTlWOTFoMTc2VjYxaC0xNHptLTE2NCAxODVWMjEzaDE2NFYxNTVIMzF2NDh6bTE2NC05NVY5OWgtMTZWNzdhNCA0IDAgMCAwLTQtNFY2MWgtNDB2MTJhNCA0IDAgMCAwLTQgN3YxNkgzMXY0MGgxNjR6Ii8+PHBhdGggZmlsbD0iI0VBRUlGRiIgZD0iTTI5NiAyNTlhMTQgMTQgMCAwIDAtMTQgMTQuMDQ0djk0LjkyQTE0IDE0IDAgMCAwIDI5NiAzODJoMTRWMjU5aC0xNHpNMzU2IDMwOWExNCAxNCAwIDAgMC0xNCAxNC4wNDR2NTUuOTJBMTQgMTQgMCAwIDAgMzU2IDM5MWgxNFYzMDloLTM1eiIvPjxwYXRoIGZpbGw9IiNDRUQ4RkYiIGQ9Ik0xMzUgMTI0YTQgNCAwIDAgMC00IDR2MjE3YTQgNCAwIDAgMCA4IDBWMTE4YTQgNCAwIDAgMC00LTR6Ii8+PHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTQyMyAzODRINjlhMTQgMTQgMCAwIDAtMTQgMTR2NTRoMzg1VjM5OGExNCAxNCAwIDAgMC0xNC0xNHoiLz48cGF0aCBmaWxsPSIjQ0VEOEZGIiBkPSJNMzY4IDQyNEgxNDR2MTZoMjI0di0xNnpNMzYxIDQwNUg4NmExNCAxNCAwIDAgMCAwIDI4aDI3NWExNCAxNCAwIDAgMCAwLTI4eiIvPjxwYXRoIGZpbGw9IiNGNEY5RkYiIGQ9Ik01NSAzOTIuMTY0VjM5OGExNCAxNCAwIDAgMCAxNCAxNGg1NDFBMTQgMTQgMCAwIDEgNDM3IDQyNkg1OFY0MDZoMzAzdjEyaDI0di0xMmgyOHYxMmgyNHYtMTJoLTh2LTEySDU4djEyLjE2NEExMy45IDEzLjkgMCAwIDEgNTUgMzkyLjE2NHptMCAzMS44MzZWMzk4YTE0IDE0IDAgMCAxIDE0LTE0aDM1N2EzMyAzMyAwIDAgMSAzMyAzM2gtNDIydi0zMmgzMDN2LTguMTY0QTEzLjkgMTMuOSAwIDAgMSA0MjQgNDI0eiIvPjxwYXRoIGZpbGw9IiM0Q0I2RkYiIGQ9Ik0yMTEgMjg4aC0zM2wtMzUtOTBoLTI4bDQ3IDEyM2g0OGwxNi00NGg0NmwtOCA0NGg0Mmw0Ny0xMjNoLTM4bC0yMyA2M2gtNDVsMTYtNDVaIi8+PHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTk1IDQ1MmgyNHYyNEg5NXptMjA4IDBoMjR2MjRIMzAzem0xMjAgMGgyNHYyNGgtMjR6Ii8+PC9zdmc+";
return (
<div className="font-inter animated-gradient min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-5xl bg-white rounded-2xl shadow-2xl overflow-hidden grid lg:grid-cols-2">
<div className="p-8 md:p-12 flex flex-col justify-center">
<div>
<div className="flex items-center space-x-3 mb-6">
<i className="fa fa-brain text-3xl text-primary"></i>
<h1 className="text-2xl font-bold text-dark"></h1>
</div>
<h2 className="text-3xl font-bold text-dark"></h2>
<p className="text-gray-500 mt-2"></p>
</div>
<div className="mt-8">
<div className="mb-4">
<div className="relative">
<span className="absolute left-3.5 top-3.5 text-gray-400"><i className="fa fa-user"></i></span>
<input
type="text"
id="username"
placeholder="请输入账号"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 pl-10 bg-gray-50 text-dark border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary placeholder-gray-400"
/>
</div>
</div>
<div className="mb-6">
<div className="relative">
<span className="absolute left-3.5 top-3.5 text-gray-400"><i className="fa fa-lock"></i></span>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleLogin(e as any)}
className="w-full px-4 py-3 pl-10 bg-gray-50 text-dark border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary placeholder-gray-400"
/>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
<div className="flex flex-col space-y-4">
<button
id="login-btn"
className="bg-primary text-white py-3 rounded-lg font-bold hover:shadow-lg hover:shadow-primary/30 hover:-translate-y-0.5 transition-all duration-200 flex items-center justify-center disabled:bg-primary/50 disabled:cursor-wait"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? <i className="fa fa-spinner fa-spin"></i> : '安全登录'}
</button>
<div className="text-center text-sm text-gray-500">
<a href="#" className="hover:text-primary transition-colors" onClick={(e) => { e.preventDefault(); setForgotModalOpen(true); }}></a>
</div>
</div>
</div>
<div className="text-center text-xs text-gray-400 mt-12">
© 2024 Your Company. All Rights Reserved.
</div>
</div>
<div className="hidden lg:flex flex-col items-center justify-center bg-blue-50 p-12 text-center">
<div className="max-w-md">
<h3 className="text-3xl font-bold text-primary mb-4">穿</h3>
<p className="text-gray-600 mb-8">
SQL
</p>
<ul className="space-y-4 text-left inline-block">
<li className="flex items-center text-gray-700"><i className="fa fa-magic text-primary w-6 text-lg"></i><span></span></li>
<li className="flex items-center text-gray-700"><i className="fa fa-pie-chart text-primary w-6 text-lg"></i><span></span></li>
<li className="flex items-center text-gray-700"><i className="fa fa-share-alt text-primary w-6 text-lg"></i><span></span></li>
</ul>
</div>
<div className="mt-8">
<img src={illustrationSvg} alt="Data Analysis Illustration" className="w-full max-w-sm h-auto" />
</div>
</div>
</div>
{/* 忘记密码模态框 */}
{isForgotModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={handleCloseForgotModal}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6" onClick={(e) => e.stopPropagation()}>
{!resetEmailSent ? (
<>
<h2 className="text-xl font-bold mb-4 text-center"></h2>
<form onSubmit={handleRequestReset}>
<p className="text-sm text-gray-600 mb-4">使</p>
<div className="mb-4">
<label className="block text-gray-700 font-medium mb-2"></label>
<input type="email" placeholder="请输入邮箱" required className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30 focus:border-primary" />
</div>
<div className="flex justify-end space-x-2">
<button type="button" onClick={handleCloseForgotModal} className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"></button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200"></button>
</div>
</form>
</>
) : (
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i className="fa fa-check text-green-500 text-3xl"></i>
</div>
<h3 className="text-xl font-bold text-gray-800 mb-2"></h3>
<p className="text-sm text-gray-500"></p>
</div>
)}
</div>
</div>
)}
</div>
);
};

@ -0,0 +1,64 @@
import React, { useEffect, useState } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
hideTitle?: boolean;
children: React.ReactNode;
contentClassName?: string; // 外部自定义样式(会覆盖默认)
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
hideTitle = false,
children,
contentClassName = ""
}) => {
const [isRendered, setIsRendered] = useState(isOpen);
useEffect(() => {
if (isOpen) {
setIsRendered(true);
} else {
const timer = setTimeout(() => setIsRendered(false), 300);
return () => clearTimeout(timer);
}
}, [isOpen]);
if (!isRendered) {
return null;
}
// 容器样式(保持居中逻辑不变)
const modalContainerClass = `fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-50 transition-opacity duration-300 ${
isOpen ? 'opacity-100' : 'opacity-0'
}`;
// 核心修改:添加默认宽度 max-w-md和你原来的一样同时让 contentClassName 能覆盖它
// 注意类的顺序:默认样式在前,自定义样式在后(后定义的会覆盖前定义的)
const modalContentClass = `
bg-white rounded-xl p-6 w-full mx-4 my-auto transition-all duration-300
max-w-md // 默认宽度
max-h-[90vh] // 弹窗最大高度(限制上限)
height: 100% // 让内容容器高度充满可用空间
${isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
${contentClassName}
`;
return (
<div className={modalContainerClass} onClick={onClose}>
<div className={modalContentClass} onClick={(e) => e.stopPropagation()}>
{!hideTitle && (
<div className="flex justify-between items-center height: 100% mb-4">
<h3 className="font-bold text-lg">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">&times;</button>
</div>
)}
{children}
</div>
</div>
);
};

@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { MOCK_NOTIFICATIONS } from '../constants';
import { Notification } from '../types';
import { Modal } from './Modal'; // 引入Modal组件
const NotificationIcon: React.FC<{ type: 'share' | 'system' }> = ({ type }) => {
switch (type) {
case 'share':
return <i className="fa fa-share-alt text-primary"></i>;
case 'system':
return <i className="fa fa-cogs text-secondary"></i>;
default:
return null;
}
};
const NotificationItem: React.FC<{ notification: Notification; onToggleRead: (id: string) => void; onDelete: (id: string) => void; }> = ({ notification, onToggleRead, onDelete }) => {
const { id, type, title, content, timestamp, isRead, isPinned } = notification;
return (
<li className={`p-4 flex items-start space-x-4 transition-colors ${!isRead ? 'bg-primary/5' : 'hover:bg-gray-50'}`}>
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0 mt-1 relative">
<NotificationIcon type={type} />
{isPinned && <i className="fa fa-thumb-tack text-xs text-gray-500 absolute -top-1 -right-1 bg-white p-1 rounded-full shadow"></i>}
</div>
<div className="flex-1">
<p className="font-semibold text-dark">{title}</p>
<p className="text-sm text-gray-600">{content}</p>
<span className="text-xs text-gray-400 mt-1 block">{new Date(timestamp).toLocaleString()}</span>
</div>
<div className="flex items-center space-x-3">
<button onClick={() => onToggleRead(id)} className="text-gray-400 hover:text-primary transition-colors" title={isRead ? '标记为未读' : '标记为已读'}>
<i className={`fa ${isRead ? 'fa-envelope-open' : 'fa-envelope'}`}></i>
</button>
{!isPinned && (
<button onClick={() => onDelete(id)} className="text-gray-400 hover:text-danger transition-colors" title="删除通知">
<i className="fa fa-trash"></i>
</button>
)}
</div>
</li>
);
};
export const NotificationsPage: React.FC = () => {
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
// 新增:删除确认弹窗状态
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const pinnedNotifications = notifications.filter(n => n.isPinned);
const regularNotifications = notifications.filter(n => !n.isPinned);
const handleToggleRead = (id: string) => {
setNotifications(
notifications.map(n => n.id === id ? { ...n, isRead: !n.isRead } : n)
);
};
const handleMarkAllRead = () => {
setNotifications(notifications.map(n => ({ ...n, isRead: true })));
};
// 修改:点击删除按钮时显示弹窗
const handleDelete = (id: string) => {
setDeleteTargetId(id);
setShowDeleteConfirm(true);
};
// 新增:确认删除后执行的逻辑
const handleConfirmDelete = () => {
if (deleteTargetId) {
setNotifications(notifications.filter(n => n.id !== deleteTargetId));
}
setShowDeleteConfirm(false);
setDeleteTargetId(null);
};
const handleClearAll = () => {
setNotifications(notifications.filter(n => n.isPinned));
};
return (
<section className="p-6 space-y-6 overflow-y-auto">
<div className="flex justify-between items-center">
<div className="space-x-2">
<button onClick={handleMarkAllRead} className="px-3 py-1 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition-colors"></button>
<button onClick={handleClearAll} className="px-3 py-1 border border-danger/50 text-danger rounded-lg text-sm hover:bg-danger/10 transition-colors"></button>
</div>
</div>
{pinnedNotifications.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 mb-2 px-2"></h3>
<div className="bg-white rounded-xl shadow-sm">
<ul className="divide-y divide-gray-200">
{pinnedNotifications.map(n => (
<NotificationItem key={n.id} notification={n} onToggleRead={handleToggleRead} onDelete={handleDelete} />
))}
</ul>
</div>
</div>
)}
<div>
<h3 className="text-sm font-semibold text-gray-500 mb-2 px-2">
{pinnedNotifications.length > 0 ? '普通通知' : ''}
</h3>
<div className="bg-white rounded-xl shadow-sm">
<ul className="divide-y divide-gray-200">
{regularNotifications.length > 0 ? regularNotifications.map(n => (
<NotificationItem key={n.id} notification={n} onToggleRead={handleToggleRead} onDelete={handleDelete} />
)) : (
<div className="text-center text-gray-500 py-16">
<i className="fa fa-bell-slash-o text-4xl mb-3"></i>
<p></p>
</div>
)}
</ul>
</div>
</div>
{/* 新增:删除确认弹窗 */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => {
setShowDeleteConfirm(false);
setDeleteTargetId(null);
}}
title="确认删除"
>
<div className="text-center py-4">
<p className="text-gray-700 mb-6"></p>
<div className="flex justify-center gap-3">
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeleteTargetId(null);
}}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
</button>
<button
onClick={handleConfirmDelete}
className="px-4 py-2 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600"
>
</button>
</div>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react';
import { Modal } from './Modal';
import { notificationApi } from '../services/api';
interface Notification {
id: number;
title: string;
content: string;
targetId: number;
priorityId: number;
publishTime?: string;
isTop: number;
createUserId: number;
createTime: string;
latestUpdateTime: string;
}
interface DisplayNotification {
id: string;
type: 'share' | 'system';
title: string;
content: string;
timestamp: string;
isRead: boolean;
isPinned: boolean;
}
const NotificationIcon: React.FC<{ type: 'share' | 'system' }> = ({ type }) => {
switch (type) {
case 'share':
return <i className="fa fa-share-alt text-primary"></i>;
case 'system':
return <i className="fa fa-cogs text-secondary"></i>;
default:
return null;
}
};
const NotificationItem: React.FC<{
notification: DisplayNotification;
onToggleRead: (id: string) => void;
onDelete: (id: string) => void;
}> = ({ notification, onToggleRead, onDelete }) => {
const { id, type, title, content, timestamp, isRead, isPinned } = notification;
return (
<li className={`p-4 flex items-start space-x-4 transition-colors ${!isRead ? 'bg-primary/5' : 'hover:bg-gray-50'}`}>
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0 mt-1 relative">
<NotificationIcon type={type} />
{isPinned && <i className="fa fa-thumb-tack text-xs text-gray-500 absolute -top-1 -right-1 bg-white p-1 rounded-full shadow"></i>}
</div>
<div className="flex-1">
<p className="font-semibold text-dark">{title}</p>
<p className="text-sm text-gray-600">{content}</p>
<span className="text-xs text-gray-400 mt-1 block">{new Date(timestamp).toLocaleString()}</span>
</div>
<div className="flex items-center space-x-3">
<button onClick={() => onToggleRead(id)} className="text-gray-400 hover:text-primary transition-colors" title={isRead ? '标记为未读' : '标记为已读'}>
<i className={`fa ${isRead ? 'fa-envelope-open' : 'fa-envelope'}`}></i>
</button>
{!isPinned && (
<button onClick={() => onDelete(id)} className="text-gray-400 hover:text-danger transition-colors" title="删除通知">
<i className="fa fa-trash"></i>
</button>
)}
</div>
</li>
);
};
export const NotificationsPageWithAPI: React.FC = () => {
const userId = Number(sessionStorage.getItem('userId') || '1');
const roleId = Number(localStorage.getItem('roleId') || '3');
const [notifications, setNotifications] = useState<DisplayNotification[]>([]);
const [readStatus, setReadStatus] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
useEffect(() => {
loadNotifications();
}, []);
const loadNotifications = async () => {
try {
setLoading(true);
const publishedNotifications = await notificationApi.getPublished();
const targetNotifications = publishedNotifications.filter(n =>
n.targetId === roleId || n.targetId === 0
);
const displayNotifications: DisplayNotification[] = targetNotifications.map(n => ({
id: String(n.id),
type: n.priorityId === 1 ? 'system' : 'share',
title: n.title,
content: n.content,
timestamp: n.publishTime || n.createTime,
isRead: readStatus[String(n.id)] !== undefined ? readStatus[String(n.id)] : false,
isPinned: n.isTop === 1,
}));
setNotifications(displayNotifications);
} catch (error) {
console.error('加载通知失败:', error);
} finally {
setLoading(false);
}
};
const pinnedNotifications = notifications.filter(n => n.isPinned);
const regularNotifications = notifications.filter(n => !n.isPinned);
const handleToggleRead = (id: string) => {
setReadStatus(prev => ({
...prev,
[id]: !prev[id]
}));
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, isRead: !n.isRead } : n)
);
};
const handleMarkAllRead = () => {
const newReadStatus: Record<string, boolean> = {};
notifications.forEach(n => {
newReadStatus[n.id] = true;
});
setReadStatus(newReadStatus);
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
};
const handleDelete = (id: string) => {
setDeleteTargetId(id);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (deleteTargetId) {
setNotifications(prev => prev.filter(n => n.id !== deleteTargetId));
const newReadStatus = { ...readStatus };
delete newReadStatus[deleteTargetId];
setReadStatus(newReadStatus);
}
setShowDeleteConfirm(false);
setDeleteTargetId(null);
};
const handleClearAll = () => {
setNotifications(prev => prev.filter(n => n.isPinned));
const newReadStatus: Record<string, boolean> = {};
pinnedNotifications.forEach(n => {
if (readStatus[n.id] !== undefined) {
newReadStatus[n.id] = readStatus[n.id];
}
});
setReadStatus(newReadStatus);
};
if (loading) {
return (
<section className="p-6 space-y-6 overflow-y-auto flex items-center justify-center h-full">
<div className="text-center">
<div className="dot-flashing"></div>
<p className="mt-4 text-gray-500">...</p>
</div>
</section>
);
}
return (
<section className="p-6 space-y-6 overflow-y-auto">
<div className="flex justify-between items-center">
<div className="space-x-2">
<button onClick={handleMarkAllRead} className="px-3 py-1 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition-colors"></button>
<button onClick={handleClearAll} className="px-3 py-1 border border-danger/50 text-danger rounded-lg text-sm hover:bg-danger/10 transition-colors"></button>
</div>
</div>
{pinnedNotifications.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 mb-2 px-2"></h3>
<div className="bg-white rounded-xl shadow-sm">
<ul className="divide-y divide-gray-200">
{pinnedNotifications.map(n => (
<NotificationItem key={n.id} notification={n} onToggleRead={handleToggleRead} onDelete={handleDelete} />
))}
</ul>
</div>
</div>
)}
<div>
<h3 className="text-sm font-semibold text-gray-500 mb-2 px-2">
{pinnedNotifications.length > 0 ? '普通通知' : ''}
</h3>
<div className="bg-white rounded-xl shadow-sm">
<ul className="divide-y divide-gray-200">
{regularNotifications.length > 0 ? regularNotifications.map(n => (
<NotificationItem key={n.id} notification={n} onToggleRead={handleToggleRead} onDelete={handleDelete} />
)) : (
<div className="text-center text-gray-500 py-16">
<i className="fa fa-bell-slash-o text-4xl mb-3"></i>
<p></p>
</div>
)}
</ul>
</div>
</div>
<Modal
isOpen={showDeleteConfirm}
onClose={() => {
setShowDeleteConfirm(false);
setDeleteTargetId(null);
}}
title="确认删除"
>
<div className="text-center py-4">
<p className="text-gray-700 mb-6"></p>
<div className="flex justify-center gap-3">
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeleteTargetId(null);
}}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
</button>
<button
onClick={handleConfirmDelete}
className="px-4 py-2 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600"
>
</button>
</div>
</div>
</Modal>
</section>
);
};

@ -0,0 +1,18 @@
import React from 'react';
interface PlaceholderPageProps {
title: string;
}
export const PlaceholderPage: React.FC<PlaceholderPageProps> = ({ title }) => {
return (
<section className="p-6 space-y-6 overflow-y-auto flex flex-col items-center justify-center h-full text-gray-500">
<div className="text-center">
<i className="fa fa-cogs text-6xl mb-4"></i>
<h2 className="text-2xl font-bold mb-2">{title}</h2>
<p></p>
</div>
</section>
);
};

@ -0,0 +1,299 @@
import React, { useState, useEffect, useRef } from 'react';
import { Conversation, MessageRole, QueryResultData } from '../types';
import { ChatMessage } from './ChatMessage';
import { Dropdown } from './Dropdown';
import { DATABASE_OPTIONS } from '../constants';
import { HistorySidebar } from './HistorySidebar';
import { RightSidebar } from './RightSidebar';
import { queryApi, QueryResponse, llmConfigApi } from '../services/api';
interface QueryPageProps {
currentConversation: Conversation | undefined;
onToggleHistory: () => void;
isHistoryOpen: boolean;
onAddMessage: (role: MessageRole, content: string | QueryResultData) => void;
onSaveQuery: (query: QueryResultData) => void;
onShareQuery: (queryId: string, friendId: string) => void;
savedQueries: QueryResultData[];
initialPrompt?: string;
onClearInitialPrompt: () => void;
conversations: Conversation[];
currentConversationId: string; // 当前激活的对话ID关键
onSwitchConversation: (id: string) => void;
onNewConversation: () => void;
onDeleteConversation: (id: string) => void;
}
export const QueryPage: React.FC<QueryPageProps> = ({
currentConversation,
onToggleHistory,
isHistoryOpen,
onAddMessage,
onSaveQuery,
onShareQuery,
savedQueries,
initialPrompt,
onClearInitialPrompt,
conversations,
currentConversationId, // 接收当前对话ID
onSwitchConversation,
onNewConversation,
onDeleteConversation,
}) => {
const [prompt, setPrompt] = useState('');
const [modelOptions, setModelOptions] = useState<Array<{id: string, name: string, disabled: boolean, description: string}>>([]);
const [selectedModelId, setSelectedModelId] = useState('');
const [selectedDatabase, setSelectedDatabase] = useState(DATABASE_OPTIONS[0].name);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [abortController, setAbortController] = useState<AbortController | null>(null);
// 新增记录当前正在处理的请求对应的对话ID
const [pendingConversationId, setPendingConversationId] = useState<string | null>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
// 从后端加载可用的大模型配置
useEffect(() => {
loadAvailableModels();
}, []);
const loadAvailableModels = async () => {
try {
const configs = await llmConfigApi.getAvailable();
const options = configs.map(config => ({
id: String(config.id),
name: `${config.name} (${config.version})`,
disabled: false,
description: `${config.name} - ${config.version}`,
}));
setModelOptions(options);
if (options.length > 0) {
setSelectedModelId(options[0].id);
}
} catch (error) {
console.error('加载大模型配置失败:', error);
// 如果加载失败,显示错误提示
setModelOptions([]);
setError('无法加载大模型配置,请联系管理员');
}
};
// 自动滚动到底部
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, [currentConversation?.messages]);
// 处理初始提示
useEffect(() => {
if (initialPrompt) {
setPrompt(initialPrompt);
const syntheticEvent = { preventDefault: () => {} } as React.FormEvent<HTMLFormElement>;
handleSubmit(syntheticEvent, initialPrompt);
onClearInitialPrompt();
}
}, [initialPrompt, onClearInitialPrompt]);
// 关键修改1当对话ID变化时自动中断当前请求
useEffect(() => {
// 如果正在加载中且当前对话ID与请求时的ID不一致中断请求
if (isLoading && pendingConversationId && pendingConversationId !== currentConversationId) {
handleStop();
}
}, [currentConversationId, isLoading, pendingConversationId]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>, customPrompt?: string) => {
e.preventDefault();
const finalPrompt = customPrompt || prompt;
if (!finalPrompt.trim() || isLoading) return;
// 1. 记录当前请求对应的对话ID关键
const requestConversationId = currentConversationId;
setPendingConversationId(requestConversationId);
// 2. 创建中断控制器
const controller = new AbortController();
setAbortController(controller);
onAddMessage('user', finalPrompt);
setPrompt('');
setIsLoading(true);
setError(null);
try {
if (!currentConversation) throw new Error("No active conversation.");
// 3. 调用后端API传递模型配置ID
const response: QueryResponse = await queryApi.execute({
userPrompt: finalPrompt,
model: selectedModelId, // 传递模型配置ID而不是名称
database: selectedDatabase,
conversationId: currentConversation.id !== 'conv-1' ? currentConversation.id : undefined,
});
// 4. 将后端响应转换为前端格式
const result: QueryResultData = {
id: response.id,
userPrompt: response.userPrompt,
sqlQuery: response.sqlQuery,
conversationId: response.conversationId,
queryTime: response.queryTime,
executionTime: response.executionTime,
database: response.database,
model: response.model,
tableData: response.tableData,
chartData: response.chartData ? {
type: (response.chartData.type || 'bar') as 'bar' | 'line' | 'pie',
labels: response.chartData.labels || [],
datasets: (response.chartData.datasets || []).map(dataset => ({
label: dataset.label,
data: dataset.data,
backgroundColor: dataset.backgroundColor || '#3b82f6', // 默认颜色
})),
} : undefined,
};
// 5. 关键校验只有当前对话仍为请求时的对话才添加AI回复
if (currentConversationId === requestConversationId) {
onAddMessage('ai', result);
} else {
// 对话已切换,丢弃回复(可选:添加日志便于调试)
console.log(`AI回复已丢弃目标对话已切换原对话ID=${requestConversationId}新对话ID=${currentConversationId}`);
}
} catch (err) {
// 6. 错误处理也需校验对话ID
if (err instanceof Error && err.name === 'AbortError') {
// 仅在原对话中显示"已停止"提示
if (currentConversationId === requestConversationId) {
onAddMessage('ai', '查询已被手动停止');
}
} else {
const errorMessage = err instanceof Error ? err.message : '查询失败,请稍后重试';
// 仅在原对话中显示错误
if (currentConversationId === requestConversationId) {
setError(errorMessage);
onAddMessage('ai', errorMessage);
}
}
} finally {
// 7. 仅在原对话中重置状态
if (currentConversationId === requestConversationId) {
setIsLoading(false);
setAbortController(null);
setPendingConversationId(null);
}
}
};
// 停止请求
const handleStop = () => {
if (abortController) {
abortController.abort(); // 触发中断
setIsLoading(false);
setAbortController(null);
setPendingConversationId(null);
}
};
const handleRecommendationClick = (recommendation: string) => {
const fakeEvent = { preventDefault: () => {} } as React.FormEvent<HTMLFormElement>;
handleSubmit(fakeEvent, recommendation);
};
return (
<div className="flex-1 flex overflow-hidden h-full">
<div className="flex-1 flex flex-col relative overflow-hidden">
<main className="flex-1 flex flex-col h-full overflow-hidden">
{/* Chat Area */}
<div ref={chatContainerRef} className="flex-1 p-6 overflow-y-auto space-y-6">
{currentConversation?.messages.map((msg, index) => (
<ChatMessage
key={index}
message={msg}
onSaveQuery={onSaveQuery}
onShareQuery={onShareQuery}
savedQueries={savedQueries}
/>
))}
{isLoading && (
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg bg-primary/10 text-primary">
<i className="fa fa-robot"></i>
</div>
<div className="p-4 shadow-sm max-w-lg bg-white border border-gray-200 rounded-lg rounded-tl-none">
<div className="flex items-center space-x-2">
<div className="dot-flashing"></div>
<span className="text-sm text-gray-500">...</span>
</div>
</div>
</div>
)}
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t flex-shrink-0">
<div className="flex items-center justify-end space-x-2 mb-2">
<Dropdown
options={modelOptions.map(opt => ({ name: opt.name, disabled: opt.disabled, description: opt.description }))}
selected={modelOptions.find(opt => opt.id === selectedModelId)?.name || ''}
setSelected={(name) => {
const option = modelOptions.find(opt => opt.name === name);
if (option) setSelectedModelId(option.id);
}}
icon="fa-cogs"
/>
<Dropdown options={DATABASE_OPTIONS} selected={selectedDatabase} setSelected={setSelectedDatabase} icon="fa-database" />
</div>
<form onSubmit={handleSubmit} className="relative">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as any);
}
}}
placeholder="请输入您的数据查询需求..."
className="w-full pl-4 pr-24 py-3 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-primary/30"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
type="button"
onClick={handleStop}
className="absolute right-3 top-1/2 -translate-y-1/2 px-4 py-2 bg-white border border-black text-black font-bold rounded-lg text-sm hover:shadow-md hover:-translate-y-px transition-all duration-200"
>
</button>
) : (
<button
type="submit"
disabled={!prompt.trim()}
className="absolute right-3 top-1/2 -translate-y-1/2 px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md hover:-translate-y-px transition-all duration-200 disabled:bg-primary/50 disabled:cursor-not-allowed"
>
</button>
)}
</form>
</div>
</main>
<HistorySidebar
isOpen={isHistoryOpen}
onClose={onToggleHistory}
conversations={conversations}
currentConversationId={currentConversationId}
onSwitchConversation={onSwitchConversation}
onNewConversation={onNewConversation}
onDeleteConversation={onDeleteConversation}
/>
</div>
<RightSidebar
currentConversation={currentConversation}
onRecommendationClick={handleRecommendationClick}
className="hidden lg:block"
/>
</div>
);
};

@ -0,0 +1,264 @@
import React, { useState, useRef, useEffect } from 'react';
import { Chart, registerables, ChartType } from 'chart.js';
import { QueryResultData, Friend } from '../types';
import { MOCK_FRIENDS_LIST } from '../constants';
import { Modal } from './Modal';
Chart.register(...registerables);
interface QueryResultProps {
result: QueryResultData;
onSaveQuery?: (query: QueryResultData) => void;
onShareQuery?: (queryId: string, friendId: string) => void;
savedQueries?: QueryResultData[];
showActions?: {
save?: boolean;
share?: boolean;
export?: boolean;
}
}
type ViewType = 'table' | 'chart' | 'prompt' | 'sql';
const defaultShowActions = { save: true, share: true, export: true };
export const QueryResult: React.FC<QueryResultProps> = ({ result, onSaveQuery, onShareQuery, savedQueries = [], showActions = defaultShowActions }) => {
const [activeView, setActiveView] = useState<ViewType>('table');
const [chartType, setChartType] = useState<ChartType>(result.chartData.type);
const [activeModal, setActiveModal] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [selectedFriendId, setSelectedFriendId] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<Chart | null>(null);
useEffect(() => {
setChartType(result.chartData.type);
}, [result.chartData.type]);
useEffect(() => {
if (activeView === 'chart' && canvasRef.current) {
if (chartRef.current) {
chartRef.current.destroy();
}
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
chartRef.current = new Chart(ctx, {
type: chartType,
data: {
labels: result.chartData.labels,
datasets: result.chartData.datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: chartType === 'pie', // only show legend for pie charts
}
}
}
});
}
}
return () => {
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
};
}, [activeView, result.chartData, chartType]);
const handleCopySql = () => {
navigator.clipboard.writeText(result.sqlQuery).then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
});
};
const handleConfirmShare = () => {
if (!selectedFriendId || !onShareQuery) return;
onShareQuery(result.id, selectedFriendId);
setActiveModal(null);
setTimeout(() => setActiveModal('shareSuccess'), 350);
}
const handleOpenShareModal = () => {
setSelectedFriendId(null);
setActiveModal('share');
}
const handleSave = () => {
if (onSaveQuery) {
onSaveQuery(result);
setActiveModal('saveSuccess');
}
};
const handleExport = () => {
const { headers, rows } = result.tableData;
const csvContent = "data:text/csv;charset=utf-8,"
+ [headers.join(','), ...rows.map(e => e.join(','))].join('\n');
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `query_result_${result.id}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setActiveModal('exportSuccess');
};
const renderViewContent = () => {
switch (activeView) {
case 'table':
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
{result.tableData.headers.map((header, i) => (
<th key={i} className="px-4 py-3 font-semibold text-dark text-center">{header}</th>
))}
</tr>
</thead>
<tbody>
{result.tableData.rows.map((row, i) => (
<tr key={i} className="border-b last:border-b-0 hover:bg-gray-50">
{row.map((cell, j) => (
<td key={j} className={`px-4 py-3 text-center ${typeof cell === 'string' && cell.startsWith('+') ? 'text-success font-medium' : 'text-gray-700'}`}>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
case 'chart':
const ChartTypeButton: React.FC<{ type: ChartType; label: string; icon: string; }> = ({ type, label, icon }) => (
<button
onClick={() => setChartType(type)}
className={`px-3 py-1.5 text-sm rounded-md flex items-center transition-colors ${chartType === type ? 'bg-primary text-white' : 'bg-gray-200 hover:bg-gray-300'}`}
>
<i className={`fa ${icon} mr-2`}></i>{label}
</button>
);
return (
<div className="bg-white p-2 rounded-lg">
<div className="flex justify-center space-x-2 mb-4">
<ChartTypeButton type="bar" label="竖状图" icon="fa-bar-chart" />
<ChartTypeButton type="line" label="折线图" icon="fa-line-chart" />
<ChartTypeButton type="pie" label="饼状图" icon="fa-pie-chart" />
</div>
<div className="chart-container">
<canvas ref={canvasRef}></canvas>
</div>
</div>
);
case 'prompt':
return (
<div className="p-4 bg-gray-50 rounded-md">
<p className="text-gray-800 whitespace-pre-wrap">{result.userPrompt}</p>
</div>
);
case 'sql':
return (
<div className="relative">
<button onClick={handleCopySql} className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-600 hover:bg-gray-700 text-white rounded transition-colors">
<i className={`fa ${copySuccess ? 'fa-check' : 'fa-copy'}`}></i> {copySuccess ? '已复制' : '复制'}
</button>
<pre className="p-4 bg-gray-800 text-white rounded-md text-sm overflow-x-auto">
<code>{result.sqlQuery}</code>
</pre>
</div>
);
default:
return null;
}
};
const TabButton: React.FC<{ view: ViewType; label: string }> = ({ view, label }) => (
<button
onClick={() => setActiveView(view)}
className={`py-2 text-sm transition-colors duration-200 ${activeView === view ? 'border-b-2 border-primary text-primary font-medium' : 'text-gray-500 hover:text-dark'}`}
>
{label}
</button>
);
return (
<div className="w-full">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-dark">{new Date(result.queryTime).toLocaleString()}</h3>
<p className="text-sm">
</p>
</div>
<div className="text-sm text-gray-500 space-y-1 mb-3">
<p><span>{result.database || '销售数据库'}</span></p>
<p><span>{result.model || 'gemini-2.5-pro'}</span></p>
<p><span>{result.executionTime+' '}</span></p>
</div>
<div className="border-b mb-4">
<div className="flex space-x-6">
<TabButton view="table" label="表格视图" />
<TabButton view="chart" label="图表视图" />
<TabButton view="prompt" label="原始提问" />
<TabButton view="sql" label="SQL语句" />
</div>
</div>
<div className="min-h-[200px]">
{renderViewContent()}
</div>
<div className="flex gap-3 mt-4 ml-auto justify-end">
{showActions.save && <button onClick={handleSave} className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"><i className="fa fa-star mr-2"></i></button>}
{showActions.share && <button onClick={handleOpenShareModal} className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"><i className="fa fa-share-alt mr-2"></i></button>}
{showActions.export && <button onClick={handleExport} className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"><i className="fa fa-download mr-2"></i></button>}
</div>
<Modal isOpen={activeModal === 'share'} onClose={() => setActiveModal(null)} title="分享查询结果">
<div className="space-y-4">
<p className="text-sm text-gray-600"></p>
<div className="max-h-60 overflow-y-auto space-y-2 p-2 bg-gray-50 rounded-lg border">
{MOCK_FRIENDS_LIST.length > 0 ? MOCK_FRIENDS_LIST.map(friend => (
<label key={friend.id} className={`flex items-center p-3 rounded-lg cursor-pointer transition-colors ${selectedFriendId === friend.id ? 'bg-primary/20' : 'hover:bg-gray-200'}`}>
<input
type="radio"
name="friend-share-selection"
checked={selectedFriendId === friend.id}
onChange={() => setSelectedFriendId(friend.id)}
className="mr-3"
/>
<div className="flex items-center space-x-3">
<img src={friend.avatarUrl} alt={friend.name} className="w-8 h-8 rounded-full"/>
<span className="text-sm font-medium text-dark truncate" title={friend.name}>{friend.name}</span>
</div>
</label>
)) : <p className="text-center text-gray-500 text-sm p-4"></p>}
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button onClick={() => setActiveModal(null)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"></button>
<button onClick={handleConfirmShare} disabled={!selectedFriendId} className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 disabled:bg-primary/50 disabled:cursor-not-allowed"></button>
</div>
</Modal>
<Modal isOpen={activeModal === 'saveSuccess'} onClose={() => setActiveModal(null)} hideTitle>
<div className="text-center"><div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"><i className="fa fa-check text-green-500 text-3xl"></i></div><h3 className="text-xl font-bold text-gray-800 mb-2"></h3><p className="text-sm text-gray-500"></p><button onClick={() => setActiveModal(null)} className="mt-4 px-6 py-2 bg-primary text-white rounded-lg"></button></div>
</Modal>
<Modal isOpen={activeModal === 'exportSuccess'} onClose={() => setActiveModal(null)} hideTitle>
<div className="text-center"><div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4"><i className="fa fa-download text-blue-500 text-2xl"></i></div><h3 className="text-xl font-bold text-gray-800 mb-2"></h3><p className="text-sm text-gray-500"></p><button onClick={() => setActiveModal(null)} className="mt-4 px-6 py-2 bg-primary text-white rounded-lg"></button></div>
</Modal>
<Modal isOpen={activeModal === 'shareSuccess'} onClose={() => setActiveModal(null)} hideTitle>
<div className="text-center"><div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4"><i className="fa fa-share-alt text-orange-500 text-2xl"></i></div><h3 className="text-xl font-bold text-gray-800 mb-2"></h3><button onClick={() => setActiveModal(null)} className="mt-4 px-6 py-2 bg-primary text-white rounded-lg"></button></div>
</Modal>
</div>
);
};

@ -0,0 +1,83 @@
import React from 'react';
import { Conversation, Message, QueryResultData } from '../types';
import { COMMON_RECOMMENDATIONS, MOCK_FAILURE_SUGGESTIONS, MOCK_SUCCESS_SUGGESTIONS } from '../constants';
interface RightSidebarProps {
currentConversation: Conversation | undefined;
onRecommendationClick: (prompt: string) => void;
className?: string;
}
const isQueryResult = (content: any): content is QueryResultData => {
return content && typeof content === 'object' && 'sqlQuery' in content;
}
export const RightSidebar: React.FC<RightSidebarProps> = ({ currentConversation, onRecommendationClick, className = '' }) => {
const lastMessage: Message | undefined = currentConversation?.messages[currentConversation.messages.length - 1];
let relatedSearches: string[] = [];
let queryFailed = false;
let showSuggestions = false;
// We only want to show suggestions after the first user query and an AI response
if (currentConversation && currentConversation.messages.length > 1 && lastMessage && lastMessage.role === 'ai') {
showSuggestions = true;
if (isQueryResult(lastMessage.content)) {
queryFailed = false;
relatedSearches = MOCK_SUCCESS_SUGGESTIONS;
} else {
// Use centralized mock suggestions for failed query
queryFailed = true;
relatedSearches = MOCK_FAILURE_SUGGESTIONS;
}
}
return (
<aside className={`w-80 bg-white border-l border-gray-200 p-4 space-y-6 overflow-y-auto flex-shrink-0 flex flex-col ${className}`}>
{/* Common Searches */}
<div className="p-4 bg-neutral rounded-lg">
<h3 className="font-bold text-lg mb-4 flex items-center">
<span className="text-xl mr-2"></span>
</h3>
<div className="space-y-3">
{COMMON_RECOMMENDATIONS.map(query => (
<button
key={query}
onClick={() => onRecommendationClick(query)}
className="w-full text-left p-3 border border-gray-300 bg-white rounded-lg hover:bg-gray-50 transition-colors text-sm shadow-sm"
>
{query}
</button>
))}
</div>
</div>
{/* AI Thinking / Related Searches */}
{showSuggestions && (
<div className="p-4 bg-neutral rounded-lg">
<h3 className="font-bold text-lg mb-4 flex items-center">
<i className="fa fa-magic text-secondary text-xl mr-2"></i>
</h3>
<div className={`p-3 rounded-md text-sm mb-3 ${queryFailed ? 'bg-danger/10 text-danger' : 'bg-success/10 text-success'}`}>
{queryFailed ? '查询似乎遇到了问题,您可以尝试:' : '基于当前结果,您可以继续探索:'}
</div>
<div className="space-y-3">
{relatedSearches.map((query, index) => (
<button
key={index}
onClick={() => onRecommendationClick(query)}
className="w-full text-left p-3 border border-gray-300 bg-white rounded-lg hover:bg-gray-50 transition-colors text-sm shadow-sm"
>
{query}
</button>
))}
</div>
</div>
)}
</aside>
);
};

@ -0,0 +1,94 @@
import React from 'react';
import { Page } from '../types';
interface SidebarItemProps {
href: Page;
icon: string;
label: string;
isActive: boolean;
onClick: (page: Page) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ href, icon, label, isActive, onClick }) => {
const activeClass = 'bg-primary/10 text-primary border-l-4 border-primary';
const inactiveClass = 'text-gray-600 hover:bg-gray-50';
return (
<li>
<a
href={`#${href}`}
className={`flex items-center px-6 py-3 text-sm transition-colors duration-200 ${isActive ? activeClass : inactiveClass}`}
onClick={(e) => {
e.preventDefault();
onClick(href);
}}
>
<i className={`fa ${icon} w-6`}></i>
<span>{label}</span>
</a>
</li>
);
};
interface SidebarProps {
activePage: Page;
setActivePage: (page: Page) => void;
onLogout: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ activePage, setActivePage, onLogout }) => {
const queryItems = [
{ href: 'query', icon: 'fa-search', label: '数据查询' },
{ href: 'history', icon: 'fa-star', label: '收藏夹' },
] as const;
const personalItems = [
{ href: 'notifications', icon: 'fa-bell', label: '通知中心' },
{ href: 'friends', icon: 'fa-users', label: '好友管理' },
{ href: 'account', icon: 'fa-user', label: '账户管理' },
] as const;
return (
<aside className="w-64 bg-white shadow-md h-screen flex-shrink-0 flex flex-col hidden lg:flex">
<div className="p-4 border-b flex items-center space-x-2">
<i className="fa fa-database text-primary text-2xl"></i>
<h1 className="text-lg font-bold"></h1>
</div>
<nav className="py-4 flex-grow pt-8">
<ul>
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase"></li>
{queryItems.map(item => (
<SidebarItem
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
isActive={activePage === item.href}
onClick={setActivePage}
/>
))}
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase mt-4"></li>
{personalItems.map(item => (
<SidebarItem
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
isActive={activePage === item.href}
onClick={setActivePage}
/>
))}
</ul>
</nav>
<div className="border-t p-4">
<button
onClick={onLogout}
className="flex items-center text-gray-600 hover:text-danger transition-colors w-full">
<i className="fa fa-sign-out w-6"></i>
<span>退</span>
</button>
</div>
</aside>
);
};

@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { SysAdminPageType } from '../types';
import { DashboardPage } from './admin/DashboardPage';
import { UserManagementPage } from './admin/UserManagementPage';
import { NotificationManagementPage } from './admin/NotificationManagementPage';
import { SystemLogPage } from './admin/SystemLogPage';
import { LLMConfigPage } from './admin/LLMConfigPage';
import { AdminAccountPage } from './admin/AdminAccountPage';
interface SysAdminPageProps {
activePage: SysAdminPageType;
setActivePage: (page: SysAdminPageType) => void;
}
export const SysAdminPage: React.FC<SysAdminPageProps> = ({ activePage, setActivePage }) => {
const [initialLogStatusFilter, setInitialLogStatusFilter] = useState('');
const handleViewAbnormalLogs = () => {
setInitialLogStatusFilter('failure');
setActivePage('system-log');
};
switch (activePage) {
case 'dashboard':
return <DashboardPage onViewAbnormalLogs={handleViewAbnormalLogs} />;
case 'user-management':
return <UserManagementPage />;
case 'notification-management':
return <NotificationManagementPage />;
case 'system-log':
return <SystemLogPage
initialStatusFilter={initialLogStatusFilter}
clearInitialFilter={() => setInitialLogStatusFilter('')}
/>;
case 'llm-config':
return <LLMConfigPage />;
case 'account':
return <AdminAccountPage />;
default:
const exhaustiveCheck: never = activePage;
return <div>Unknown page: {exhaustiveCheck}</div>;
}
};

@ -0,0 +1,90 @@
import React from 'react';
import { SysAdminPageType } from '../types';
interface SidebarItemProps {
href: SysAdminPageType;
icon: string;
label: string;
isActive: boolean;
onClick: (page: SysAdminPageType) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ href, icon, label, isActive, onClick }) => {
const activeClass = 'bg-primary/10 text-primary border-l-4 border-primary';
const inactiveClass = 'text-gray-600 hover:bg-gray-50';
return (
<li>
<a
href={`#${href}`}
className={`flex items-center px-6 py-3 text-sm transition-colors duration-200 ${isActive ? activeClass : inactiveClass}`}
onClick={(e) => {
e.preventDefault();
onClick(href);
}}
>
<i className={`fa ${icon} w-6`}></i>
<span>{label}</span>
</a>
</li>
);
};
interface SysAdminSidebarProps {
activePage: SysAdminPageType;
setActivePage: (page: SysAdminPageType) => void;
onLogout: () => void;
}
export const SysAdminSidebar: React.FC<SysAdminSidebarProps> = ({ activePage, setActivePage, onLogout }) => {
const monitoringItems = [
{ href: 'dashboard', icon: 'fa-tachometer', label: '仪表盘' },
{ href: 'system-log', icon: 'fa-history', label: '系统日志' },
] as const;
const managementItems = [
{ href: 'user-management', icon: 'fa-users', label: '用户管理' },
{ href: 'llm-config', icon: 'fa-cogs', label: '大模型配置' },
{ href: 'notification-management', icon: 'fa-bullhorn', label: '通知管理' },
] as const;
const personalItems = [
{ href: 'account', icon: 'fa-user-circle-o', label: '我的账户' },
] as const;
return (
<aside className="w-64 bg-white shadow-md h-screen flex-shrink-0 flex flex-col hidden lg:flex">
<div className="p-4 border-b flex items-center space-x-2">
<i className="fa fa-shield text-primary text-2xl"></i>
<h1 className="text-lg font-bold"></h1>
</div>
<nav className="py-4 flex-grow pt-8">
<ul>
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase"></li>
{monitoringItems.map(item => (
<SidebarItem key={item.href} {...item} isActive={activePage === item.href} onClick={setActivePage} />
))}
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase mt-4"></li>
{managementItems.map(item => (
<SidebarItem key={item.href} {...item} isActive={activePage === item.href} onClick={setActivePage} />
))}
<li className="px-6 py-2 text-xs text-gray-400 font-bold uppercase mt-4"></li>
{personalItems.map(item => (
<SidebarItem key={item.href} {...item} isActive={activePage === item.href} onClick={setActivePage} />
))}
</ul>
</nav>
<div className="border-t p-4">
<button
onClick={onLogout}
className="flex items-center text-gray-600 hover:text-danger transition-colors w-full">
<i className="fa fa-sign-out w-6"></i>
<span>退</span>
</button>
</div>
</aside>
);
};

@ -0,0 +1,267 @@
import React, { useState, useEffect, useRef } from 'react';
import { UserRole, Notification } from '../types';
// 顶部导航栏组件属性接口
interface TopHeaderProps {
user: {
name: string;
role: UserRole | null;
avatarUrl: string;
};
notificationCount: number; // 未读通知数量
notifications: Notification[]; // 所有通知列表
onNewConversation?: () => void; // 新建对话回调
onNotificationClick: () => void; // 查看全部通知回调
showHistoryToggle?: boolean; // 是否显示历史/新对话按钮
onToggleHistory?: () => void; // 切换历史对话面板回调
onAvatarClick: () => void; // 头像点击回调(通常用于打开个人中心)
currentConversationName?: string; // 当前对话名称(显示在标题栏)
}
// 格式化通知标题指定关键词王小明、Gemini加粗显示
const formatNotificationTitle = (title: string): React.ReactNode => {
const keywords = ['王小明', 'Gemini'];
const regex = new RegExp(`(${keywords.join('|')})`, 'g');
const parts = title.split(regex);
return (
<>
{parts.map((part, i) =>
keywords.includes(part) ? <strong key={i}>{part}</strong> : part
)}
</>
);
};
// 根据通知内容获取对应的图标和颜色
const getNotificationDetails = (notification: Notification) => {
let icon = 'fa-info-circle'; // 默认图标
let color = 'text-primary'; // 默认颜色
// 新用户通知:绿色用户添加图标
if (notification.title.includes('新用户')) {
icon = 'fa-user-plus';
color = 'text-green-500';
}
// 连接失败通知:红色警告图标
else if (notification.title.includes('连接失败')) {
icon = 'fa-exclamation-triangle';
color = 'text-danger';
}
return { icon, color };
};
// 格式化时间戳为相对时间3秒前、2小时前
const formatTimeAgo = (timestamp: string): string => {
const now = new Date();
const then = new Date(timestamp);
const diffInSeconds = Math.round((now.getTime() - then.getTime()) / 1000);
if (diffInSeconds < 60) return `${diffInSeconds}秒前`;
const diffInMinutes = Math.round(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}分钟前`;
const diffInHours = Math.round(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}小时前`;
const diffInDays = Math.round(diffInHours / 24);
return `${diffInDays}天前`;
};
// 顶部导航栏主组件
export const TopHeader: React.FC<TopHeaderProps> = ({
user,
notificationCount,
notifications,
onNotificationClick,
showHistoryToggle,
onToggleHistory,
onAvatarClick,
onNewConversation,
currentConversationName
}) => {
// 通知下拉框显示状态
const [isNotificationOpen, setNotificationOpen] = useState(false);
// 头像加载失败状态(用于显示占位符)
const [avatarError, setAvatarError] = useState(false);
// 通知下拉框DOM引用用于点击外部关闭
const notificationRef = useRef<HTMLDivElement>(null);
// 根据用户角色获取角色中文名称
const getRoleName = (role: UserRole | null) => {
if (!role) return '';
switch (role) {
case 'sys-admin': return '系统管理员';
case 'data-admin': return '数据管理员';
case 'normal-user': return '普通用户';
default: return '用户';
}
};
// 点击页面外部关闭通知下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
setNotificationOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 当用户头像URL变化时重置加载失败状态
useEffect(() => {
if (user.avatarUrl) {
setAvatarError(false);
}
}, [user.avatarUrl]);
// 查看全部通知:关闭下拉框并触发回调
const handleViewAll = () => {
setNotificationOpen(false);
onNotificationClick();
};
// 筛选通知下拉框显示内容优先显示3条未读通知不足时补充已读通知
const notificationsToShow = notifications
.filter(n => !n.isRead)
.slice(0, 3);
if (notificationsToShow.length < 3) {
const readNotifications = notifications.filter(n => n.isRead);
notificationsToShow.push(...readNotifications.slice(0, 3 - notificationsToShow.length));
}
// 获取用户姓名首字母(用于头像占位符)
const getInitials = (name: string) => {
if (!name) return '?';
return name.trim().charAt(0).toUpperCase();
};
return (
<header className="p-4 border-b flex justify-between items-center bg-white flex-shrink-0">
{/* 左侧:当前对话标题(仅当名称存在时显示) */}
<div className="flex items-center space-x-2">
{currentConversationName && (
<h1 className="text-xl font-bold truncate max-w-xs">
{currentConversationName}
</h1>
)}
</div>
{/* 右侧:功能按钮区(历史/新对话、通知、用户头像) */}
<div className="flex items-center space-x-4">
{/* 历史/新对话按钮组仅showHistoryToggle为true时显示 */}
{showHistoryToggle && (
<>
{/* 新对话按钮 */}
{onNewConversation && (
<button
onClick={onNewConversation}
className="p-2 text-gray-500 hover:text-primary transition-colors"
title="新对话"
>
<i className="fa fa-plus-circle text-xl"></i>
</button>
)}
{/* 历史对话按钮 */}
{onToggleHistory && (
<button
onClick={onToggleHistory}
className="p-2 text-gray-500 hover:text-primary transition-colors"
title="历史对话"
>
<i className="fa fa-history text-xl"></i>
</button>
)}
</>
)}
{/* 通知中心:按钮+下拉框 */}
<div className="relative" ref={notificationRef}>
{/* 通知按钮:带未读数量徽章 */}
<button
onClick={() => setNotificationOpen(prev => !prev)}
className="p-2 text-gray-500 hover:text-primary transition-colors"
title="通知中心"
>
<i className="fa fa-bell text-xl"></i>
{/* 未读徽章:数量>9时显示"9+" */}
{notificationCount > 0 && (
<span className="absolute top-0.5 right-0.5 block h-4 w-4 rounded-full bg-danger text-white text-[10px] flex items-center justify-center ring-2 ring-white">
{notificationCount > 9 ? '9+' : notificationCount}
</span>
)}
</button>
{/* 通知下拉框(展开状态显示) */}
{isNotificationOpen && (
<div className="absolute top-full right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border z-50">
{/* 下拉框标题栏 */}
<div className="p-4 border-b">
<h3 className="font-bold text-lg"></h3>
</div>
{/* 通知列表最多显示3条 */}
<ul className="divide-y divide-gray-100">
{notificationsToShow.map(notification => {
const { icon, color } = getNotificationDetails(notification);
return (
<li key={notification.id} className="p-4 flex items-start space-x-3 hover:bg-gray-50">
{/* 通知图标 */}
<div className={`mt-1 w-6 text-center ${color}`}>
<i className={`fa ${icon}`}></i>
</div>
{/* 通知内容+时间 */}
<div className="flex-1">
<p className="text-sm text-dark">{formatNotificationTitle(notification.title)}</p>
<p className="text-xs text-gray-400 mt-1">{formatTimeAgo(notification.timestamp)}</p>
</div>
</li>
);
})}
</ul>
{/* 查看全部按钮 */}
<div className="p-2 border-t text-center">
<button
onClick={handleViewAll}
className="w-full text-sm text-primary py-2 hover:bg-primary/10 rounded"
>
</button>
</div>
</div>
)}
</div>
{/* 用户头像+信息按钮 */}
<button
onClick={onAvatarClick}
className="flex items-center space-x-2 text-left transition-colors hover:bg-gray-100 p-1 rounded-lg"
>
{/* 头像:优先显示图片,加载失败/无图片时显示姓名首字母占位符 */}
{avatarError || !user.avatarUrl ? (
<div className="w-9 h-9 rounded-full bg-primary/80 flex items-center justify-center text-sm font-bold text-white">
{user.name ? getInitials(user.name) : <i className="fa fa-user"></i>}
</div>
) : (
<img
src={user.avatarUrl}
alt="User Avatar"
className="w-9 h-9 rounded-full object-cover"
onError={() => setAvatarError(true)} // 图片加载失败触发占位符显示
/>
)}
{/* 用户名+角色(仅桌面端显示) */}
<div className="hidden md:block">
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-gray-500">{getRoleName(user.role)}</p>
</div>
</button>
</div>
</header>
);
};

@ -0,0 +1,194 @@
import React, { useState, useRef, useEffect } from 'react';
import { AdminModal } from './AdminModal';
import { userApi } from '../../services/api';
export const AdminAccountPage: React.FC = () => {
const userId = Number(sessionStorage.getItem('userId') || '1');
const [user, setUser] = useState({
name: '',
id: '',
email: '',
phone: '',
avatar: ''
});
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<'edit' | 'password' | '2fa' | null>(null);
const [editForm, setEditForm] = useState({ username: '', email: '', phonenumber: '' });
const [passwordForm, setPasswordForm] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
const avatarInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadUserProfile();
}, []);
const loadUserProfile = async () => {
try {
setLoading(true);
const userData = await userApi.getById(userId);
setUser({
name: userData.username,
id: `#${userData.id}`,
email: userData.email,
phone: maskPhoneNumber(userData.phonenumber),
avatar: userData.avatarUrl || '/default-avatar.png'
});
} catch (error) {
console.error('加载用户信息失败:', error);
} finally {
setLoading(false);
}
};
const maskPhoneNumber = (phone: string) => {
if (phone && phone.length === 11) {
return `${phone.substring(0, 3)}****${phone.substring(7)}`;
}
return phone;
};
const handleOpenEditModal = () => {
setEditForm({
username: user.name,
email: user.email,
phonenumber: user.phone.replace(/\*/g, ''),
});
setModal('edit');
};
const handleSaveProfile = async () => {
try {
await userApi.update({
id: userId,
username: editForm.username,
email: editForm.email,
phonenumber: editForm.phonenumber,
avatarUrl: user.avatar,
});
await loadUserProfile();
setModal(null);
alert('个人信息更新成功!');
} catch (error) {
console.error('更新个人信息失败:', error);
alert('更新失败,请重试');
}
};
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert('两次输入的新密码不一致!');
return;
}
if (passwordForm.newPassword.length < 6) {
alert('新密码长度不能少于6位');
return;
}
alert('密码修改功能待后端接口实现');
setModal(null);
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' });
};
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = async (event) => {
const newAvatar = event.target?.result as string;
try {
await userApi.update({
id: userId,
avatarUrl: newAvatar,
});
setUser(prev => ({ ...prev, avatar: newAvatar }));
// 触发全局刷新事件,让右上角头像也更新
window.dispatchEvent(new CustomEvent('userAvatarUpdated', {
detail: { avatarUrl: newAvatar }
}));
alert('头像更新成功!');
} catch (error) {
console.error('更新头像失败:', error);
alert('更新头像失败,请重试');
}
};
reader.readAsDataURL(file);
}
};
if (loading) {
return (
<main className="flex-1 overflow-y-auto p-6">
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">...</div>
</div>
</main>
);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<h2 className="text-2xl font-bold"></h2>
<div className="bg-white rounded-xl p-6 shadow-sm">
<div className="flex flex-col md:flex-row gap-6">
<div className="flex flex-col items-center">
<img src={user.avatar} alt="Avatar" className="w-24 h-24 rounded-full object-cover mb-4" />
<input type="file" ref={avatarInputRef} onChange={handleAvatarChange} className="hidden" accept="image/*" />
<button onClick={() => avatarInputRef.current?.click()} className="px-4 py-2 border rounded-lg text-sm"><i className="fa fa-upload mr-1"></i> </button>
</div>
<div className="flex-1">
<h3 className="font-bold text-lg mb-4"></h3>
<div className="grid grid-cols-2 gap-4">
<div><p className="text-sm text-gray-500"></p><p>{user.name}</p></div>
<div><p className="text-sm text-gray-500">ID</p><p>{user.id}</p></div>
<div><p className="text-sm text-gray-500"></p><p>{user.email}</p></div>
<div><p className="text-sm text-gray-500"></p><p>{user.phone}</p></div>
</div>
<div className="mt-6 flex justify-end">
<button onClick={handleOpenEditModal} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"><i className="fa fa-edit mr-1"></i> </button>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm">
<h3 className="font-bold text-lg mb-6"></h3>
<div className="space-y-6">
<div className="flex justify-between items-center border-b pb-4">
<div><h4 className="font-medium"></h4><p className="text-sm text-gray-500 mt-1"></p></div>
<button onClick={() => setModal('password')} className="px-4 py-2 border rounded-lg text-sm"></button>
</div>
<div className="flex justify-between items-center">
<div><h4 className="font-medium"> (2FA)</h4><p className="text-sm text-gray-500 mt-1"></p></div>
<button onClick={() => setModal('2fa')} className="px-4 py-2 border rounded-lg text-sm"></button>
</div>
</div>
</div>
<AdminModal isOpen={modal === 'edit'} onClose={() => setModal(null)} title="编辑信息">
<div className="space-y-4">
<div><label className="block text-sm mb-1"></label><input value={editForm.username} onChange={(e) => setEditForm(prev => ({...prev, username: e.target.value}))} className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><input value={editForm.email} onChange={(e) => setEditForm(prev => ({...prev, email: e.target.value}))} type="email" className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><input value={editForm.phonenumber} onChange={(e) => setEditForm(prev => ({...prev, phonenumber: e.target.value}))} type="tel" className="w-full px-3 py-2 border rounded-lg" /></div>
</div>
<div className="flex justify-end space-x-2 mt-4"><button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button onClick={handleSaveProfile} className="px-4 py-2 bg-primary text-white rounded-lg"></button></div>
</AdminModal>
<AdminModal isOpen={modal === 'password'} onClose={() => setModal(null)} title="修改密码">
<div className="space-y-4">
<div><label className="block text-sm mb-1"></label><input type="password" value={passwordForm.oldPassword} onChange={(e) => setPasswordForm(prev => ({...prev, oldPassword: e.target.value}))} className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><input type="password" value={passwordForm.newPassword} onChange={(e) => setPasswordForm(prev => ({...prev, newPassword: e.target.value}))} className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><input type="password" value={passwordForm.confirmPassword} onChange={(e) => setPasswordForm(prev => ({...prev, confirmPassword: e.target.value}))} className="w-full px-3 py-2 border rounded-lg" /></div>
</div>
<div className="flex justify-end space-x-2 mt-4"><button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button onClick={handleChangePassword} className="px-4 py-2 bg-primary text-white rounded-lg"></button></div>
</AdminModal>
<AdminModal isOpen={modal === '2fa'} onClose={() => setModal(null)} title="设置两步验证">
<div className="text-center p-4">
<div className="w-32 h-32 bg-gray-200 mx-auto mb-4 flex items-center justify-center"><span className="text-sm text-gray-500"></span></div>
<p className="text-sm mb-4">使</p>
<input placeholder="输入6位验证码" className="w-full px-3 py-2 border rounded-lg text-center" />
</div>
<div className="flex justify-end space-x-2 mt-4"><button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button onClick={() => setModal(null)} className="px-4 py-2 bg-primary text-white rounded-lg"></button></div>
</AdminModal>
</main>
);
};

@ -0,0 +1,28 @@
import React from 'react';
interface AdminModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const AdminModal: React.FC<AdminModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 fade-in" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b flex justify-between items-center">
<h3 className="font-bold text-lg">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<i className="fa fa-times"></i>
</button>
</div>
<div className="p-6">
{children}
</div>
</div>
</div>
);
};

@ -0,0 +1,243 @@
import React, { useState } from 'react';
import { SysAdminPageType } from '../../types';
import { AdminModal } from './AdminModal';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Title } from 'chart.js';
import { Doughnut, Line, Bar } from 'react-chartjs-2';
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Title);
type StatCardColor = 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
interface StatCardProps {
icon: string;
title: string;
value: string;
change: { value: string; type: 'positive' | 'negative' | 'neutral' };
color: StatCardColor;
}
const StatCard: React.FC<StatCardProps> = ({ icon, title, value, change, color }) => {
const changeColor = change.type === 'positive' ? 'text-success' : change.type === 'negative' ? 'text-danger' : 'text-gray-500';
const changeIcon = change.type === 'positive' ? 'fa-arrow-up' : change.type === 'negative' ? 'fa-arrow-down' : 'fa-minus';
const colorMapping: Record<StatCardColor, { bg: string; text: string }> = {
primary: { bg: 'bg-primary/10', text: 'text-primary' },
secondary: { bg: 'bg-secondary/10', text: 'text-secondary' },
success: { bg: 'bg-success/10', text: 'text-success' },
warning: { bg: 'bg-warning/10', text: 'text-warning' },
danger: { bg: 'bg-danger/10', text: 'text-danger' },
};
const { bg, text } = colorMapping[color];
return (
<div className="bg-white rounded-xl p-6 shadow-sm card-hover">
<div className="flex justify-between items-start">
<div>
<p className="text-gray-500 text-sm">{title}</p>
<h3 className="text-3xl font-bold mt-2">{value}</h3>
<p className={`${changeColor} text-sm mt-2`}><i className={`fa ${changeIcon}`}></i> {change.value}</p>
</div>
<div className={`w-12 h-12 rounded-full ${bg} flex items-center justify-center ${text}`}>
<i className={`fa ${icon} text-xl`}></i>
</div>
</div>
</div>
);
};
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
const toast = document.createElement('div');
const colors = {
success: 'bg-green-500 text-white',
error: 'bg-red-500 text-white'
};
toast.className = `fixed top-5 right-5 px-6 py-3 rounded-lg shadow-lg z-50 transition-all duration-300 transform translate-x-full ${colors[type]}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.remove('translate-x-full');
toast.classList.add('translate-x-0');
}, 10);
setTimeout(() => {
toast.classList.remove('translate-x-0');
toast.classList.add('translate-x-full');
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
};
export const DashboardPage: React.FC<{ onViewAbnormalLogs: () => void }> = ({ onViewAbnormalLogs }) => {
const [isExportModalOpen, setExportModalOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = () => {
setIsRefreshing(true);
setTimeout(() => {
setIsRefreshing(false);
showToast('仪表盘数据已刷新', 'success');
}, 1500);
};
const handleExport = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const reportType = formData.get('report-type');
const format = formData.get('export-format');
showToast(`正在导出 ${reportType} 报告 (${format})...`, 'success');
setTimeout(() => {
const fakeFile = new Blob(['This is a mock report file.'], { type: 'text/plain' });
const link = document.createElement('a');
link.href = URL.createObjectURL(fakeFile);
link.download = `report-${reportType}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, 1000);
setExportModalOpen(false);
};
// --- MOCK DATA FOR NEW CHARTS ---
const queryVolumeData = {
labels: Array.from({ length: 24 }, (_, i) => `${i.toString().padStart(2, '0')}:00`),
datasets: [{
label: '查询量',
data: [12, 10, 8, 15, 25, 30, 45, 60, 80, 110, 150, 120, 100, 90, 85, 95, 120, 180, 220, 160, 110, 80, 50, 30],
borderColor: '#165DFF',
backgroundColor: 'rgba(22, 93, 255, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0,
}],
};
const responseTimeData = {
labels: ['7天前', '6天前', '5天前', '4天前', '3天前', '昨天', '今天'],
datasets: [{
label: '平均响应时间 (ms)',
data: [850, 860, 840, 900, 880, 920, 950],
borderColor: '#00B42A',
backgroundColor: 'rgba(0, 180, 42, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0,
}],
};
const errorChartData = {
labels: ['模型调用超时', '数据库连接错误', '用户认证失败', '其他'],
datasets: [{
data: [5, 2, 8, 3],
backgroundColor: ['#FF7D00', '#F53F3F', '#FADC19', '#86909C'],
borderWidth: 0,
}]
};
const costChartData = {
labels: ['gemini-2.5-pro', 'GPT-4', 'GLM-4.6', 'qwen3-max'],
datasets: [{
label: '今日预估成本 (USD)',
data: [12.5, 8.2, 0, 0],
backgroundColor: ['#165DFF', '#00B42A', '#86909C', '#36BFFA'],
borderRadius: 4,
}],
};
const commonLineChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: false }, x: { grid: { display: false } } }
};
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div className="flex space-x-2">
<button onClick={handleRefresh} disabled={isRefreshing} className="bg-white border border-gray-300 rounded-lg px-4 py-2 text-sm btn-effect disabled:opacity-50">
<i className={`fa ${isRefreshing ? 'fa-refresh fa-spin' : 'fa-refresh'} mr-1`}></i> {isRefreshing ? '刷新中...' : '刷新'}
</button>
<button onClick={() => setExportModalOpen(true)} className="bg-primary text-white rounded-lg px-4 py-2 text-sm btn-effect">
<i className="fa fa-download mr-1"></i>
</button>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm card-hover">
<h3 className="font-bold mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex items-center p-4 border rounded-lg"><span className="health-status-indicator health-status-healthy"></span><div><p className="font-medium"></p><p className="text-sm text-gray-500">: 45ms</p></div></div>
<div className="flex items-center p-4 border rounded-lg"><span className="health-status-indicator health-status-warning"></span><div><p className="font-medium"></p><p className="text-sm text-gray-500">: 120ms</p></div></div>
<div className="flex items-center p-4 border rounded-lg"><span className="health-status-indicator health-status-healthy"></span><div><p className="font-medium"></p><p className="text-sm text-gray-500">: 200ms</p></div></div>
<div className="flex items-center p-4 border rounded-lg"><span className="health-status-indicator health-status-danger"></span><div><p className="font-medium"></p><p className="text-sm text-gray-500">使: 95%</p></div></div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6">
<StatCard icon="fa-user-circle-o" title="总用户数" value="1,284" change={{ value: '12%', type: 'positive' }} color="primary" />
<StatCard icon="fa-plug" title="数据源" value="8" change={{ value: '', type: 'neutral' }} color="secondary" />
<StatCard icon="fa-search" title="今日查询" value="3,280" change={{ value: '8%', type: 'positive' }} color="success" />
<StatCard icon="fa-bolt" title="今日Token消耗" value="1.2M" change={{ value: '15%', type: 'negative' }} color="warning" />
<div className="cursor-pointer" onClick={onViewAbnormalLogs}>
<StatCard icon="fa-exclamation-triangle" title="异常日志" value="18" change={{ value: '2', type: 'negative' }} color="danger" />
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm card-hover">
<h3 className="font-bold mb-4"></h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h4 className="font-semibold text-sm text-center mb-2">24</h4>
<div className="h-64"><Line data={queryVolumeData} options={commonLineChartOptions} /></div>
</div>
<div>
<h4 className="font-semibold text-sm text-center mb-2"> (7)</h4>
<div className="h-64"><Line data={responseTimeData} options={commonLineChartOptions} /></div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-xl p-6 shadow-sm card-hover">
<h3 className="font-bold mb-4"></h3>
<div className="h-64 relative"><Doughnut data={errorChartData} options={{ responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } }} /></div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm card-hover">
<h3 className="font-bold mb-4"></h3>
<div className="h-64 relative">
<Bar data={costChartData} options={{ responsive: true, maintainAspectRatio: false, indexAxis: 'y' as const, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false } } } }} />
</div>
</div>
</div>
<AdminModal isOpen={isExportModalOpen} onClose={() => setExportModalOpen(false)} title="导出系统报告">
<form onSubmit={handleExport} className="space-y-4">
<div>
<label className="block text-gray-700 text-sm mb-2"></label>
<select name="report-type" className="w-full px-4 py-2 border border-gray-300 rounded-lg"><option value="system-overview"></option><option value="user-statistics"></option></select>
</div>
<div>
<label className="block text-gray-700 text-sm mb-2"></label>
<div className="grid grid-cols-2 gap-2"><input name="start-date" type="date" className="px-3 py-2 border rounded-lg" /><input name="end-date" type="date" className="px-3 py-2 border rounded-lg" /></div>
</div>
<div>
<label className="block text-gray-700 text-sm mb-2"></label>
<div className="flex space-x-4"><label className="flex items-center"><input type="radio" name="export-format" value="csv" defaultChecked className="mr-2" /><span>CSV</span></label><label className="flex items-center"><input type="radio" name="export-format" value="json" className="mr-2" /><span>JSON</span></label></div>
</div>
<div className="pt-4 flex justify-end space-x-2">
<button type="button" onClick={() => setExportModalOpen(false)} className="px-4 py-2 border rounded-lg"></button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</form>
</AdminModal>
</main>
);
};

@ -0,0 +1,397 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AdminModal } from './AdminModal';
import { LLMConfig } from '../../types';
import { llmConfigApi, LlmConfig as BackendLlmConfig } from '../../services/api';
export const LLMConfigPage: React.FC = () => {
const [llms, setLlms] = useState<LLMConfig[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<'add' | 'edit' | 'confirmDelete' | 'confirmToggleDisable' | null>(null);
const [currentItem, setCurrentItem] = useState<Partial<LLMConfig> | null>(null);
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Gemini'])); // 默认展开Gemini
// 加载大模型配置列表
useEffect(() => {
loadLlmConfigs();
}, []);
const loadLlmConfigs = async () => {
try {
setLoading(true);
const configs = await llmConfigApi.getList();
// 转换后端格式到前端格式
const frontendConfigs: LLMConfig[] = configs.map(config => ({
id: String(config.id),
name: config.name,
version: config.version,
apiKey: config.apiKey || '',
endpoint: config.apiUrl,
status: mapBackendStatusToFrontend(config.isDisabled, config.statusId),
}));
setLlms(frontendConfigs);
} catch (error) {
console.error('加载大模型配置失败:', error);
alert('加载大模型配置失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setLoading(false);
}
};
// 映射后端状态到前端状态
const mapBackendStatusToFrontend = (isDisabled: number, statusId: number): LLMConfig['status'] => {
if (isDisabled === 1) return 'disabled';
// 可以根据statusId进一步映射这里简化处理
return 'available';
};
// 映射前端状态到后端字段
const mapFrontendStatusToBackend = (status: LLMConfig['status']): { isDisabled: number, statusId: number } => {
if (status === 'disabled') {
return { isDisabled: 1, statusId: 1 };
}
return { isDisabled: 0, statusId: 1 }; // 默认statusId=1表示可用
};
// 按模型品牌分组(功能不变)
const groupedLlms = useMemo<Record<string, LLMConfig[]>>(() => {
const groups: Record<string, LLMConfig[]> = {};
llms.forEach(llm => {
if (!groups[llm.name]) {
groups[llm.name] = [];
}
groups[llm.name].push(llm);
});
return groups;
}, [llms]);
// 展开/折叠分组(功能不变)
const handleToggleGroup = (name: string) => {
setExpandedGroups(prev => {
const newSet = new Set(prev);
newSet.has(name) ? newSet.delete(name) : newSet.add(name);
return newSet;
});
};
// 测试模型连接暂时保持Mock逻辑后续可以实现真实测试
const handleTest = (id: string) => {
setLlms(prev => prev.map(llm => llm.id === id ? { ...llm, status: 'testing' } : llm));
setTimeout(() => {
setLlms(prev => prev.map(llm => {
if (llm.id === id) {
const statuses: LLMConfig['status'][] = ['available', 'unstable', 'unavailable'];
return { ...llm, status: statuses[Math.floor(Math.random() * 3)] };
}
return llm;
}));
}, 2000);
};
// 打开模态框(功能不变)
const openModal = (type: 'add' | 'edit' | 'confirmDelete', item?: Partial<LLMConfig>) => {
setCurrentItem(item || null);
setModal(type);
};
// 保存模型配置(连接后端)
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
const version = formData.get('version') as string;
const apiKey = formData.get('apiKey') as string;
const endpoint = formData.get('endpoint') as string;
// 验证必填字段
if (!name || !name.trim()) {
alert('模型名称不能为空');
return;
}
if (!version || !version.trim()) {
alert('模型版本不能为空');
return;
}
if (!apiKey || !apiKey.trim()) {
alert('API密钥不能为空');
return;
}
if (!endpoint || !endpoint.trim()) {
alert('API地址不能为空');
return;
}
try {
if (modal === 'add') {
// 创建新配置
const backendConfig: Partial<BackendLlmConfig> = {
name: name.trim(),
version: version.trim(),
apiKey: apiKey.trim(),
apiUrl: endpoint.trim(),
isDisabled: 0,
statusId: 1,
timeout: 60000, // 默认60秒
createUserId: Number(sessionStorage.getItem('userId') || '1'),
};
await llmConfigApi.create(backendConfig);
alert('添加成功');
await loadLlmConfigs(); // 重新加载列表
} else if (modal === 'edit' && currentItem) {
// 更新配置
const backendConfig: Partial<BackendLlmConfig> = {
id: Number(currentItem.id),
name,
version,
apiKey,
apiUrl: endpoint || '',
};
await llmConfigApi.update(backendConfig);
alert('更新成功');
await loadLlmConfigs(); // 重新加载列表
}
} catch (error) {
console.error('保存大模型配置失败:', error);
alert('保存失败: ' + (error instanceof Error ? error.message : '未知错误'));
return;
}
setModal(null);
};
// 确认删除(连接后端)
const confirmDelete = async () => {
if (currentItem) {
try {
await llmConfigApi.delete(Number(currentItem.id));
alert('删除成功');
await loadLlmConfigs(); // 重新加载列表
} catch (error) {
console.error('删除大模型配置失败:', error);
alert('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
}
setModal(null);
};
// 显示/隐藏密钥(功能不变)
const toggleKeyVisibility = (id: string) => {
setShowKeys(prev => ({ ...prev, [id]: !prev[id] }));
};
// 确认启用/禁用(连接后端)
const requestToggleDisable = (llm: LLMConfig) => {
setCurrentItem(llm);
setModal('confirmToggleDisable');
};
const confirmToggleDisable = async () => {
if (currentItem) {
try {
await llmConfigApi.toggle(Number(currentItem.id));
alert('操作成功');
await loadLlmConfigs(); // 重新加载列表
} catch (error) {
console.error('切换大模型状态失败:', error);
alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
}
setModal(null);
};
// 状态文本/样式映射(功能不变)
const getStatusText = (status: LLMConfig['status']) => ({
available: '可用',
unstable: '不稳定',
unavailable: '不可用',
testing: '测试中...',
disabled: '已禁用'
}[status]);
const getStatusClass = (status: LLMConfig['status']) => ({
available: 'text-success',
unstable: 'text-warning',
unavailable: 'text-danger',
testing: 'text-blue-500',
disabled: 'text-gray-500'
}[status]);
// 模态框标题(功能不变)
const isAddingVersion = modal === 'add' && !!currentItem?.name;
const modalTitle = modal === 'add'
? (isAddingVersion ? `${currentItem?.name} 添加新版本` : '添加新模型')
: '编辑模型配置';
if (loading) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<i className="fa fa-spinner fa-spin text-3xl text-primary mb-4"></i>
<p className="text-gray-500">...</p>
</div>
</div>
</main>
);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<button onClick={() => openModal('add')} className="bg-primary text-white rounded-lg px-4 py-2 text-sm btn-effect"><i className="fa fa-plus mr-1"></i> </button>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="divide-y divide-gray-200">
{Object.entries(groupedLlms).map(([name, versions]) => {
const isExpanded = expandedGroups.has(name);
return (
<div key={name}>
<div className="p-4 grid grid-cols-12 gap-4 items-center cursor-pointer hover:bg-gray-50" onClick={() => handleToggleGroup(name)}>
<div className="font-bold text-lg col-span-8 flex items-center">
<i className={`fa fa-chevron-right transition-transform duration-200 mr-3 ${isExpanded ? 'rotate-90' : ''}`}></i>
{name}
</div>
<div className="text-sm text-gray-500 col-span-2 text-right">{versions.length} </div>
<div className="col-span-2 flex justify-end">
<button
onClick={(e) => { e.stopPropagation(); openModal('add', { name }); }}
className="px-3 py-1 bg-primary/10 text-primary rounded-md text-xs hover:bg-primary/20 transition-colors"
title={`${name} 添加新版本`}
>
<i className="fa fa-plus mr-1"></i>
</button>
</div>
</div>
{isExpanded && (
<div className="pl-10 pr-4 pb-2 bg-gray-50/50">
{versions.map(llm => (
<div key={llm.id} className="py-4 grid grid-cols-1 md:grid-cols-6 gap-4 items-center border-t border-gray-200">
<div className="font-medium text-gray-800">{llm.version}</div>
<div className="md:col-span-3">
<div className="relative">
<input
type={showKeys[llm.id] ? 'text' : 'password'}
value={llm.apiKey}
className="w-full px-4 py-2 border rounded-lg bg-white"
readOnly
/>
<button
onClick={(e) => { e.stopPropagation(); toggleKeyVisibility(llm.id); }}
className="absolute right-3 top-2.5 text-gray-400"
>
<i className={`fa ${showKeys[llm.id] ? 'fa-eye-slash' : 'fa-eye'}`}></i>
</button>
</div>
</div>
<div><span className="text-xs text-gray-500">: <span className={`${getStatusClass(llm.status)} font-medium`}>{getStatusText(llm.status)}</span></span></div>
<div className="flex justify-end space-x-3 text-base">
<button
onClick={(e) => { e.stopPropagation(); handleTest(llm.id); }}
disabled={llm.status === 'testing' || llm.status === 'disabled'}
className="text-secondary disabled:text-gray-400"
title="测试连接"
>
<i className={`fa ${llm.status === 'testing' ? 'fa-spinner fa-spin' : 'fa-check-circle'}`}></i>
</button>
<button
onClick={(e) => { e.stopPropagation(); requestToggleDisable(llm); }}
className={llm.status === 'disabled' ? 'text-success' : 'text-warning'}
title={llm.status === 'disabled' ? '启用' : '禁用'}
>
<i className={`fa ${llm.status === 'disabled' ? 'fa-check-circle' : 'fa-ban'}`}></i>
</button>
<button
onClick={(e) => { e.stopPropagation(); openModal('edit', llm); }}
className="text-primary"
title="编辑"
>
<i className="fa fa-edit"></i>
</button>
<button
onClick={(e) => { e.stopPropagation(); openModal('confirmDelete', llm); }}
className="text-danger"
title="删除"
>
<i className="fa fa-trash"></i>
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
{/* 添加/编辑模态框(功能不变) */}
<AdminModal isOpen={modal === 'add' || modal === 'edit'} onClose={() => setModal(null)} title={modalTitle}>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm mb-1"></label>
<input
name="name"
defaultValue={currentItem?.name || ''}
required
readOnly={isAddingVersion}
className={`w-full px-3 py-2 border rounded-lg ${isAddingVersion ? 'bg-gray-100' : ''}`}
/>
</div>
<div>
<label className="block text-sm mb-1"></label>
<input
name="version"
defaultValue={currentItem?.version || ''}
placeholder="例如: gemini-2.5-pro、gpt-4"
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm mb-1">API Key</label>
<input
name="apiKey"
defaultValue={currentItem?.apiKey || ''}
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm mb-1">API ()</label>
<input
name="endpoint"
defaultValue={currentItem?.endpoint || ''}
placeholder="如无特殊需求可留空"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div className="flex justify-end space-x-2 pt-4">
<button type="button" onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</form>
</AdminModal>
{/* 删除确认模态框(功能不变) */}
<AdminModal isOpen={modal === 'confirmDelete'} onClose={() => setModal(null)} title="确认删除模型">
<p> "{currentItem?.name} ({currentItem?.version})" </p>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button>
<button onClick={confirmDelete} className="px-4 py-2 bg-danger text-white rounded-lg"></button>
</div>
</AdminModal>
{/* 启用/禁用确认模态框(功能不变) */}
<AdminModal isOpen={modal === 'confirmToggleDisable'} onClose={() => setModal(null)} title={`确认${currentItem?.status === 'disabled' ? '启用' : '禁用'}模型`}>
<p>{currentItem?.status === 'disabled' ? '启用' : '禁用'} "{currentItem?.name} ({currentItem?.version})" </p>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button>
<button
onClick={confirmToggleDisable}
className={`px-4 py-2 text-white rounded-lg ${currentItem?.status === 'disabled' ? 'bg-success' : 'bg-danger'}`}
>
</button>
</div>
</AdminModal>
</main>
);
};

@ -0,0 +1,129 @@
import React, { useState, useMemo } from 'react';
import { AdminModal } from './AdminModal';
import { AdminNotification, UserRole } from '../../types';
import { MOCK_ADMIN_NOTIFICATIONS } from '../../constants';
export const NotificationManagementPage: React.FC = () => {
const [notifications, setNotifications] = useState<AdminNotification[]>(MOCK_ADMIN_NOTIFICATIONS);
const [filters, setFilters] = useState({ search: '', role: '', priority: '' });
const [modal, setModal] = useState<'add' | 'edit' | 'delete' | null>(null);
const [currentItem, setCurrentItem] = useState<AdminNotification | null>(null);
const filteredNotifications = useMemo(() => {
return notifications.sort((a, b) => (b.pinned ? 1 : -1) - (a.pinned ? 1 : -1) || new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime()).filter(n =>
(n.title.toLowerCase().includes(filters.search.toLowerCase()) || n.content.toLowerCase().includes(filters.search.toLowerCase())) &&
(filters.role ? n.role === filters.role : true) &&
(filters.priority ? n.priority === filters.priority : true)
);
}, [notifications, filters]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFilters(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const openModal = (type: 'add' | 'edit' | 'delete', item?: AdminNotification) => {
setCurrentItem(item || null);
setModal(type);
};
const handleTogglePin = (item: AdminNotification) => {
setNotifications(prev => prev.map(n => n.id === item.id ? { ...n, pinned: !n.pinned } : n));
};
const handleSave = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (modal === 'add') {
const newNotif: AdminNotification = {
id: Date.now(),
title: formData.get('title') as string,
content: formData.get('content') as string,
role: formData.get('role') as any,
priority: formData.get('priority') as any,
pinned: formData.get('pinned') === 'on',
publisher: '系统管理员',
publishTime: new Date().toISOString().split('T')[0],
status: 'published',
};
setNotifications(prev => [newNotif, ...prev]);
} else if (modal === 'edit' && currentItem) {
const updated = {
...currentItem,
title: formData.get('title') as string,
content: formData.get('content') as string,
role: formData.get('role') as any,
priority: formData.get('priority') as any,
pinned: formData.get('pinned') === 'on',
};
setNotifications(prev => prev.map(n => n.id === currentItem.id ? updated : n));
}
setModal(null);
};
const confirmDelete = () => {
if (currentItem) {
setNotifications(prev => prev.filter(n => n.id !== currentItem.id));
}
setModal(null);
};
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<button onClick={() => openModal('add')} className="bg-primary text-white rounded-lg px-4 py-2 text-sm btn-effect"><i className="fa fa-plus mr-1"></i> </button>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1"><input name="search" onChange={handleFilterChange} placeholder="搜索标题/内容" className="w-full px-4 py-2 border rounded-lg" /></div>
<div className="w-full md:w-40"><select name="role" onChange={handleFilterChange} className="w-full px-4 py-2 border rounded-lg"><option value=""></option><option value="all"></option><option value="sys-admin"></option><option value="data-admin"></option><option value="normal-user"></option></select></div>
<div className="w-full md:w-32"><select name="priority" onChange={handleFilterChange} className="w-full px-4 py-2 border rounded-lg"><option value=""></option><option value="normal"></option><option value="important"></option><option value="urgent"></option></select></div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="bg-gray-50"><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th></tr></thead>
<tbody>
{filteredNotifications.map(n => (
<tr key={n.id} className={`border-b hover:bg-gray-50 ${n.pinned ? 'bg-yellow-50' : ''}`}>
<td className="px-6 py-4 font-medium">{n.title} {n.pinned && <i className="fa fa-thumb-tack text-yellow-500 ml-1"></i>}</td>
<td className="px-6 py-4">{n.role}</td>
<td className="px-6 py-4">{n.priority}</td>
<td className="px-6 py-4">{n.publisher}</td>
<td className="px-6 py-4">{n.publishTime}</td>
<td className="px-6 py-4">{n.status}</td>
<td className="px-6 py-4">
<button onClick={() => handleTogglePin(n)} className={`hover:underline mr-3 ${n.pinned ? 'text-yellow-600' : 'text-gray-500'}`} title={n.pinned ? '取消置顶' : '置顶'}><i className="fa fa-thumb-tack"></i></button>
<button onClick={() => openModal('edit', n)} className="text-primary hover:underline mr-3"></button>
<button onClick={() => openModal('delete', n)} className="text-danger hover:underline"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<AdminModal isOpen={modal === 'add' || modal === 'edit'} onClose={() => setModal(null)} title={modal === 'add' ? '发布新通知' : '编辑通知'}>
<form onSubmit={handleSave} className="space-y-4">
<div><label className="block text-sm mb-1"></label><input name="title" defaultValue={currentItem?.title || ''} required className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><textarea name="content" defaultValue={currentItem?.content || ''} required className="w-full px-3 py-2 border rounded-lg" rows={4}></textarea></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm mb-1"></label><select name="role" defaultValue={currentItem?.role || 'all'} className="w-full px-3 py-2 border rounded-lg"><option value="all"></option><option value="sys-admin"></option><option value="data-admin"></option><option value="normal-user"></option></select></div>
<div><label className="block text-sm mb-1"></label><select name="priority" defaultValue={currentItem?.priority || 'normal'} className="w-full px-3 py-2 border rounded-lg"><option value="normal"></option><option value="important"></option><option value="urgent"></option></select></div>
</div>
<div><label className="flex items-center"><input type="checkbox" name="pinned" defaultChecked={currentItem?.pinned} className="mr-2" /> </label></div>
<div className="flex justify-end space-x-2 pt-4"><button type="button" onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button></div>
</form>
</AdminModal>
<AdminModal isOpen={modal === 'delete'} onClose={() => setModal(null)} title="确认删除通知">
<p> "{currentItem?.title}" </p>
<div className="flex justify-end space-x-2 mt-4"><button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button onClick={confirmDelete} className="px-4 py-2 bg-danger text-white rounded-lg"></button></div>
</AdminModal>
</main>
);
};

@ -0,0 +1,130 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AdminModal } from './AdminModal';
import { SystemLog } from '../../types';
import { MOCK_SYSTEM_LOGS } from '../../constants';
interface SystemLogPageProps {
initialStatusFilter: string;
clearInitialFilter: () => void;
}
export const SystemLogPage: React.FC<SystemLogPageProps> = ({ initialStatusFilter, clearInitialFilter }) => {
const [logs, setLogs] = useState<SystemLog[]>(MOCK_SYSTEM_LOGS);
const [filters, setFilters] = useState({ startDate: '', endDate: '', user: '', action: '', status: initialStatusFilter });
const [isExportModalOpen, setExportModalOpen] = useState(false);
const [viewingLog, setViewingLog] = useState<SystemLog | null>(null);
useEffect(() => {
// Clear the initial filter from the parent so it's not reapplied on re-renders
if (initialStatusFilter) {
clearInitialFilter();
}
}, [initialStatusFilter, clearInitialFilter]);
const filteredLogs = useMemo(() => {
return logs.filter(log =>
(!filters.startDate || log.time >= filters.startDate) &&
(!filters.endDate || log.time <= `${filters.endDate} 23:59:59`) &&
(!filters.user || log.user.toLowerCase().includes(filters.user.toLowerCase())) &&
(!filters.action || log.action.toLowerCase().includes(filters.action.toLowerCase())) &&
(!filters.status || log.status === filters.status)
);
}, [logs, filters]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFilters(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const resetFilters = () => {
setFilters({ startDate: '', endDate: '', user: '', action: '', status: '' });
};
const handleExport = () => {
alert('日志已开始导出...');
setExportModalOpen(false);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<button onClick={() => setExportModalOpen(true)} className="bg-white border border-gray-300 rounded-lg px-4 py-2 text-sm btn-effect"><i className="fa fa-download mr-1"></i> </button>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="md:col-span-2 lg:col-span-2">
<label className="block text-sm mb-1"></label>
<div className="flex space-x-2"><input type="date" name="startDate" value={filters.startDate} onChange={handleFilterChange} className="flex-1 px-3 py-2 border rounded-lg" /><span className="flex items-center">-</span><input type="date" name="endDate" value={filters.endDate} onChange={handleFilterChange} className="flex-1 px-3 py-2 border rounded-lg" /></div>
</div>
<div>
<label className="block text-sm mb-1"></label>
<input type="text" name="user" value={filters.user} onChange={handleFilterChange} placeholder="输入用户名" className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm mb-1"></label>
<select name="status" value={filters.status} onChange={handleFilterChange} className="w-full px-3 py-2 border rounded-lg">
<option value=""></option>
<option value="success"></option>
<option value="failure"></option>
</select>
</div>
<div className="self-end"><button onClick={resetFilters} className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm btn-effect"></button></div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="bg-gray-50"><th className="px-6 py-3 text-left">ID</th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left">IP</th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th></tr></thead>
<tbody>
{filteredLogs.map(log => (
<tr key={log.id} className="border-b hover:bg-gray-50">
<td className="px-6 py-4">{log.id}</td>
<td className="px-6 py-4">{log.time}</td>
<td className="px-6 py-4">{log.user}</td>
<td className="px-6 py-4">{log.action}</td>
<td className="px-6 py-4">{log.model}</td>
<td className="px-6 py-4">{log.ip}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 text-xs rounded-full ${log.status === 'success' ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'}`}>{log.status === 'success' ? '成功' : '失败'}</span></td>
<td className="px-6 py-4">
{log.status === 'failure' && log.details && (
<button onClick={() => setViewingLog(log)} className="text-primary hover:underline"></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t flex justify-between items-center"><p className="text-sm text-gray-500"> {filteredLogs.length} {logs.length} </p></div>
</div>
<AdminModal isOpen={isExportModalOpen} onClose={() => setExportModalOpen(false)} title="导出系统日志">
<form onSubmit={(e) => { e.preventDefault(); handleExport(); }} className="space-y-4">
<div>
<label className="block text-gray-700 text-sm mb-2"></label>
<div className="grid grid-cols-2 gap-2"><input type="date" className="px-3 py-2 border rounded-lg" /><input type="date" className="px-3 py-2 border rounded-lg" /></div>
</div>
<div>
<label className="block text-gray-700 text-sm mb-2"></label>
<div className="flex space-x-4"><label className="flex items-center"><input type="radio" name="export-format" value="csv" defaultChecked className="mr-2" /><span>CSV</span></label><label className="flex items-center"><input type="radio" name="export-format" value="json" className="mr-2" /><span>JSON</span></label></div>
</div>
<div className="pt-4 flex justify-end space-x-2">
<button type="button" onClick={() => setExportModalOpen(false)} className="px-4 py-2 border rounded-lg"></button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</form>
</AdminModal>
<AdminModal isOpen={!!viewingLog} onClose={() => setViewingLog(null)} title={`日志详情 (ID: ${viewingLog?.id})`}>
<div>
<h4 className="font-bold mb-2"></h4>
<pre className="p-4 bg-gray-100 text-danger rounded-md text-sm overflow-x-auto whitespace-pre-wrap">
<code>{viewingLog?.details}</code>
</pre>
</div>
<div className="pt-4 flex justify-end">
<button type="button" onClick={() => setViewingLog(null)} className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</AdminModal>
</main>
);
};

@ -0,0 +1,219 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AdminModal } from './AdminModal';
import { operationLogApi, userApi } from '../../services/api';
interface SystemLog {
id: string;
time: string;
user: string;
action: string;
module: string;
status: '成功' | '失败';
ip: string;
details: string;
}
interface SystemLogPageProps {
initialStatusFilter: string;
clearInitialFilter: () => void;
}
export const SystemLogPageWithAPI: React.FC<SystemLogPageProps> = ({ initialStatusFilter, clearInitialFilter }) => {
const [logs, setLogs] = useState<SystemLog[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ startDate: '', endDate: '', user: '', action: '', status: initialStatusFilter });
const [isExportModalOpen, setExportModalOpen] = useState(false);
const [viewingLog, setViewingLog] = useState<SystemLog | null>(null);
useEffect(() => {
if (initialStatusFilter) {
clearInitialFilter();
}
}, [initialStatusFilter, clearInitialFilter]);
useEffect(() => {
loadLogs();
}, []);
const loadLogs = async () => {
try {
setLoading(true);
const operationLogs = await operationLogApi.getList();
const systemLogs: SystemLog[] = await Promise.all(
operationLogs.map(async (log) => {
let username = 'Unknown';
try {
const user = await userApi.getById(log.userId);
username = user.username;
} catch (error) {
console.error('获取用户信息失败:', error);
}
return {
id: String(log.id),
time: new Date(log.operateTime).toLocaleString(),
user: username,
action: log.operateType,
module: log.module,
status: log.status === 1 ? '成功' : '失败',
ip: log.ip,
details: log.operateDesc,
};
})
);
setLogs(systemLogs);
} catch (error) {
console.error('加载系统日志失败:', error);
} finally {
setLoading(false);
}
};
const filteredLogs = useMemo(() => {
return logs.filter(log =>
(!filters.startDate || log.time >= filters.startDate) &&
(!filters.endDate || log.time <= `${filters.endDate} 23:59:59`) &&
(!filters.user || log.user.toLowerCase().includes(filters.user.toLowerCase())) &&
(!filters.action || log.action.toLowerCase().includes(filters.action.toLowerCase())) &&
(!filters.status || log.status === filters.status)
);
}, [logs, filters]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFilters(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const resetFilters = () => {
setFilters({ startDate: '', endDate: '', user: '', action: '', status: '' });
};
const handleExport = () => {
const csvContent = "data:text/csv;charset=utf-8,"
+ "时间,用户,操作,模块,状态,IP,详情\n"
+ filteredLogs.map(log =>
`${log.time},${log.user},${log.action},${log.module},${log.status},${log.ip},"${log.details}"`
).join('\n');
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `system_logs_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setExportModalOpen(false);
alert('日志已导出');
}
if (loading) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6 flex items-center justify-center">
<div className="text-center">
<div className="dot-flashing"></div>
<p className="mt-4 text-gray-500">...</p>
</div>
</main>
);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<button onClick={() => setExportModalOpen(true)} className="bg-white border border-gray-300 rounded-lg px-4 py-2 text-sm btn-effect"><i className="fa fa-download mr-1"></i> </button>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border space-y-3">
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<input type="date" name="startDate" value={filters.startDate} onChange={handleFilterChange} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="开始日期" />
<input type="date" name="endDate" value={filters.endDate} onChange={handleFilterChange} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="结束日期" />
<input type="text" name="user" value={filters.user} onChange={handleFilterChange} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="用户名" />
<input type="text" name="action" value={filters.action} onChange={handleFilterChange} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="操作类型" />
<select name="status" value={filters.status} onChange={handleFilterChange} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value=""></option>
<option value="成功"></option>
<option value="失败"></option>
</select>
</div>
<button onClick={resetFilters} className="px-4 py-2 bg-gray-200 rounded-lg text-sm btn-effect"><i className="fa fa-refresh mr-1"></i> </button>
</div>
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-left font-semibold">IP</th>
<th className="px-4 py-3 text-center font-semibold"></th>
</tr>
</thead>
<tbody>
{filteredLogs.length > 0 ? filteredLogs.map(log => (
<tr key={log.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3">{log.time}</td>
<td className="px-4 py-3">{log.user}</td>
<td className="px-4 py-3">{log.action}</td>
<td className="px-4 py-3">{log.module}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs ${log.status === '成功' ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'}`}>
{log.status}
</span>
</td>
<td className="px-4 py-3">{log.ip}</td>
<td className="px-4 py-3 text-center">
<button onClick={() => setViewingLog(log)} className="text-primary hover:underline text-xs"></button>
</td>
</tr>
)) : (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
<i className="fa fa-inbox text-3xl mb-2 block"></i>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<AdminModal isOpen={isExportModalOpen} onClose={() => setExportModalOpen(false)} title="导出日志">
<p className="text-sm text-gray-700 mb-4"> {filteredLogs.length} </p>
<div className="flex justify-end space-x-3">
<button onClick={() => setExportModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleExport} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</AdminModal>
<AdminModal isOpen={!!viewingLog} onClose={() => setViewingLog(null)} title="日志详情">
{viewingLog && (
<div className="space-y-3 text-sm">
<div><span className="font-semibold"></span>{viewingLog.time}</div>
<div><span className="font-semibold"></span>{viewingLog.user}</div>
<div><span className="font-semibold"></span>{viewingLog.action}</div>
<div><span className="font-semibold"></span>{viewingLog.module}</div>
<div><span className="font-semibold"></span>
<span className={`ml-2 px-2 py-1 rounded text-xs ${viewingLog.status === '成功' ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'}`}>
{viewingLog.status}
</span>
</div>
<div><span className="font-semibold">IP</span>{viewingLog.ip}</div>
<div><span className="font-semibold"></span>{viewingLog.details}</div>
</div>
)}
</AdminModal>
</main>
);
};

@ -0,0 +1,368 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { AdminModal } from './AdminModal';
import { AdminUser, UserRole } from '../../types';
import { userApi, User } from '../../services/api';
export const UserManagementPage: React.FC = () => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ search: '', role: '', status: '' });
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
const [modal, setModal] = useState<'add' | 'edit' | 'confirmDelete' | 'confirmBatch' | 'confirmResetPassword' | 'confirmToggleStatus' | null>(null);
const [userToProcess, setUserToProcess] = useState<AdminUser | null>(null);
const [batchAction, setBatchAction] = useState<'enable' | 'disable' | 'delete' | ''>('');
// 加载用户列表
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const userList = await userApi.getList();
// 转换后端User格式到前端AdminUser格式
const adminUsers: AdminUser[] = userList.map(user => ({
id: user.id,
username: user.username,
role: mapRoleIdToRole(user.roleId),
email: user.email,
regTime: new Date().toISOString().split('T')[0], // 后端没有返回createTime暂时用当前日期
status: user.status === 1 ? 'active' : 'disabled',
}));
setUsers(adminUsers);
} catch (error) {
console.error('加载用户列表失败:', error);
alert('加载用户列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setLoading(false);
}
};
// 映射角色ID到前端角色类型
const mapRoleIdToRole = (roleId: number): UserRole => {
if (roleId === 1) return 'sys-admin';
if (roleId === 2) return 'data-admin';
return 'normal-user';
};
// 映射前端角色到后端角色ID
const mapRoleToRoleId = (role: UserRole): number => {
if (role === 'sys-admin') return 1;
if (role === 'data-admin') return 2;
return 3;
};
const filteredUsers = useMemo(() => {
return users.filter(user =>
(user.username.toLowerCase().includes(filters.search.toLowerCase()) || user.email.toLowerCase().includes(filters.search.toLowerCase())) &&
(filters.role ? user.role === filters.role : true) &&
(filters.status ? user.status === filters.status : true)
);
}, [users, filters]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFilters(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedUserIds(new Set(filteredUsers.map(u => u.id)));
} else {
setSelectedUserIds(new Set());
}
};
const handleSelectUser = (id: number, checked: boolean) => {
const newSet = new Set(selectedUserIds);
if (checked) {
newSet.add(id);
} else {
newSet.delete(id);
}
setSelectedUserIds(newSet);
};
const requestDeleteUser = useCallback((user: AdminUser) => {
setUserToProcess(user);
setModal('confirmDelete');
}, []);
const confirmDeleteUser = async () => {
if (userToProcess) {
try {
await userApi.delete(userToProcess.id);
setUsers(prev => prev.filter(u => u.id !== userToProcess.id));
alert('删除成功');
} catch (error) {
console.error('删除用户失败:', error);
alert('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
}
setModal(null);
};
const requestToggleUserStatus = useCallback((user: AdminUser) => {
setUserToProcess(user);
setModal('confirmToggleStatus');
}, []);
const confirmToggleUserStatus = async () => {
if(userToProcess) {
try {
const newStatus = userToProcess.status === 'active' ? 0 : 1;
await userApi.update({
id: userToProcess.id,
status: newStatus,
});
setUsers(prev => prev.map(u => u.id === userToProcess.id ? { ...u, status: u.status === 'active' ? 'disabled' : 'active' } : u));
alert('操作成功');
} catch (error) {
console.error('更新用户状态失败:', error);
alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
}
setModal(null);
};
const handleApplyBatchAction = () => {
if (!batchAction || selectedUserIds.size === 0) return;
setModal('confirmBatch');
};
const confirmBatchAction = async () => {
try {
if (batchAction === 'delete') {
// 批量删除
const deletePromises = Array.from(selectedUserIds).map(id => userApi.delete(id));
await Promise.all(deletePromises);
setUsers(prev => prev.filter(u => !selectedUserIds.has(u.id)));
} else {
// 批量启用/禁用
const newStatus = batchAction === 'enable' ? 1 : 0;
const updatePromises = Array.from(selectedUserIds).map(id =>
userApi.update({ id, status: newStatus })
);
await Promise.all(updatePromises);
setUsers(prev => prev.map(u => selectedUserIds.has(u.id) ? { ...u, status: batchAction === 'enable' ? 'active' : 'disabled' } : u));
}
alert('批量操作成功');
} catch (error) {
console.error('批量操作失败:', error);
alert('批量操作失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
setSelectedUserIds(new Set());
setModal(null);
};
const requestEditUser = useCallback((user: AdminUser) => {
setUserToProcess(user);
setModal('edit');
}, []);
const requestAddUser = () => {
setUserToProcess(null);
setModal('add');
};
const handleSaveUser = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
try {
if (modal === 'add') {
const password = formData.get('password') as string;
if (password.length < 6) {
alert('密码至少需要6位');
return;
}
const role = formData.get('role') as UserRole;
const phonenumber = formData.get('phonenumber') as string;
await userApi.create({
username: formData.get('username') as string,
email: formData.get('email') as string,
password: password,
roleId: mapRoleToRoleId(role),
status: 1,
phonenumber,
});
alert('添加成功');
await loadUsers(); // 重新加载列表
} else if (modal === 'edit' && userToProcess) {
const phonenumber = formData.get('phonenumber') as string;
await userApi.update({
id: userToProcess.id,
username: formData.get('username') as string,
email: formData.get('email') as string,
phonenumber,
roleId: mapRoleToRoleId(formData.get('role') as UserRole),
});
alert('更新成功');
await loadUsers(); // 重新加载列表
}
} catch (error) {
console.error('保存用户失败:', error);
alert('保存失败: ' + (error instanceof Error ? error.message : '未知错误'));
return;
}
setModal(null);
}, [modal, userToProcess]);
const requestResetPassword = useCallback((user: AdminUser) => {
setUserToProcess(user);
setModal('confirmResetPassword');
}, []);
const confirmResetPassword = () => {
if (userToProcess) {
alert(`已向用户 ${userToProcess.username} (${userToProcess.email}) 发送密码重置邮件。`);
}
setModal(null);
};
const getRoleName = (role: UserRole) => ({ 'sys-admin': '系统管理员', 'data-admin': '数据管理员', 'normal-user': '普通用户' }[role]);
const getRoleClass = (role: UserRole) => ({ 'sys-admin': 'bg-primary/10 text-primary', 'data-admin': 'bg-success/10 text-success', 'normal-user': 'bg-secondary/10 text-secondary' }[role]);
if (loading) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<i className="fa fa-spinner fa-spin text-3xl text-primary mb-4"></i>
<p className="text-gray-500">...</p>
</div>
</div>
</main>
);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex flex-col md:flex-row gap-4 mb-4">
<div className="flex-1">
<div className="relative"><i className="fa fa-search absolute left-3 top-3 text-gray-400"></i><input type="text" name="search" value={filters.search} onChange={handleFilterChange} placeholder="搜索用户名/邮箱" className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30 focus:border-primary" /></div>
</div>
<div className="w-full md:w-40"><select name="role" value={filters.role} onChange={handleFilterChange} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30 focus:border-primary"><option value=""></option><option value="sys-admin"></option><option value="normal-user"></option><option value="data-admin"></option></select></div>
<div className="w-full md:w-32"><select name="status" value={filters.status} onChange={handleFilterChange} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30 focus:border-primary"><option value=""></option><option value="active"></option><option value="disabled"></option></select></div>
<button onClick={requestAddUser} className="bg-primary text-white rounded-lg px-4 py-2 text-sm btn-effect"><i className="fa fa-plus mr-1"></i> </button>
</div>
<div className="border-t pt-4 flex items-center space-x-4">
<label className="flex items-center text-sm font-medium cursor-pointer"><input type="checkbox" onChange={handleSelectAll} checked={filteredUsers.length > 0 && selectedUserIds.size === filteredUsers.length} className="w-4 h-4 text-primary focus:ring-primary/30 mr-2" /><span> ({selectedUserIds.size})</span></label>
<select value={batchAction} onChange={(e) => setBatchAction(e.target.value as any)} className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary"><option value=""></option><option value="enable"></option><option value="disable"></option><option value="delete"></option></select>
<button onClick={handleApplyBatchAction} className="bg-primary text-white px-4 py-2 rounded-lg text-sm btn-effect"></button>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="px-6 py-3 text-left"><input type="checkbox" onChange={handleSelectAll} checked={filteredUsers.length > 0 && selectedUserIds.size === filteredUsers.length} className="w-4 h-4 text-primary focus:ring-primary/30" /></th>
<th className="px-6 py-3 text-left"></th>
<th className="px-6 py-3 text-left"></th>
<th className="px-6 py-3 text-left"></th>
<th className="px-6 py-3 text-left"></th>
<th className="px-6 py-3 text-left"></th>
<th className="px-6 py-3 text-left"></th>
</tr>
</thead>
<tbody>
{filteredUsers.map(user => (
<tr key={user.id} className="border-b hover:bg-gray-50">
<td className="px-6 py-4"><input type="checkbox" checked={selectedUserIds.has(user.id)} onChange={(e) => handleSelectUser(user.id, e.target.checked)} className="w-4 h-4 text-primary focus:ring-primary/30" /></td>
<td className="px-6 py-4">{user.username}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded-full text-xs ${getRoleClass(user.role)}`}>{getRoleName(user.role)}</span></td>
<td className="px-6 py-4">{user.email}</td>
<td className="px-6 py-4">{user.regTime}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded-full text-xs ${user.status === 'active' ? 'bg-success/10 text-success' : 'bg-gray-200 text-gray-700'}`}>{user.status === 'active' ? '正常' : '禁用'}</span></td>
<td className="px-6 py-4">
<div className="flex space-x-3">
<button onClick={() => requestEditUser(user)} className="text-primary hover:text-primary/80" title="编辑"><i className="fa fa-edit"></i></button>
<button onClick={() => requestResetPassword(user)} className="text-indigo-500 hover:text-indigo-400" title="重置密码"><i className="fa fa-key"></i></button>
<button onClick={() => requestToggleUserStatus(user)} className="text-secondary hover:text-secondary/80" title={user.status === 'active' ? '禁用' : '启用'}><i className={`fa ${user.status === 'active' ? 'fa-ban' : 'fa-check'}`}></i></button>
<button onClick={() => requestDeleteUser(user)} className="text-danger hover:text-danger/80" title="删除"><i className="fa fa-trash"></i></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t flex justify-between items-center"><p className="text-sm text-gray-500"> {filteredUsers.length} {users.length} </p></div>
</div>
<AdminModal isOpen={modal === 'edit' || modal === 'add'} onClose={() => setModal(null)} title={modal === 'add' ? '添加新用户' : '编辑用户信息'}>
<form onSubmit={handleSaveUser} className="space-y-4">
<div><label className="block text-sm mb-1"></label><input name="username" defaultValue={userToProcess?.username || ''} required className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><input name="email" type="email" defaultValue={userToProcess?.email || ''} required className="w-full px-3 py-2 border rounded-lg" /></div>
{/* 手机号 */}
<div>
<label className="block text-sm mb-1"></label>
<input
name="phonenumber"
pattern="[0-9]{11}"
title="请输入 11 位数字手机号码"
defaultValue={userToProcess?.phonenumber || ''}
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{modal === 'add' && <>
<div><label className="block text-sm mb-1"></label><input name="password" type="password" required className="w-full px-3 py-2 border rounded-lg" /></div>
</>}
<div>
<label className="block text-sm mb-1"></label>
<select name="role" defaultValue={userToProcess?.role || 'normal-user'} className="w-full px-3 py-2 border rounded-lg">
<option value="data-admin"></option>
<option value="normal-user"></option>
</select>
</div>
<div className="flex justify-end space-x-2 pt-4">
<button type="button" onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</form>
</AdminModal>
<AdminModal isOpen={modal === 'confirmDelete'} onClose={() => setModal(null)} title="确认删除用户">
<p> "{userToProcess?.username}" </p>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg text-sm"></button>
<button onClick={confirmDeleteUser} className="px-4 py-2 bg-danger text-white rounded-lg text-sm"></button>
</div>
</AdminModal>
<AdminModal isOpen={modal === 'confirmResetPassword'} onClose={() => setModal(null)} title="确认重置密码">
<p> "{userToProcess?.username}" </p>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button>
<button onClick={confirmResetPassword} className="px-4 py-2 bg-warning text-white rounded-lg"></button>
</div>
</AdminModal>
<AdminModal isOpen={modal === 'confirmToggleStatus'} onClose={() => setModal(null)} title={`确认${userToProcess?.status === 'active' ? '禁用' : '启用'}用户`}>
<p>{userToProcess?.status === 'active' ? '禁用' : '启用'} "{userToProcess?.username}" </p>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg text-sm"></button>
<button onClick={confirmToggleUserStatus} className={`px-4 py-2 text-white rounded-lg text-sm ${userToProcess?.status === 'active' ? 'bg-danger' : 'bg-success'}`}></button>
</div>
</AdminModal>
<AdminModal isOpen={modal === 'confirmBatch'} onClose={() => setModal(null)} title="确认批量操作">
<p> {selectedUserIds.size} "{ {enable: '启用', disable: '禁用', delete: '删除'}[batchAction] }" </p>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg text-sm"></button>
<button onClick={confirmBatchAction} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</AdminModal>
</main>
);
};

@ -0,0 +1,169 @@
import React, { useState, useMemo } from 'react';
import { AdminModal } from '../admin/AdminModal';
import { ConnectionLog } from '../../types';
import { MOCK_CONNECTION_LOGS } from '../../constants';
export const ConnectionLogPage: React.FC = () => {
const [logs, setLogs] = useState<ConnectionLog[]>(MOCK_CONNECTION_LOGS);
const [isExportModalOpen, setExportModalOpen] = useState(false);
const [viewingLog, setViewingLog] = useState<ConnectionLog | null>(null);
// 新增搜索相关状态
const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<'all' | 'time' | 'datasource' | 'status'>('all');
const getStatusClass = (status: '成功' | '失败') => {
return status === '成功' ? 'text-success' : 'text-danger';
};
const handleExport = () => {
alert('日志已开始导出...');
setExportModalOpen(false);
};
// 搜索筛选逻辑
const filteredLogs = useMemo(() => {
if (!searchTerm.trim()) return logs;
const term = searchTerm.toLowerCase();
return logs.filter(log => {
switch (searchType) {
case 'time':
return new Date(log.time).toLocaleString().toLowerCase().includes(term);
case 'datasource':
return log.datasource.toLowerCase().includes(term);
case 'status':
return log.status.toLowerCase().includes(term);
default: // 'all'
return (
new Date(log.time).toLocaleString().toLowerCase().includes(term) ||
log.datasource.toLowerCase().includes(term) ||
log.status.toLowerCase().includes(term)
);
}
});
}, [logs, searchTerm, searchType]);
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
{/* 修改顶部区域,添加搜索功能 */}
<div className="flex justify-between items-center">
<div className="flex items-center space-x-2 w-3/4">
<input
type="text"
placeholder="输入搜索内容即可进行查询...(日期输入格式为 “2023/6/15 16:42:30 ”,英文字符)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-lg bg-white appearance-none pr-8 font-bold"
style={{
backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'16\' height=\'16\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23666\' strokeWidth=\'2\' strokeLinecap=\'round\' strokeLinejoin=\'round\'%3E%3Cpolyline points=\'6 9 12 15 18 9\'/%3E%3C/svg%3E")',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 8px center', // 关键从右侧向左移动8px默认是最右侧
backgroundSize: '16px'
}}
>
<option value="all"></option>
<option value="time"></option>
<option value="datasource"></option>
<option value="status"></option>
</select>
</div>
{/* 导出按钮靠右放置 */}
<button
onClick={() => setExportModalOpen(true)}
className="bg-white border border-gray-300 rounded-lg px-4 py-2 text-sm btn-effect"
>
<i className="fa fa-download mr-1"></i>
</button>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
</tr>
</thead>
<tbody>
{/* 使用筛选后的日志列表 */}
{filteredLogs.map(log => (
<tr key={log.id} className="border-b hover:bg-gray-50">
<td className="px-6 py-4">{new Date(log.time).toLocaleString()}</td>
<td className="px-6 py-4">{log.datasource}</td>
<td className={`px-6 py-4 ${getStatusClass(log.status)}`}>{log.status}</td>
<td className="px-6 py-4">
{log.status === '失败' && log.details && (
<button
onClick={() => setViewingLog(log)}
className="text-primary hover:underline"
>
</button>
)}
</td>
</tr>
))}
{/* 无搜索结果时显示 */}
{filteredLogs.length === 0 && (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<AdminModal isOpen={isExportModalOpen} onClose={() => setExportModalOpen(false)} title="导出连接日志">
<form onSubmit={(e) => { e.preventDefault(); handleExport(); }} className="space-y-4">
<div>
<label className="block text-gray-700 text-sm mb-2"></label>
<div className="grid grid-cols-2 gap-2">
<input type="date" className="px-3 py-2 border rounded-lg" />
<input type="date" className="px-3 py-2 border rounded-lg" />
</div>
</div>
<div>
<label className="block text-gray-700 text-sm mb-2"></label>
<div className="flex space-x-4">
<label className="flex items-center">
<input type="radio" name="export-format" value="csv" defaultChecked className="mr-2" />
<span>CSV</span>
</label>
<label className="flex items-center">
<input type="radio" name="export-format" value="json" className="mr-2" />
<span>JSON</span>
</label>
</div>
</div>
<div className="pt-4 flex justify-end space-x-2">
<button type="button" onClick={() => setExportModalOpen(false)} className="px-4 py-2 border rounded-lg"></button>
<button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</form>
</AdminModal>
<AdminModal isOpen={!!viewingLog} onClose={() => setViewingLog(null)} title={`连接失败详情 (${viewingLog?.datasource})`}>
<div>
<h4 className="font-bold mb-2"></h4>
<pre className="p-4 bg-gray-100 text-danger rounded-md text-sm overflow-x-auto whitespace-pre-wrap">
<code>{viewingLog?.details}</code>
</pre>
</div>
<div className="pt-4 flex justify-end">
<button type="button" onClick={() => setViewingLog(null)} className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</AdminModal>
</main>
);
};

@ -0,0 +1,211 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AdminModal } from '../admin/AdminModal';
import { dbConnectionLogApi, dbConnectionApi } from '../../services/api';
interface ConnectionLog {
id: string;
time: string;
datasource: string;
status: '成功' | '失败';
details: string;
}
export const ConnectionLogPageWithAPI: React.FC = () => {
const [logs, setLogs] = useState<ConnectionLog[]>([]);
const [loading, setLoading] = useState(true);
const [isExportModalOpen, setExportModalOpen] = useState(false);
const [viewingLog, setViewingLog] = useState<ConnectionLog | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<'all' | 'time' | 'datasource' | 'status'>('all');
useEffect(() => {
loadLogs();
}, []);
const loadLogs = async () => {
try {
setLoading(true);
const connectionLogs = await dbConnectionLogApi.getList();
const logData: ConnectionLog[] = await Promise.all(
connectionLogs.map(async (log) => {
let datasourceName = 'Unknown';
try {
const connection = await dbConnectionApi.getById(log.dbConnectionId);
datasourceName = connection.name;
} catch (error) {
console.error('获取数据源信息失败:', error);
}
return {
id: String(log.id),
time: new Date(log.connectTime).toLocaleString(),
datasource: datasourceName,
status: log.status === 1 ? '成功' : '失败',
details: log.errorMessage || '连接成功',
};
})
);
setLogs(logData);
} catch (error) {
console.error('加载连接日志失败:', error);
} finally {
setLoading(false);
}
};
const getStatusClass = (status: '成功' | '失败') => {
return status === '成功' ? 'text-success' : 'text-danger';
};
const handleExport = () => {
const csvContent = "data:text/csv;charset=utf-8,"
+ "时间,数据源,状态,详情\n"
+ filteredLogs.map(log =>
`${log.time},${log.datasource},${log.status},"${log.details}"`
).join('\n');
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `connection_logs_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setExportModalOpen(false);
alert('日志已导出');
};
const filteredLogs = useMemo(() => {
if (!searchTerm.trim()) return logs;
const term = searchTerm.toLowerCase();
return logs.filter(log => {
switch (searchType) {
case 'time':
return new Date(log.time).toLocaleString().toLowerCase().includes(term);
case 'datasource':
return log.datasource.toLowerCase().includes(term);
case 'status':
return log.status.toLowerCase().includes(term);
default:
return (
new Date(log.time).toLocaleString().toLowerCase().includes(term) ||
log.datasource.toLowerCase().includes(term) ||
log.status.toLowerCase().includes(term)
);
}
});
}, [logs, searchTerm, searchType]);
if (loading) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6 flex items-center justify-center">
<div className="text-center">
<div className="dot-flashing"></div>
<p className="mt-4 text-gray-500">...</p>
</div>
</main>
);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-2 w-3/4">
<div className="relative flex-1">
<i className="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="搜索日志..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/30"
/>
</div>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="all"></option>
<option value="time"></option>
<option value="datasource"></option>
<option value="status"></option>
</select>
</div>
<button onClick={() => setExportModalOpen(true)} className="bg-white border border-gray-300 rounded-lg px-4 py-2 text-sm btn-effect">
<i className="fa fa-download mr-1"></i>
</button>
</div>
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-left font-semibold"></th>
<th className="px-4 py-3 text-center font-semibold"></th>
</tr>
</thead>
<tbody>
{filteredLogs.length > 0 ? filteredLogs.map(log => (
<tr key={log.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3">{log.time}</td>
<td className="px-4 py-3">{log.datasource}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs ${log.status === '成功' ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'}`}>
{log.status}
</span>
</td>
<td className="px-4 py-3 text-center">
<button onClick={() => setViewingLog(log)} className="text-primary hover:underline text-xs"></button>
</td>
</tr>
)) : (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
<i className="fa fa-inbox text-3xl mb-2 block"></i>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<AdminModal isOpen={isExportModalOpen} onClose={() => setExportModalOpen(false)} title="导出日志">
<p className="text-sm text-gray-700 mb-4"> {filteredLogs.length} </p>
<div className="flex justify-end space-x-3">
<button onClick={() => setExportModalOpen(false)} className="px-4 py-2 border border-gray-300 rounded-lg text-sm"></button>
<button onClick={handleExport} className="px-4 py-2 bg-primary text-white rounded-lg text-sm"></button>
</div>
</AdminModal>
<AdminModal isOpen={!!viewingLog} onClose={() => setViewingLog(null)} title="日志详情">
{viewingLog && (
<div className="space-y-3 text-sm">
<div><span className="font-semibold"></span>{viewingLog.time}</div>
<div><span className="font-semibold"></span>{viewingLog.datasource}</div>
<div><span className="font-semibold"></span>
<span className={`ml-2 px-2 py-1 rounded text-xs ${viewingLog.status === '成功' ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'}`}>
{viewingLog.status}
</span>
</div>
<div><span className="font-semibold"></span>{viewingLog.details}</div>
</div>
)}
</AdminModal>
</main>
);
};

@ -0,0 +1,126 @@
import React from 'react';
import { DataAdminPageType } from '../../types';
import { MOCK_DATASOURCES, MOCK_CONNECTION_LOGS, MOCK_PERMISSION_LOGS, MOCK_QUERY_LOAD } from '../../constants';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js';
import { Pie, Bar } from 'react-chartjs-2';
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement);
// Reusable StatCard component specific for this dashboard
const StatCard: React.FC<{ title: string; value: string; icon: string; color: string; onClick: () => void; }> = ({ title, value, icon, color, onClick }) => (
<div onClick={onClick} className="bg-white p-6 rounded-xl shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-1">
<div className="flex justify-between items-start">
<div>
<p className="text-sm text-gray-500">{title}</p>
<h3 className="text-3xl font-bold mt-2">{value}</h3>
</div>
<div className={`w-12 h-12 rounded-full ${color} flex items-center justify-center text-white`}>
<i className={`fa ${icon} text-xl`}></i>
</div>
</div>
</div>
);
// Helper to format timestamp into a relative string
const formatTimeAgo = (timestamp: string): string => {
const now = new Date();
const then = new Date(timestamp);
const diffInSeconds = Math.round((now.getTime() - then.getTime()) / 1000);
if (diffInSeconds < 60) return `${diffInSeconds}秒前`;
const diffInMinutes = Math.round(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}分钟前`;
const diffInHours = Math.round(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}小时前`;
const diffInDays = Math.round(diffInHours / 24);
return `${diffInDays}天前`;
};
export const DataAdminDashboardPage: React.FC<{ setActivePage: (page: DataAdminPageType) => void; }> = ({ setActivePage }) => {
// Data processing for cards and charts
const connectedCount = MOCK_DATASOURCES.filter(ds => ds.status === 'connected').length;
const errorCount = MOCK_CONNECTION_LOGS.filter(log => log.status === '失败').length;
const pendingRequests = 3; // Mock value
const healthStatusCounts = MOCK_DATASOURCES.reduce((acc, ds) => {
const statusMap = { connected: '已连接', disconnected: '未连接', error: '错误', testing: '测试中', disabled: '已禁用' };
const status = statusMap[ds.status];
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const healthStatusData = {
labels: Object.keys(healthStatusCounts),
datasets: [{
data: Object.values(healthStatusCounts),
backgroundColor: ['#00B42A', '#86909C', '#F53F3F', '#36BFFA', '#C9CDD4'],
borderWidth: 0,
}],
};
const queryLoadData = {
labels: MOCK_QUERY_LOAD.labels,
datasets: [{
label: '查询量',
data: MOCK_QUERY_LOAD.data,
backgroundColor: '#165DFF',
borderRadius: 4,
}],
};
const recentFailures = MOCK_CONNECTION_LOGS.filter(log => log.status === '失败').slice(0, 5);
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6 bg-neutral">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard title="数据源总数" value={`${MOCK_DATASOURCES.length}`} icon="fa-database" color="bg-primary" onClick={() => setActivePage('datasource')} />
<StatCard title="当前连接数" value={`${connectedCount}`} icon="fa-check-circle" color="bg-success" onClick={() => setActivePage('datasource')} />
<StatCard title="连接错误数" value={`${errorCount}`} icon="fa-exclamation-triangle" color="bg-danger" onClick={() => setActivePage('connection-log')} />
<StatCard title="待处理权限请求" value={`${pendingRequests}`} icon="fa-key" color="bg-warning" onClick={() => setActivePage('user-permission')} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div className="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm">
<h3 className="font-bold mb-4"></h3>
<div className="h-64 relative"><Pie data={healthStatusData} options={{ responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } }} /></div>
</div>
<div className="lg:col-span-3 bg-white rounded-xl p-6 shadow-sm">
<h3 className="font-bold mb-4"> Top 5</h3>
<div className="h-64 relative"><Bar data={queryLoadData} options={{ responsive: true, maintainAspectRatio: false, indexAxis: 'y' as const, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false } } } }} /></div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-xl p-6 shadow-sm">
<h3 className="font-bold mb-4"></h3>
<div className="space-y-3">
{recentFailures.length > 0 ? recentFailures.map(log => (
<div key={log.id} className="flex justify-between items-center text-sm p-2 rounded-lg hover:bg-gray-50">
<div>
<p className="font-medium text-danger">{log.datasource}: {log.note}</p>
<p className="text-xs text-gray-500">{log.time}</p>
</div>
<button onClick={() => setActivePage('connection-log')} className="text-primary text-xs hover:underline"></button>
</div>
)) : <p className="text-sm text-gray-500 text-center py-4"></p>}
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm">
<h3 className="font-bold mb-4"></h3>
<div className="space-y-4">
{MOCK_PERMISSION_LOGS.slice(0, 4).map(log => (
<div key={log.id} className="flex items-start space-x-3 text-sm">
<i className="fa fa-user-secret text-gray-400 mt-1"></i>
<div className="flex-1">
<p dangerouslySetInnerHTML={{ __html: log.text }}></p>
<p className="text-xs text-gray-400">{formatTimeAgo(log.timestamp)}</p>
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
};

@ -0,0 +1,107 @@
import React, { useState, useMemo } from 'react';
import { AdminModal } from '../admin/AdminModal';
import { AdminNotification } from '../../types';
import { MOCK_DATA_ADMIN_NOTIFICATIONS } from '../../constants';
export const DataAdminNotificationPage: React.FC = () => {
const [notifications, setNotifications] = useState<AdminNotification[]>(MOCK_DATA_ADMIN_NOTIFICATIONS);
const [modal, setModal] = useState<'add' | 'edit' | 'delete' | null>(null);
const [currentItem, setCurrentItem] = useState<AdminNotification | null>(null);
const sortedNotifications = useMemo(() => {
return [...notifications].sort((a, b) => (b.pinned ? 1 : -1) - (a.pinned ? 1 : -1) || new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime());
}, [notifications]);
const openModal = (type: 'add' | 'edit' | 'delete', item?: AdminNotification) => {
setCurrentItem(item || null);
setModal(type);
};
const handleTogglePin = (item: AdminNotification) => {
setNotifications(prev => prev.map(n => n.id === item.id ? { ...n, pinned: !n.pinned } : n));
};
const handleSave = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (modal === 'add') {
const newNotif: AdminNotification = {
id: Date.now(),
title: formData.get('title') as string,
content: formData.get('content') as string,
dataSourceTopic: formData.get('dataSourceTopic') as string,
role: 'all', // Data admins typically notify all or data-related roles
priority: 'normal',
pinned: formData.get('pinned') === 'on',
publisher: '数据管理员',
publishTime: new Date().toISOString().split('T')[0],
status: 'published',
};
setNotifications(prev => [newNotif, ...prev]);
} else if (modal === 'edit' && currentItem) {
const updated = {
...currentItem,
title: formData.get('title') as string,
content: formData.get('content') as string,
dataSourceTopic: formData.get('dataSourceTopic') as string,
pinned: formData.get('pinned') === 'on',
};
setNotifications(prev => prev.map(n => n.id === currentItem.id ? updated : n));
}
setModal(null);
};
const confirmDelete = () => {
if (currentItem) {
setNotifications(prev => prev.filter(n => n.id !== currentItem.id));
}
setModal(null);
};
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<button onClick={() => openModal('add')} className="bg-primary text-white rounded-lg px-4 py-2 text-sm btn-effect"><i className="fa fa-plus mr-1"></i> </button>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="bg-gray-50"><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th><th className="px-6 py-3 text-left"></th></tr></thead>
<tbody>
{sortedNotifications.map(n => (
<tr key={n.id} className={`border-b hover:bg-gray-50 ${n.pinned ? 'bg-yellow-50' : ''}`}>
<td className="px-6 py-4 font-medium">{n.title} {n.pinned && <i className="fa fa-thumb-tack text-yellow-500 ml-1"></i>}</td>
<td className="px-6 py-4"><span className="bg-blue-100 text-blue-800 px-2 py-1 text-xs rounded">{n.dataSourceTopic}</span></td>
<td className="px-6 py-4">{n.publisher}</td>
<td className="px-6 py-4">{n.publishTime}</td>
<td className="px-6 py-4">
<button onClick={() => handleTogglePin(n)} className={`hover:underline mr-3 ${n.pinned ? 'text-yellow-600' : 'text-gray-500'}`} title={n.pinned ? '取消置顶' : '置顶'}><i className="fa fa-thumb-tack"></i></button>
<button onClick={() => openModal('edit', n)} className="text-primary hover:underline mr-3"></button>
<button onClick={() => openModal('delete', n)} className="text-danger hover:underline"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<AdminModal isOpen={modal === 'add' || modal === 'edit'} onClose={() => setModal(null)} title={modal === 'add' ? '发布新通知' : '编辑通知'}>
<form onSubmit={handleSave} className="space-y-4">
<div><label className="block text-sm mb-1"></label><input name="title" defaultValue={currentItem?.title || ''} required className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><input name="dataSourceTopic" defaultValue={currentItem?.dataSourceTopic || ''} placeholder="例如: 销售数据库" required className="w-full px-3 py-2 border rounded-lg" /></div>
<div><label className="block text-sm mb-1"></label><textarea name="content" defaultValue={currentItem?.content || ''} required className="w-full px-3 py-2 border rounded-lg" rows={4}></textarea></div>
<div><label className="flex items-center"><input type="checkbox" name="pinned" defaultChecked={currentItem?.pinned} className="mr-2" /> </label></div>
<div className="flex justify-end space-x-2 pt-4"><button type="button" onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button></div>
</form>
</AdminModal>
<AdminModal isOpen={modal === 'delete'} onClose={() => setModal(null)} title="确认删除通知">
<p> "{currentItem?.title}" </p>
<div className="flex justify-end space-x-2 mt-4"><button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button onClick={confirmDelete} className="px-4 py-2 bg-danger text-white rounded-lg"></button></div>
</AdminModal>
</main>
);
};

@ -0,0 +1,306 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AdminModal } from '../admin/AdminModal';
import { DataSource } from '../../types';
import { dbConnectionApi, DbConnection } from '../../services/api';
export const DataSourceManagementPage: React.FC = () => {
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ search: '', type: '', status: '' });
const [modal, setModal] = useState<'add' | 'edit' | 'delete' | 'confirmDisable' | null>(null);
const [currentItem, setCurrentItem] = useState<DataSource | null>(null);
// 加载数据源列表
useEffect(() => {
loadDataSources();
}, []);
const loadDataSources = async () => {
try {
setLoading(true);
const connections = await dbConnectionApi.getList();
// 转换后端DbConnection格式到前端DataSource格式
const frontendDataSources: DataSource[] = connections.map(conn => ({
id: String(conn.id),
name: conn.name,
type: mapDbTypeIdToType(conn.dbTypeId), // 需要根据dbTypeId映射
address: conn.url,
status: mapBackendStatusToFrontend(conn.status),
}));
setDataSources(frontendDataSources);
} catch (error) {
console.error('加载数据源列表失败:', error);
alert('加载数据源列表失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setLoading(false);
}
};
// 映射数据库类型ID到前端类型需要从后端获取类型列表这里先简化处理
const mapDbTypeIdToType = (dbTypeId: number): DataSource['type'] => {
// 简化映射,实际应该从后端获取类型列表
if (dbTypeId === 1) return 'MySQL';
if (dbTypeId === 2) return 'PostgreSQL';
if (dbTypeId === 3) return 'Oracle';
if (dbTypeId === 4) return 'SQL Server';
return 'MySQL';
};
// 映射前端类型到数据库类型ID
const mapTypeToDbTypeId = (type: DataSource['type']): number => {
if (type === 'MySQL') return 1;
if (type === 'PostgreSQL') return 2;
if (type === 'Oracle') return 3;
if (type === 'SQL Server') return 4;
return 1;
};
// 映射后端状态到前端状态
const mapBackendStatusToFrontend = (status: string): DataSource['status'] => {
if (status === 'connected') return 'connected';
if (status === 'disconnected') return 'disconnected';
if (status === 'error') return 'error';
if (status === 'disabled') return 'disabled';
return 'disconnected';
};
// 映射前端状态到后端状态
const mapFrontendStatusToBackend = (status: DataSource['status']): string => {
if (status === 'connected') return 'connected';
if (status === 'disconnected') return 'disconnected';
if (status === 'error') return 'error';
if (status === 'disabled') return 'disabled';
return 'disconnected';
};
const filteredDataSources = useMemo(() => {
return dataSources.filter(ds =>
ds.name.toLowerCase().includes(filters.search.toLowerCase()) &&
(filters.type ? ds.type === filters.type : true) &&
(filters.status ? ds.status === filters.status : true)
);
}, [dataSources, filters]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFilters(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const resetFilters = () => setFilters({ search: '', type: '', status: '' });
const openModal = (type: 'add' | 'edit' | 'delete', item?: DataSource) => {
setCurrentItem(item || null);
setModal(type);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData.entries());
// 验证必填字段
if (!data.name || !(data.name as string).trim()) {
alert('连接名称不能为空');
return;
}
if (!data.host || !(data.host as string).trim()) {
alert('数据库主机地址不能为空');
return;
}
if (!data.port || !(data.port as string).trim()) {
alert('数据库端口不能为空');
return;
}
if (!data.username || !(data.username as string).trim()) {
alert('数据库账号不能为空');
return;
}
if (!data.password || !(data.password as string).trim()) {
alert('数据库密码不能为空');
return;
}
try {
if (modal === 'add') {
const backendConnection: Partial<DbConnection> = {
name: (data.name as string).trim(),
dbTypeId: mapTypeToDbTypeId(data.type as DataSource['type']),
url: `${(data.host as string).trim()}:${(data.port as string).trim()}`,
username: (data.username as string).trim(),
password: (data.password as string).trim(),
status: 'disconnected',
createUserId: Number(sessionStorage.getItem('userId') || '1'),
};
await dbConnectionApi.create(backendConnection);
alert('添加成功');
await loadDataSources();
} else if (modal === 'edit' && currentItem) {
const backendConnection: Partial<DbConnection> = {
id: Number(currentItem.id),
name: data.name as string,
dbTypeId: mapTypeToDbTypeId(data.type as DataSource['type']),
url: `${data.host}:${data.port}`,
};
await dbConnectionApi.update(backendConnection);
alert('更新成功');
await loadDataSources();
}
} catch (error) {
console.error('保存数据源失败:', error);
alert('保存失败: ' + (error instanceof Error ? error.message : '未知错误'));
return;
}
setModal(null);
};
const confirmDelete = async () => {
if (currentItem) {
try {
await dbConnectionApi.delete(Number(currentItem.id));
alert('删除成功');
await loadDataSources();
} catch (error) {
console.error('删除数据源失败:', error);
alert('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
}
setModal(null);
};
const handleTestConnection = async (id: string) => {
setDataSources(prev => prev.map(ds => ds.id === id ? { ...ds, status: 'testing' } : ds));
try {
const result = await dbConnectionApi.test(Number(id));
setDataSources(prev => prev.map(ds => {
if (ds.id === id) {
return { ...ds, status: result ? 'connected' : 'error' };
}
return ds;
}));
if (result) {
alert('连接测试成功');
} else {
alert('连接测试失败');
}
} catch (error) {
console.error('测试连接失败:', error);
setDataSources(prev => prev.map(ds => ds.id === id ? { ...ds, status: 'error' } : ds));
alert('测试连接失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
};
const requestToggleDisable = (dataSource: DataSource) => {
setCurrentItem(dataSource);
setModal('confirmDisable');
};
const confirmToggleDisable = async () => {
if (currentItem) {
try {
const newStatus = currentItem.status === 'disabled' ? 'disconnected' : 'disabled';
await dbConnectionApi.update({
id: Number(currentItem.id),
status: newStatus,
});
alert('操作成功');
await loadDataSources();
} catch (error) {
console.error('切换数据源状态失败:', error);
alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
}
setModal(null);
};
const getStatusChip = (status: DataSource['status']) => {
const styles = {
connected: 'bg-success/10 text-success',
disconnected: 'bg-gray-100 text-gray-600',
error: 'bg-danger/10 text-danger',
testing: 'bg-blue-100 text-blue-600 animate-pulse',
disabled: 'bg-gray-200 text-gray-700'
};
const text = {
connected: '已连接',
disconnected: '未连接',
error: '连接错误',
testing: '测试中...',
disabled: '已禁用'
};
return <span className={`px-2 py-1 ${styles[status]} text-xs rounded-full`}>{text[status]}</span>
};
if (loading) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<i className="fa fa-spinner fa-spin text-3xl text-primary mb-4"></i>
<p className="text-gray-500">...</p>
</div>
</div>
</main>
);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1"><div className="relative"><i className="fa fa-search absolute left-3 top-3 text-gray-400"></i><input name="search" value={filters.search} onChange={handleFilterChange} type="text" placeholder="搜索数据源名称" className="w-full pl-10 pr-4 py-2 border rounded-lg" /></div></div>
<div className="w-full md:w-40"><select name="type" value={filters.type} onChange={handleFilterChange} className="w-full px-4 py-2 border rounded-lg"><option value=""></option><option value="MySQL">MySQL</option><option value="PostgreSQL">PostgreSQL</option><option value="Oracle">Oracle</option><option value="SQL Server">SQL Server</option></select></div>
<div className="w-full md:w-32"><select name="status" value={filters.status} onChange={handleFilterChange} className="w-full px-4 py-2 border rounded-lg"><option value=""></option><option value="connected"></option><option value="disconnected"></option><option value="error"></option><option value="disabled"></option></select></div>
<div className="self-end"><button onClick={resetFilters} className="bg-white border rounded-lg px-4 py-2 btn-effect"></button></div>
<button onClick={() => openModal('add')} className="bg-primary text-white rounded-lg px-4 py-2 text-sm btn-effect"><i className="fa fa-plus mr-1"></i> </button>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="bg-gray-50"><th className="text-left px-6 py-3"></th><th className="text-left px-6 py-3"></th><th className="text-left px-6 py-3"></th><th className="text-left px-6 py-3"></th><th className="text-left px-6 py-3"></th></tr></thead>
<tbody>
{filteredDataSources.map(ds => (
<tr key={ds.id} className="border-b hover:bg-gray-50">
<td className="px-6 py-4">{ds.name}</td><td className="px-6 py-4">{ds.type}</td><td className="px-6 py-4">{ds.address}</td><td className="px-6 py-4">{getStatusChip(ds.status)}</td>
<td className="px-6 py-4 whitespace-nowrap">
<button onClick={() => openModal('edit', ds)} className="text-primary hover:underline mr-3"></button>
<button onClick={() => handleTestConnection(ds.id)} disabled={ds.status === 'testing' || ds.status === 'disabled'} className="text-secondary hover:underline mr-3 disabled:text-gray-400">{ds.status === 'testing' ? '测试中...' : '测试连接'}</button>
<button onClick={() => requestToggleDisable(ds)} className={`hover:underline mr-3 ${ds.status === 'disabled' ? 'text-success' : 'text-warning'}`}>{ds.status === 'disabled' ? '启用' : '禁用'}</button>
<button onClick={() => openModal('delete', ds)} className="text-danger hover:underline"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<AdminModal isOpen={modal === 'add' || modal === 'edit'} onClose={() => setModal(null)} title={modal === 'add' ? '添加数据源' : '编辑数据源'}>
<form onSubmit={handleSave} className="space-y-4">
<div><label className="block text-sm mb-1"></label><input name="name" defaultValue={currentItem?.name} required className="w-full px-3 py-2 border rounded-lg"/></div>
<div><label className="block text-sm mb-1"></label><select name="type" defaultValue={currentItem?.type} className="w-full px-3 py-2 border rounded-lg"><option value="MySQL">MySQL</option><option value="PostgreSQL">PostgreSQL</option><option value="Oracle">Oracle</option><option value="SQL Server">SQL Server</option></select></div>
<div><label className="block text-sm mb-1"></label><input name="host" defaultValue={currentItem?.address.split(':')[0]} required className="w-full px-3 py-2 border rounded-lg"/></div>
<div><label className="block text-sm mb-1"></label><input name="port" defaultValue={currentItem?.address.split(':')[1]} required className="w-full px-3 py-2 border rounded-lg"/></div>
<div><label className="block text-sm mb-1"></label><input name="username" type="text" required className="w-full px-3 py-2 border rounded-lg"/></div>
<div><label className="block text-sm mb-1"></label><input name="password" type="password" required className="w-full px-3 py-2 border rounded-lg"/></div>
<div className="flex justify-end space-x-2 pt-4"><button type="button" onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button type="submit" className="px-4 py-2 bg-primary text-white rounded-lg"></button></div>
</form>
</AdminModal>
<AdminModal isOpen={modal === 'delete'} onClose={() => setModal(null)} title="确认删除数据源">
<p> "{currentItem?.name}" </p>
<div className="flex justify-end space-x-2 mt-4"><button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button><button onClick={confirmDelete} className="px-4 py-2 bg-danger text-white rounded-lg"></button></div>
</AdminModal>
<AdminModal isOpen={modal === 'confirmDisable'} onClose={() => setModal(null)} title={`确认${currentItem?.status === 'disabled' ? '启用' : '禁用'}数据源`}>
<p>{currentItem?.status === 'disabled' ? '启用' : '禁用'} "{currentItem?.name}" </p>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={() => setModal(null)} className="px-4 py-2 border rounded-lg"></button>
<button onClick={confirmToggleDisable} className={`px-4 py-2 text-white rounded-lg ${currentItem?.status === 'disabled' ? 'bg-success' : 'bg-danger'}`}>
</button>
</div>
</AdminModal>
</main>
);
};

@ -0,0 +1,434 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AdminModal } from '../admin/AdminModal';
import { UserPermissionAssignment, UnassignedUser, DataSourcePermission } from '../../types';
import { userDbPermissionApi, UserDbPermission, userApi, User, dbConnectionApi, DbConnection } from '../../services/api';
export const UserPermissionPage: React.FC = () => {
const [unassignedUsers, setUnassignedUsers] = useState<UnassignedUser[]>([]);
const [assignedPermissions, setAssignedPermissions] = useState<UserPermissionAssignment[]>([]);
const [dataSources, setDataSources] = useState<DbConnection[]>([]);
const [loading, setLoading] = useState(true);
// 定义支持的搜索类别
type SearchCategory = 'all' | 'username' | 'email' | 'datasource' | 'table';
// 加载数据
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
// 加载所有用户
const allUsers = await userApi.getList();
// 加载已分配权限
const assignedPerms = await userDbPermissionApi.getAssigned();
// 加载数据源
const connections = await dbConnectionApi.getList();
setDataSources(connections);
// 解析已分配权限
const parsedAssigned: UserPermissionAssignment[] = assignedPerms.map(perm => {
const user = allUsers.find(u => u.id === perm.userId);
let permissions: DataSourcePermission[] = [];
try {
const details = JSON.parse(perm.permissionDetails || '[]');
permissions = details.map((detail: any) => {
const conn = connections.find(c => c.id === detail.db_connection_id);
return {
dataSourceId: String(detail.db_connection_id),
dataSourceName: conn?.name || '未知数据源',
tables: detail.table_ids?.map((tid: number) => `table_${tid}`) || [],
};
});
} catch (e) {
console.error('解析权限详情失败:', e);
}
return {
id: String(perm.id),
userId: String(perm.userId),
username: user?.username || '未知用户',
permissions,
};
});
// 找出未分配权限的用户(所有用户中不在已分配列表中的)
const assignedUserIds = new Set(assignedPerms.map(p => p.userId));
const unassigned: UnassignedUser[] = allUsers
.filter(u => !assignedUserIds.has(u.id))
.map(u => ({
id: String(u.id),
username: u.username,
email: u.email,
regTime: new Date().toISOString().split('T')[0],
}));
setAssignedPermissions(parsedAssigned);
setUnassignedUsers(unassigned);
} catch (error) {
console.error('加载权限数据失败:', error);
alert('加载权限数据失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setLoading(false);
}
};
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set());
const [modal, setModal] = useState<'assign' | 'manage' | null>(null);
const [currentItem, setCurrentItem] = useState<UserPermissionAssignment | null>(null);
const [usersToAssign, setUsersToAssign] = useState<UnassignedUser[]>([]);
const [searchKeyword, setSearchKeyword] = useState<string>('');
const [searchCategory, setSearchCategory] = useState<SearchCategory>('all');
// 2. 优化:按「搜索类别+关键词」过滤待分配用户
const filteredUnassignedUsers = useMemo(() => {
const keyword = searchKeyword.toLowerCase().trim();
if (!keyword) return unassignedUsers;
return unassignedUsers.filter(user => {
switch (searchCategory) {
case 'username':
return user.username.toLowerCase().includes(keyword);
case 'email':
return user.email.toLowerCase().includes(keyword);
case 'all':
return user.username.toLowerCase().includes(keyword) || user.email.toLowerCase().includes(keyword);
default:
return false;
}
});
}, [unassignedUsers, searchKeyword, searchCategory]);
// 3. 优化:按「搜索类别+关键词」过滤已分配用户
const filteredAssignedPermissions = useMemo(() => {
const keyword = searchKeyword.toLowerCase().trim();
if (!keyword) return assignedPermissions;
return assignedPermissions.filter(assignment => {
switch (searchCategory) {
case 'username':
return assignment.username.toLowerCase().includes(keyword);
case 'email': // 已分配用户无邮箱字段,不匹配
return false;
case 'datasource':
return assignment.permissions.some(perm => perm.dataSourceName.toLowerCase().includes(keyword));
case 'table':
return assignment.permissions.some(perm => perm.tables.some(table => table.toLowerCase().includes(keyword)));
case 'all': // 全部:匹配用户名/数据源/表名
return assignment.username.toLowerCase().includes(keyword) ||
assignment.permissions.some(perm => perm.dataSourceName.toLowerCase().includes(keyword)) ||
assignment.permissions.some(perm => perm.tables.some(table => table.toLowerCase().includes(keyword)));
default:
return false;
}
});
}, [assignedPermissions, searchKeyword, searchCategory]);
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedUserIds(new Set(filteredUnassignedUsers.map(u => u.id)));
} else {
setSelectedUserIds(new Set());
}
};
const handleSelectUser = (id: string, checked: boolean) => {
const newSet = new Set(selectedUserIds);
if (checked) newSet.add(id);
else newSet.delete(id);
setSelectedUserIds(newSet);
};
const openAssignModal = (users: UnassignedUser[]) => {
if (users.length === 0) return;
setUsersToAssign(users);
setCurrentItem(null);
setModal('assign');
};
const openManageModal = (permission: UserPermissionAssignment) => {
setCurrentItem(permission);
setModal('manage');
};
const handleSavePermissions = async (userIds: string[], permissions: DataSourcePermission[]) => {
try {
const filteredPerms = permissions.filter(p => p.tables.length > 0);
if (modal === 'assign') {
// 为多个用户分配权限
const currentUserId = Number(sessionStorage.getItem('userId') || '1');
const permissionDetails = filteredPerms.map(p => ({
db_connection_id: Number(p.dataSourceId),
table_ids: p.tables.map(t => Number(t.replace('table_', ''))),
}));
for (const userId of userIds) {
await userDbPermissionApi.create({
userId: Number(userId),
permissionDetails: JSON.stringify(permissionDetails),
isAssigned: 1,
lastGrantUserId: currentUserId,
});
}
alert('分配权限成功');
await loadData();
} else if (modal === 'manage' && currentItem) {
// 更新权限
const permissionDetails = filteredPerms.map(p => ({
db_connection_id: Number(p.dataSourceId),
table_ids: p.tables.map(t => Number(t.replace('table_', ''))),
}));
await userDbPermissionApi.update({
id: Number(currentItem.id),
permissionDetails: JSON.stringify(permissionDetails),
lastGrantUserId: Number(sessionStorage.getItem('userId') || '1'),
});
alert('更新权限成功');
await loadData();
}
} catch (error) {
console.error('保存权限失败:', error);
alert('保存权限失败: ' + (error instanceof Error ? error.message : '未知错误'));
return;
}
setModal(null);
};
const PermissionModal: React.FC<{
users: { id: string, username: string }[];
existingPermissions?: DataSourcePermission[];
onSave: (userIds: string[], permissions: DataSourcePermission[]) => void;
onClose: () => void;
}> = ({ users, existingPermissions = [], onSave, onClose }) => {
const [perms, setPerms] = useState<DataSourcePermission[]>(
dataSources.map(ds => {
const existing = existingPermissions.find(p => p.dataSourceId === String(ds.id));
return {
dataSourceId: String(ds.id),
dataSourceName: ds.name,
tables: existing ? [...existing.tables] : []
};
})
);
const handleTableToggle = (dsId: string, table: string, checked: boolean) => {
setPerms(prev => prev.map(p => {
if (p.dataSourceId === dsId) {
const newTables = new Set(p.tables);
if (checked) newTables.add(table);
else newTables.delete(table);
return { ...p, tables: Array.from(newTables) };
}
return p;
}));
};
const handleSelectAllTables = (dsId: string, checked: boolean) => {
// 简化处理:假设每个数据源有默认的表列表
// 实际应该从后端获取表列表
const defaultTables = ['table_1', 'table_2', 'table_3']; // 临时处理
setPerms(prev => prev.map(p => p.dataSourceId === dsId ? { ...p, tables: checked ? defaultTables : [] } : p));
};
const title = users.length > 1 ? `${users.length} 位用户分配权限` : `${users[0].username} 分配权限`;
const isEditing = existingPermissions.length > 0;
return (
<AdminModal isOpen={true} onClose={onClose} title={isEditing ? `管理 ${users[0].username} 的权限` : title}>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
{dataSources.map(ds => {
const currentPerm = perms.find(p => p.dataSourceId === String(ds.id));
// 简化处理:使用默认表列表,实际应该从后端获取
const allTablesForDs = ['table_1', 'table_2', 'table_3'];
const allSelected = currentPerm ? currentPerm.tables.length === allTablesForDs.length : false;
return (
<div key={ds.id} className="p-3 border rounded-lg">
<h4 className="font-semibold mb-2">{ds.name}</h4>
<div className="border-t pt-2">
<label className="flex items-center mb-2 font-medium text-sm">
<input type="checkbox" onChange={(e) => handleSelectAllTables(String(ds.id), e.target.checked)} checked={allSelected} className="mr-2 h-4 w-4" />
</label>
<div className="grid grid-cols-2 gap-2 text-sm">
{allTablesForDs.map(table => (
<label key={table} className="flex items-center">
<input
type="checkbox"
checked={currentPerm?.tables.includes(table) || false}
onChange={(e) => handleTableToggle(String(ds.id), table, e.target.checked)}
className="mr-2 h-4 w-4"
/>
{table}
</label>
))}
</div>
</div>
</div>
)
})}
</div>
<div className="flex justify-end space-x-2 mt-6">
<button onClick={onClose} className="px-4 py-2 border rounded-lg"></button>
<button onClick={() => onSave(users.map(u => u.id), perms)} className="px-4 py-2 bg-primary text-white rounded-lg"></button>
</div>
</AdminModal>
);
};
if (loading) {
return (
<main className="flex-1 overflow-y-auto p-6 space-y-8">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<i className="fa fa-spinner fa-spin text-3xl text-primary mb-4"></i>
<p className="text-gray-500">...</p>
</div>
</div>
</main>
);
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-8">
<div className="w-full max-w-2xl flex items-center gap-3">
<select
value={searchCategory}
onChange={(e) => setSearchCategory(e.target.value as SearchCategory)}
className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 bg-white font-bold"
>
<option value="all"></option>
<option value="username"></option>
<option value="email"></option>
<option value="datasource"></option>
<option value="table"></option>
</select>
<input
type="text"
placeholder={`搜索${
searchCategory === 'all' ? '(用户名/数据源/表名)' :
searchCategory === 'username' ? '用户名' :
searchCategory === 'email' ? '邮箱' :
searchCategory === 'datasource' ? '数据源名' : '表名'
}`}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold"> ({filteredUnassignedUsers.length})</h2>
<button
onClick={() => openAssignModal(filteredUnassignedUsers.filter(u => selectedUserIds.has(u.id)))}
disabled={selectedUserIds.size === 0}
className="bg-primary text-white rounded-lg px-4 py-2 text-sm btn-effect disabled:bg-primary/50 disabled:cursor-not-allowed"
>
<i className="fa fa-key mr-1"></i>
</button>
</div>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="px-6 py-3 text-left w-12">
<input
type="checkbox"
onChange={handleSelectAll}
checked={filteredUnassignedUsers.length > 0 && selectedUserIds.size === filteredUnassignedUsers.length}
/>
</th>
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
</tr>
</thead>
<tbody>
{filteredUnassignedUsers.map(user => (
<tr key={user.id} className="border-b hover:bg-gray-50">
<td className="px-6 py-4">
<input
type="checkbox"
checked={selectedUserIds.has(user.id)}
onChange={(e) => handleSelectUser(user.id, e.target.checked)}
/>
</td>
<td className="px-6 py-4">{user.username}</td>
<td className="px-6 py-4">{user.email}</td>
<td className="px-6 py-4">{user.regTime}</td>
<td className="px-6 py-4">
<button onClick={() => openAssignModal([user])} className="text-primary hover:underline"></button>
</td>
</tr>
))}
{filteredUnassignedUsers.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<h2 className="text-xl font-bold mb-4"> ({filteredAssignedPermissions.length})</h2>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
<th className="text-left px-6 py-3"></th>
</tr>
</thead>
<tbody>
{filteredAssignedPermissions.map(p => (
<tr key={p.id} className="border-b hover:bg-gray-50">
<td className="px-6 py-4 font-medium">{p.username}</td>
<td className="px-6 py-4">
{p.permissions.map(perm => (
<div key={perm.dataSourceId} className="mb-1">
<span className="font-semibold">{perm.dataSourceName}:</span>
<span className="text-gray-600">{perm.tables.join(', ')}</span>
</div>
))}
</td>
<td className="px-6 py-4">
<button onClick={() => openManageModal(p)} className="text-primary hover:underline"></button>
</td>
</tr>
))}
{filteredAssignedPermissions.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-8 text-center text-gray-500"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{(modal === 'assign' && usersToAssign.length > 0) && (
<PermissionModal
users={usersToAssign}
onSave={handleSavePermissions}
onClose={() => setModal(null)}
/>
)}
{(modal === 'manage' && currentItem) && (
<PermissionModal
users={[currentItem]}
existingPermissions={currentItem.permissions}
onSave={handleSavePermissions}
onClose={() => setModal(null)}
/>
)}
</main>
);
};

@ -0,0 +1,270 @@
import { Conversation, QueryResultData, Notification, UserProfile, Friend, FriendRequest, ModelOption, AdminNotification, SystemLog, DataSource, ConnectionLog, PermissionLog, QueryShare } from './types';
// Used in QueryPage.tsx
export const MODEL_OPTIONS: ModelOption[] = [
{ name: 'gemini-2.5-pro', disabled: false, description: '最强大的模型,用于复杂查询' },
{ name: 'gemini-2.5-flash', disabled: false, description: '速度最快的模型,用于快速响应' },
{ name: 'GPT-4', disabled: false, description: 'OpenAI 的先进模型' },
{ name: 'GLM-4.6', disabled: false, description: '智谱AI GLM-4 (即将支持)' },
{ name: 'qwen3-max', disabled: false, description: '阿里通义千问 (即将支持)' },
{ name: 'kimi-k2-0905-preview', disabled: false, description: 'kimi-k2-0905-preview K2 (即将支持)' },
];
export const DATABASE_OPTIONS: ModelOption[] = [
{ name: '销售数据库', disabled: false, description: '包含订单、客户和销售数据' },
{ name: '用户数据库', disabled: false, description: '包含用户信息和活动日志' },
{ name: '产品数据库', disabled: false, description: '包含产品目录和库存信息' },
];
// Mocks for normal user view
export const MOCK_INITIAL_CONVERSATION: Conversation = {
id: 'conv-1',
title: '',
messages: [{
role: 'ai',
content: '您好!我是数据查询助手,您可以通过自然语言描述您的查询需求(例如:"展示2023年各季度的订单量"),我会为您生成相应的结果。'
}],
createTime: new Date().toISOString(),
};
const MOCK_QUERY_RESULT_1: QueryResultData = {
id: 'query-1',
userPrompt: '展示2023年各季度的订单量',
sqlQuery: "SELECT strftime('%Y-Q', order_date) as quarter, COUNT(order_id) as order_count FROM orders WHERE strftime('%Y', order_date) = '2023' GROUP BY quarter ORDER BY quarter;",
conversationId: 'conv-1',
queryTime: new Date('2023-11-20T10:30:00Z').toISOString(),
executionTime: '0.8秒',
tableData: {
headers: ['季度', '订单量', '同比增长'],
rows: [
['2023-Q1', '1,200', '+15%'],
['2023-Q2', '1,550', '+18%'],
['2023-Q3', '1,400', '+12%'],
['2023-Q4', '1,850', '+25%']
]
},
chartData: {
type: 'bar',
labels: ['2023-Q1', '2023-Q2', '2023-Q3', '2023-Q4'],
datasets: [{
label: '订单量',
data: [1200, 1550, 1400, 1850],
backgroundColor: 'rgba(22, 93, 255, 0.6)',
}]
},
database:"销售数据库",
model:"gemini-2.5-pro",
};
const MOCK_QUERY_RESULT_2: QueryResultData = {
id: 'query-2',
userPrompt: '展示2023年各季度的订单量', // Same prompt
sqlQuery: "SELECT strftime('%Y-Q', order_date) as quarter, COUNT(order_id) as order_count FROM orders WHERE strftime('%Y', order_date) = '2023' GROUP BY quarter ORDER BY quarter;",
conversationId: 'conv-2', // Different conversation
queryTime: new Date('2023-11-21T11:00:00Z').toISOString(), // Later time
executionTime: '0.9秒',
tableData: {
headers: ['季度', '订单量', '同比增长'],
rows: [
// ['2023-Q1', '1,200', '+15%'] is now deleted
['2023-Q2', '1,600', '+20%'], // Changed value
['2023-Q3', '1,400', '+12%'], // Same value
['2023-Q4', '1,850', '+25%'], // Same value
['2023-Q5 (预测)', '2,100', '+30%'] // Added row
]
},
chartData: {
type: 'bar',
labels: ['2023-Q2', '2023-Q3', '2023-Q4', '2023-Q5 (预测)'], // Changed labels
datasets: [{
label: '订单量',
data: [1600, 1400, 1850, 2100], // Changed data
backgroundColor: 'rgba(54, 162, 235, 0.6)',
}]
},
database:"销售数据库",
model:"qwen3-max",
};
const MOCK_QUERY_RESULT_3: QueryResultData = {
id: 'query-3',
userPrompt: '统计每月新增用户数', // A different prompt
sqlQuery: "SELECT strftime('%Y-%m', registration_date) as month, COUNT(user_id) as new_users FROM users GROUP BY month;",
conversationId: 'conv-3',
queryTime: new Date('2023-11-22T09:00:00Z').toISOString(),
executionTime: '1.1秒',
tableData: {
headers: ['月份', '新增用户数'],
rows: [
['2023-09', '5,200'],
['2023-10', '6,100'],
]
},
chartData: {
type: 'line',
labels: ['2023-09', '2023-10'],
datasets: [{
label: '新增用户数',
data: [5200, 6100],
backgroundColor: 'rgba(75, 192, 192, 0.6)',
}]
},
database:"产品数据库",
model:"qwen3-max",
}
const MOCK_QUERY_RESULT_4: QueryResultData = {
id: 'query-4',
userPrompt: '各产品线销售额占比',
sqlQuery: "SELECT product_line, SUM(sales) as total_sales FROM sales_by_product_line GROUP BY product_line;",
conversationId: 'conv-4',
queryTime: new Date('2023-11-23T14:00:00Z').toISOString(),
executionTime: '0.7秒',
tableData: {
headers: ['产品线', '销售额', '占比'],
rows: [
['电子产品', '550,000', '55%'],
['家居用品', '250,000', '25%'],
['服装配饰', '200,000', '20%']
]
},
chartData: {
type: 'pie',
labels: ['电子产品', '家居用品', '服装配饰'],
datasets: [{
label: '销售额',
data: [550000, 250000, 200000],
backgroundColor: [
'rgba(22, 93, 255, 0.7)',
'rgba(54, 162, 235, 0.7)',
'rgba(255, 206, 86, 0.7)',
],
}]
},
database:"用户数据库",
model:"qwen3-max",
};
export const MOCK_SAVED_QUERIES: QueryResultData[] = [MOCK_QUERY_RESULT_1, MOCK_QUERY_RESULT_2, MOCK_QUERY_RESULT_3, MOCK_QUERY_RESULT_4];
export const MOCK_NOTIFICATIONS: Notification[] = [
{ id: '3', type: 'system', title: '系统将在今晚2点进行维护。', content: '系统将在今晚2点进行维护。', timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), isRead: false, isPinned: true },
{ id: '1', type: 'system', title: '新用户 王小明 已注册。', content: '新用户 王小明 已注册。', timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(), isRead: false, isPinned: false },
{ id: '2', type: 'system', title: '模型 Gemini 连接失败。', content: '模型 Gemini 连接失败。', timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), isRead: false, isPinned: false },
{ id: 'share-1', type: 'share', title: '李琪雯 分享了一个查询给你', content: '"展示2023年各季度的订单量"', timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), isRead: false, isPinned: false, fromUser: { name: '李琪雯', avatarUrl: 'https://i.pravatar.cc/150?u=li-si' }, relatedShareId: 'share-1' },
];
export const MOCK_USER_PROFILE: UserProfile = {
id: 'user-001',
userId: 'zhangsan',
name: '李瑜清',
email: 'zhangsan@example.com',
phoneNumber: '13812345678',
avatarUrl: 'https://i.pravatar.cc/150?u=zhang-san',
registrationDate: '2024-03-22',
accountStatus: 'normal',
preferences: {
defaultModel: 'gemini-2.5-pro',
defaultDatabase: '销售数据库',
},
};
export const MOCK_FRIENDS_LIST: Friend[] = [{ id: 'friend-1', name: '李琪雯', avatarUrl: 'https://i.pravatar.cc/150?u=li-si', isOnline: true,email:'Maem12129@gmail.com'}
,
{ id: 'friend-2', name: '马芳琼', avatarUrl: 'https://i.pravatar.cc/150?u=wang-wu', isOnline: false,email:'DonQuixote@gmail.com' },
];
export const MOCK_FRIEND_REQUESTS: FriendRequest[] = [
{ id: 'req-1', fromUser: { name: '赵文琪', avatarUrl: 'https://i.pravatar.cc/150?u=zhao-liu' }, timestamp: '2小时前' }
];
export const MOCK_QUERY_SHARES: QueryShare[] = [
{
id: 'share-1',
sender: { id: 'friend-1', name: '李琪雯', avatarUrl: 'https://i.pravatar.cc/150?u=li-si', isOnline: true ,email:'Maem12129@gmail.com'},
recipientId: 'user-001',
querySnapshot: MOCK_QUERY_RESULT_1,
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
status: 'unread',
},
{
id: 'share-2',
sender: { id: 'friend-2', name: '马芳琼', avatarUrl: 'https://i.pravatar.cc/150?u=wang-wu', isOnline: false ,email:'DonQuixote@gmail.com'},
recipientId: 'user-001',
querySnapshot: MOCK_QUERY_RESULT_3,
timestamp: new Date(Date.now() - 28 * 60 * 60 * 1000).toISOString(),
status: 'read',
}
];
// Used in RightSidebar.tsx
export const COMMON_RECOMMENDATIONS = [
'近7天用户增长趋势',
'上个季度各产品线销售额对比',
'查询华东地区销量最高的产品',
'统计每月新增用户数'
];
export const MOCK_SUCCESS_SUGGESTIONS = [
'按地区细分订单量',
'与去年同期数据进行对比',
'分析各季度订单的平均金额',
];
export const MOCK_FAILURE_SUGGESTIONS = [
'换一种更简单的问法',
'检查是否选择了正确的数据源',
'尝试询问“你能做什么?”',
];
// Mocks for Admin Panel
export const MOCK_ADMIN_NOTIFICATIONS: AdminNotification[] = [
{ id: 1, title: '系统将于今晚23:00进行升级维护', content: '...', role: 'all', priority: 'urgent', pinned: true, publisher: '系统管理员', publishTime: '2025-10-28 18:00', status: 'published' },
{ id: 2, title: '【草稿】新功能发布预告', content: '...', role: 'normal-user', priority: 'normal', pinned: false, publisher: '系统管理员', publishTime: '2025-10-27 09:00', status: 'draft' },
];
export const MOCK_DATA_ADMIN_NOTIFICATIONS: AdminNotification[] = [
{ id: 1, title: '销售数据库表结构变更', content: '`orders` 表新增 `discount_rate` 字段。', role: 'data-admin', priority: 'important', pinned: false, publisher: '李琪雯', publishTime: '2025-10-28 10:00', status: 'published', dataSourceTopic: '销售数据库' },
{ id: 2, title: '用户数据库计划下线旧表', content: '`users_old` 表将于11月30日下线请及时迁移。', role: 'all', priority: 'normal', pinned: false, publisher: '李琪雯', publishTime: '2025-10-26 15:00', status: 'published', dataSourceTopic: '用户数据库' },
];
export const MOCK_SYSTEM_LOGS: SystemLog[] = [
{ id: '#LOG001', time: '2025-10-29 14:32:18', user: '李瑜清', action: '执行自然语言查询', model: 'gemini-2.5-pro', ip: '192.168.1.102', status: 'success' },
{ id: '#LOG002', time: '2025-10-29 14:28:45', user: '李琪雯', action: '添加MySQL数据源', model: '-', ip: '192.168.1.105', status: 'success' },
{ id: '#LOG003', time: '2025-10-29 14:25:10', user: '李瑜清', action: '执行自然语言查询', model: 'GPT-4', ip: '192.168.1.102', status: 'failure', details: 'Error: API call to OpenAI failed with status 429 - Too Many Requests. Please check your plan and billing details.' },
{ id: '#LOG004', time: '2025-10-29 13:50:21', user: '未知用户', action: '尝试登录系统', model: '-', ip: '203.0.113.45', status: 'failure', details: 'Authentication failed: Invalid credentials provided for user "unknown".' },
{ id: '#LOG005', time: '2025-10-29 12:15:05', user: 'admin', action: '更新用户角色', model: '-', ip: '127.0.0.1', status: 'success' },
];
// Mocks for Data Admin Panel
export const MOCK_DATASOURCES: DataSource[] = [
{ id: 'ds-1', name: '销售数据库', type: 'MySQL', address: '192.168.1.101:3306', status: 'connected' },
{ id: 'ds-2', name: '用户数据库', type: 'PostgreSQL', address: '192.168.1.102:5432', status: 'connected' },
{ id: 'ds-3', name: '产品数据库', type: 'MySQL', address: '192.168.1.103:3306', status: 'error' },
{ id: 'ds-4', name: '日志数据库', type: 'SQL Server', address: '192.168.1.104:1433', status: 'disconnected' },
{ id: 'ds-5', name: '库存数据库', type: 'Oracle', address: '192.168.1.105:1521', status: 'disabled' },
];
export const MOCK_CONNECTION_LOGS: ConnectionLog[] = [
{ id: 'log-1', time: '2023-06-15 16:42:30', datasource: '销售数据库', status: '成功' },
{ id: 'log-2', time: '2023-06-15 14:20:15', datasource: '产品数据库', status: '失败', details: "Error: Connection timed out after 15000ms. Could not connect to 192.168.1.103:3306. Please check network connectivity and firewall rules." },
{ id: 'log-3', time: '2023-06-14 11:05:42', datasource: '用户数据库', status: '成功' },
{ id: 'log-4', time: '2023-06-13 09:30:18', datasource: '日志数据库', status: '失败', details: "SQLSTATE[28000]: [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]Login failed for user 'log_reader'." },
{ id: 'log-5', time: new Date(Date.now() - 2 * 60 * 1000).toISOString(), datasource: '产品数据库', status: '失败', details: "Error: Access denied for user 'prod_user'@'localhost' (using password: YES)" }
];
export const MOCK_PERMISSION_LOGS: PermissionLog[] = [
{ id: 'plog-1', timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(), text: '管理员 <strong>李琪雯</strong> 授予 <strong>李瑜清</strong> "销售数据库" 的访问权限。' },
{ id: 'plog-2', timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), text: '管理员 <strong>李琪雯</strong> 撤销了 <strong>马芳琼</strong> 对 "产品数据库" 的所有权限。' },
{ id: 'plog-3', timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), text: '管理员 <strong>李琪雯</strong> 修改了 <strong>李瑜清</strong> 对 "销售数据库" 的 <code>orders</code> 表权限。' },
{ id: 'plog-4', timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), text: '系统自动为新用户 <strong>赵文琪</strong> 分配了默认权限。' },
];
export const MOCK_QUERY_LOAD = {
labels: ['销售数据库', '用户数据库', '产品数据库', '日志数据库', '库存数据库'],
data: [1250, 890, 650, 320, 150]
};

@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
// 与 .env.local 中的变量一一对应
readonly VITE_GEMINI_API_KEY: string;
readonly VITE_OPENAI_API_KEY: string;
readonly VITE_GLM_API_KEY: string;
readonly VITE_QWEN_API_KEY: string;
readonly VITE_KIMI_API_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

@ -0,0 +1,158 @@
import { useState, useEffect } from 'react';
import { queryCollectionApi, collectionRecordApi } from '../services/api';
export interface QueryCollection {
id: number;
userId: number;
collectionName: string;
description?: string;
createTime: string;
}
export interface CollectionRecord {
id: string;
collectionId: number;
queryLogId: number;
addTime: string;
}
export const useQueryCollection = (userId: number) => {
const [collections, setCollections] = useState<QueryCollection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadCollections = async () => {
setLoading(true);
setError(null);
try {
const data = await queryCollectionApi.getByUser(userId);
setCollections(data);
} catch (err) {
setError(err instanceof Error ? err.message : '加载收藏夹失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (userId) {
loadCollections();
}
}, [userId]);
const createCollection = async (name: string, description?: string) => {
setLoading(true);
setError(null);
try {
const newCollection = await queryCollectionApi.create({
userId,
collectionName: name,
description,
});
setCollections(prev => [...prev, newCollection]);
return newCollection;
} catch (err) {
setError(err instanceof Error ? err.message : '创建收藏夹失败');
return null;
} finally {
setLoading(false);
}
};
const updateCollection = async (id: number, name: string, description?: string) => {
setLoading(true);
setError(null);
try {
const updated = await queryCollectionApi.update({
id,
collectionName: name,
description,
});
setCollections(prev => prev.map(c => c.id === id ? updated : c));
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : '更新收藏夹失败');
return null;
} finally {
setLoading(false);
}
};
const deleteCollection = async (id: number) => {
setLoading(true);
setError(null);
try {
await queryCollectionApi.delete(id);
setCollections(prev => prev.filter(c => c.id !== id));
return true;
} catch (err) {
setError(err instanceof Error ? err.message : '删除收藏夹失败');
return false;
} finally {
setLoading(false);
}
};
const addQueryToCollection = async (collectionId: number, queryLogId: number) => {
setLoading(true);
setError(null);
try {
await collectionRecordApi.create({
collectionId,
queryLogId,
});
return true;
} catch (err) {
setError(err instanceof Error ? err.message : '添加到收藏夹失败');
return false;
} finally {
setLoading(false);
}
};
const removeQueryFromCollection = async (recordId: string) => {
setLoading(true);
setError(null);
try {
await collectionRecordApi.delete(recordId);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : '从收藏夹移除失败');
return false;
} finally {
setLoading(false);
}
};
const getCollectionRecords = async (collectionId: string) => {
setLoading(true);
setError(null);
try {
const records = await collectionRecordApi.getByCollection(collectionId);
return records;
} catch (err) {
setError(err instanceof Error ? err.message : '获取收藏记录失败');
return [];
} finally {
setLoading(false);
}
};
return {
collections,
loading,
error,
loadCollections,
createCollection,
updateCollection,
deleteCollection,
addQueryToCollection,
removeQueryFromCollection,
getCollectionRecords,
};
};

@ -0,0 +1,62 @@
import { useState } from 'react';
import { queryShareApi, queryLogApi } from '../services/api';
export const useQueryShare = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const shareQuery = async (queryLogId: number, receiveUserId: number) => {
setLoading(true);
setError(null);
try {
await queryShareApi.create({
shareUserId: Number(sessionStorage.getItem('userId') || '1'),
receiveUserId,
queryLogId,
receiveStatus: 0,
});
return true;
} catch (err) {
setError(err instanceof Error ? err.message : '分享失败');
return false;
} finally {
setLoading(false);
}
};
const markAsRead = async (shareId: number) => {
try {
await queryShareApi.update({
id: shareId,
receiveStatus: 1,
});
return true;
} catch (err) {
setError(err instanceof Error ? err.message : '标记失败');
return false;
}
};
const deleteShare = async (shareId: number) => {
try {
await queryShareApi.delete(shareId);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : '删除失败');
return false;
}
};
return {
shareQuery,
markAsRead,
deleteShare,
loading,
error,
};
};

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自然语言数据库查询系统</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#165DFF',
secondary: '#36BFFA',
neutral: '#F5F7FA',
dark: '#1D2129',
success: '#00B42A',
warning: '#FF7D00',
danger: '#F53F3F',
},
fontFamily: {
inter: ['Inter', 'sans-serif']
},
},
}
}
</script>
<style>
/* For Chart.js responsiveness */
.chart-container {
position: relative;
height: 256px; /* h-64 */
width: 100%;
}
.animated-gradient {
background: linear-gradient(-45deg, #eef2ff, #f3e8ff, #dbeafe, #e0f2fe);
background-size: 400% 400%;
animation: gradient-animation 15s ease infinite;
}
@keyframes gradient-animation {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>
<script type="importmap">
{
"imports": {
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.28.0",
"chart.js": "https://aistudiocdn.com/chart.js@^4.5.1",
"react-chartjs-2": "https://aistudiocdn.com/react-chartjs-2@^5.3.1"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-neutral font-inter text-dark">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

@ -0,0 +1,5 @@
{
"name": "Natural Language Database Query System",
"description": "A web application that allows users to query databases using natural language. It provides a chat-based interface, displays results in tables and charts, and manages conversation history.",
"requestFramePermissions": []
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,25 @@
{
"name": "natural-language-database-query-system",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^1.28.0",
"chart.js": "^4.5.1",
"openai": "^6.8.1",
"react": "^19.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

@ -0,0 +1,933 @@
// API 基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
// 从sessionStorage获取token每个标签页独立
const getToken = (): string | null => {
return sessionStorage.getItem('token');
};
// 从sessionStorage获取userId每个标签页独立
const getUserId = (): string | null => {
return sessionStorage.getItem('userId');
};
// 通用请求函数
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const userId = getUserId();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
// 添加认证token
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 添加userId header如果后端需要
if (userId) {
headers['userId'] = userId;
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: '请求失败' }));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// 处理统一的Result格式
if (data.code !== undefined) {
if (data.code === 200 || data.code === 0) {
return data.data as T;
} else {
throw new Error(data.message || '请求失败');
}
}
return data as T;
}
// ==================== 认证接口 ====================
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
userId: number;
username: string;
email: string;
roleId: number;
roleName: string;
avatarUrl: string;
}
export const authApi = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
// 保存token和用户信息到sessionStorage每个标签页独立
if (response.token) {
sessionStorage.setItem('token', response.token);
sessionStorage.setItem('userId', String(response.userId));
sessionStorage.setItem('username', response.username);
sessionStorage.setItem('roleId', String(response.roleId));
sessionStorage.setItem('roleName', response.roleName || '');
}
return response;
},
logout: () => {
sessionStorage.removeItem('token');
sessionStorage.removeItem('userId');
sessionStorage.removeItem('username');
sessionStorage.removeItem('roleId');
sessionStorage.removeItem('roleName');
},
isAuthenticated: (): boolean => {
return !!getToken();
},
};
// ==================== 查询接口 ====================
export interface QueryRequest {
userPrompt: string;
model: string;
database: string;
conversationId?: string;
}
export interface QueryResponse {
id: string;
userPrompt: string;
sqlQuery: string;
conversationId: string;
queryTime: string;
executionTime: string;
database: string;
model: string;
tableData: {
headers: string[];
rows: string[][];
};
chartData?: {
type: string;
labels: string[];
datasets: Array<{
label: string;
data: number[];
backgroundColor?: string | string[];
}>;
};
}
export const queryApi = {
execute: async (queryRequest: QueryRequest): Promise<QueryResponse> => {
return await request<QueryResponse>('/query/execute', {
method: 'POST',
body: JSON.stringify(queryRequest),
});
},
};
// ==================== 对话接口 ====================
export interface DialogRecord {
dialogId: string;
userId: number;
topic: string;
totalRounds: number;
startTime: string;
lastTime: string;
}
export const dialogApi = {
getList: async (): Promise<DialogRecord[]> => {
return await request<DialogRecord[]>('/dialog/list');
},
getById: async (dialogId: string): Promise<DialogRecord> => {
return await request<DialogRecord>(`/dialog/${dialogId}`);
},
create: async (dialog: Partial<DialogRecord>): Promise<DialogRecord> => {
return await request<DialogRecord>('/dialog', {
method: 'POST',
body: JSON.stringify(dialog),
});
},
update: async (dialogId: string, dialog: Partial<DialogRecord>): Promise<DialogRecord> => {
return await request<DialogRecord>(`/dialog/${dialogId}`, {
method: 'PUT',
body: JSON.stringify(dialog),
});
},
delete: async (dialogId: string): Promise<void> => {
return await request<void>(`/dialog/${dialogId}`, {
method: 'DELETE',
});
},
};
// ==================== 用户接口 ====================
export interface User {
id: number;
username: string;
email: string;
phonenumber: string;
roleId: number;
avatarUrl: string;
status: number;
}
export interface ChangePasswordRequest {
userId: number;
oldPassword: string;
newPassword: string;
}
export const userApi = {
getById: async (id: number): Promise<User> => {
return await request<User>(`/user/${id}`);
},
getList: async (): Promise<User[]> => {
return await request<User[]>('/user/list');
},
getByUsername: async (username: string): Promise<User> => {
return await request<User>(`/user/username/${username}`);
},
create: async (user: Partial<User>): Promise<string> => {
return await request<string>('/user', {
method: 'POST',
body: JSON.stringify(user),
});
},
update: async (user: Partial<User>): Promise<string> => {
return await request<string>('/user', {
method: 'PUT',
body: JSON.stringify(user),
});
},
delete: async (id: number): Promise<string> => {
return await request<string>(`/user/${id}`, {
method: 'DELETE',
});
},
changePassword: async (changePasswordRequest: ChangePasswordRequest): Promise<string> => {
return await request<string>('/user/change-password', {
method: 'POST',
body: JSON.stringify(changePasswordRequest),
});
},
};
// ==================== 数据库连接接口 ====================
export interface DbConnection {
id: number;
name: string;
dbTypeId: number;
url: string;
username: string;
password?: string;
status: string;
createUserId: number;
}
export const dbConnectionApi = {
getList: async (): Promise<DbConnection[]> => {
return await request<DbConnection[]>('/db-connection/list');
},
getById: async (id: number): Promise<DbConnection> => {
return await request<DbConnection>(`/db-connection/${id}`);
},
getListByUser: async (createUserId: number): Promise<DbConnection[]> => {
return await request<DbConnection[]>(`/db-connection/list/${createUserId}`);
},
create: async (dbConnection: Partial<DbConnection>): Promise<DbConnection> => {
return await request<DbConnection>('/db-connection', {
method: 'POST',
body: JSON.stringify(dbConnection),
});
},
update: async (dbConnection: Partial<DbConnection>): Promise<DbConnection> => {
return await request<DbConnection>('/db-connection', {
method: 'PUT',
body: JSON.stringify(dbConnection),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/db-connection/${id}`, {
method: 'DELETE',
});
},
test: async (id: number): Promise<boolean> => {
return await request<boolean>(`/db-connection/test/${id}`);
},
};
// ==================== 大模型配置接口 ====================
export interface LlmConfig {
id: number;
name: string;
version: string;
apiKey?: string;
apiUrl: string;
statusId: number;
isDisabled: number;
timeout: number;
}
export const llmConfigApi = {
getList: async (): Promise<LlmConfig[]> => {
return await request<LlmConfig[]>('/llm-config/list');
},
getAvailable: async (): Promise<LlmConfig[]> => {
return await request<LlmConfig[]>('/llm-config/list/available');
},
getById: async (id: number): Promise<LlmConfig> => {
return await request<LlmConfig>(`/llm-config/${id}`);
},
create: async (llmConfig: Partial<LlmConfig>): Promise<LlmConfig> => {
return await request<LlmConfig>('/llm-config', {
method: 'POST',
body: JSON.stringify(llmConfig),
});
},
update: async (llmConfig: Partial<LlmConfig>): Promise<LlmConfig> => {
return await request<LlmConfig>('/llm-config', {
method: 'PUT',
body: JSON.stringify(llmConfig),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/llm-config/${id}`, {
method: 'DELETE',
});
},
toggle: async (id: number): Promise<void> => {
return await request<void>(`/llm-config/${id}/toggle`, {
method: 'PUT',
});
},
};
// ==================== 用户数据权限接口 ====================
export interface UserDbPermission {
id: number;
userId: number;
permissionDetails: string; // JSON字符串
isAssigned: number;
lastGrantUserId: number;
lastGrantTime?: string;
}
export const userDbPermissionApi = {
getList: async (): Promise<UserDbPermission[]> => {
return await request<UserDbPermission[]>('/user-db-permission/list');
},
getAssigned: async (): Promise<UserDbPermission[]> => {
return await request<UserDbPermission[]>('/user-db-permission/list/assigned');
},
getUnassigned: async (): Promise<UserDbPermission[]> => {
return await request<UserDbPermission[]>('/user-db-permission/list/unassigned');
},
getByUserId: async (userId: number): Promise<UserDbPermission> => {
return await request<UserDbPermission>(`/user-db-permission/user/${userId}`);
},
create: async (permission: Partial<UserDbPermission>): Promise<UserDbPermission> => {
return await request<UserDbPermission>('/user-db-permission', {
method: 'POST',
body: JSON.stringify(permission),
});
},
update: async (permission: Partial<UserDbPermission>): Promise<UserDbPermission> => {
return await request<UserDbPermission>('/user-db-permission', {
method: 'PUT',
body: JSON.stringify(permission),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/user-db-permission/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 通知接口 ====================
export interface Notification {
id: number;
title: string;
content: string;
targetId: number;
priorityId: number;
publishTime?: string;
isTop: number;
createUserId: number;
createTime: string;
latestUpdateTime: string;
}
export const notificationApi = {
getList: async (): Promise<Notification[]> => {
return await request<Notification[]>('/notification/list');
},
getPublished: async (): Promise<Notification[]> => {
return await request<Notification[]>('/notification/list/published');
},
getDrafts: async (): Promise<Notification[]> => {
return await request<Notification[]>('/notification/list/drafts');
},
getByTarget: async (targetId: number): Promise<Notification[]> => {
return await request<Notification[]>(`/notification/list/target/${targetId}`);
},
getById: async (id: number): Promise<Notification> => {
return await request<Notification>(`/notification/${id}`);
},
create: async (notification: Partial<Notification>): Promise<Notification> => {
return await request<Notification>('/notification', {
method: 'POST',
body: JSON.stringify(notification),
});
},
update: async (notification: Partial<Notification>): Promise<Notification> => {
return await request<Notification>('/notification', {
method: 'PUT',
body: JSON.stringify(notification),
});
},
publish: async (id: number): Promise<void> => {
return await request<void>(`/notification/${id}/publish`, {
method: 'PUT',
});
},
toggleTop: async (id: number): Promise<void> => {
return await request<void>(`/notification/${id}/toggle-top`, {
method: 'PUT',
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/notification/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 查询分享接口 ====================
export interface QueryShare {
id: number;
shareUserId: number;
receiveUserId: number;
queryLogId: number;
shareTime: string;
receiveStatus: number;
}
export const queryShareApi = {
getByReceiveUser: async (receiveUserId: number): Promise<QueryShare[]> => {
return await request<QueryShare[]>(`/query-share/list/receive/${receiveUserId}`);
},
getByReceiveUserAndStatus: async (receiveUserId: number, receiveStatus: number): Promise<QueryShare[]> => {
return await request<QueryShare[]>(`/query-share/list/receive/${receiveUserId}/${receiveStatus}`);
},
getByShareUser: async (shareUserId: number): Promise<QueryShare[]> => {
return await request<QueryShare[]>(`/query-share/list/share/${shareUserId}`);
},
getById: async (id: number): Promise<QueryShare> => {
return await request<QueryShare>(`/query-share/${id}`);
},
create: async (queryShare: Partial<QueryShare>): Promise<QueryShare> => {
return await request<QueryShare>('/query-share', {
method: 'POST',
body: JSON.stringify(queryShare),
});
},
update: async (queryShare: Partial<QueryShare>): Promise<QueryShare> => {
return await request<QueryShare>('/query-share', {
method: 'PUT',
body: JSON.stringify(queryShare),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/query-share/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 查询日志接口 ====================
export interface QueryLog {
id: number;
userId: number;
dialogId: string;
userPrompt: string;
sqlQuery: string;
queryResult: string;
queryTime: string;
executionTime: string;
llmConfigId: number;
dbConnectionId: number;
}
export const queryLogApi = {
getList: async (): Promise<QueryLog[]> => {
return await request<QueryLog[]>('/query-log/list');
},
getByUser: async (userId: number): Promise<QueryLog[]> => {
return await request<QueryLog[]>(`/query-log/list/user/${userId}`);
},
getByDialog: async (dialogId: string): Promise<QueryLog[]> => {
return await request<QueryLog[]>(`/query-log/list/dialog/${dialogId}`);
},
getById: async (id: number): Promise<QueryLog> => {
return await request<QueryLog>(`/query-log/${id}`);
},
create: async (queryLog: Partial<QueryLog>): Promise<QueryLog> => {
return await request<QueryLog>('/query-log', {
method: 'POST',
body: JSON.stringify(queryLog),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/query-log/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 好友关系接口 ====================
export interface FriendRelation {
id: number;
userId: number;
friendId: number;
remark?: string;
onlineStatus: number;
createTime: string;
}
export interface FriendRelationWithUser extends FriendRelation {
friendUser?: User;
}
export const friendRelationApi = {
getByUser: async (userId: number): Promise<FriendRelation[]> => {
return await request<FriendRelation[]>(`/friend-relation/list/${userId}`);
},
getByUserAndFriend: async (userId: number, friendId: number): Promise<FriendRelation> => {
return await request<FriendRelation>(`/friend-relation/${userId}/${friendId}`);
},
create: async (friendRelation: Partial<FriendRelation>): Promise<FriendRelation> => {
return await request<FriendRelation>('/friend-relation', {
method: 'POST',
body: JSON.stringify(friendRelation),
});
},
update: async (friendRelation: Partial<FriendRelation>): Promise<FriendRelation> => {
return await request<FriendRelation>('/friend-relation', {
method: 'PUT',
body: JSON.stringify(friendRelation),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/friend-relation/${id}`, {
method: 'DELETE',
});
},
deleteByUserAndFriend: async (userId: number, friendId: number): Promise<void> => {
return await request<void>(`/friend-relation/${userId}/${friendId}`, {
method: 'DELETE',
});
},
};
// ==================== 好友请求接口 ====================
export interface FriendRequest {
id: number;
sendUserId: number;
recipientId: number;
status: number;
createTime: string;
handleTime?: string;
}
export interface FriendRequestWithUser extends FriendRequest {
applicantUser?: User;
}
export const friendRequestApi = {
getByRecipient: async (recipientId: number): Promise<FriendRequest[]> => {
return await request<FriendRequest[]>(`/friend-request/list/${recipientId}`);
},
getByRecipientAndStatus: async (recipientId: number, status: number): Promise<FriendRequest[]> => {
return await request<FriendRequest[]>(`/friend-request/list/${recipientId}/${status}`);
},
getById: async (id: number): Promise<FriendRequest> => {
return await request<FriendRequest>(`/friend-request/${id}`);
},
create: async (friendRequest: Partial<FriendRequest>): Promise<FriendRequest> => {
return await request<FriendRequest>('/friend-request', {
method: 'POST',
body: JSON.stringify(friendRequest),
});
},
update: async (friendRequest: Partial<FriendRequest>): Promise<FriendRequest> => {
return await request<FriendRequest>('/friend-request', {
method: 'PUT',
body: JSON.stringify(friendRequest),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/friend-request/${id}`, {
method: 'DELETE',
});
},
accept: async (id: number): Promise<string> => {
return await request<string>(`/friend-request/${id}/accept`, {
method: 'POST',
});
},
reject: async (id: number): Promise<string> => {
return await request<string>(`/friend-request/${id}/reject`, {
method: 'POST',
});
},
};
// ==================== 好友聊天接口 ====================
export interface FriendChat {
id: string;
sendUserId: number;
receiveUserId: number;
content: string;
sendTime: string;
isRead: boolean;
}
export const friendChatApi = {
getByUserAndFriend: async (userId: number, friendId: number): Promise<FriendChat[]> => {
return await request<FriendChat[]>(`/friend-chat/list/${userId}/${friendId}`);
},
getUnreadByFriend: async (friendId: number): Promise<FriendChat[]> => {
return await request<FriendChat[]>(`/friend-chat/unread/${friendId}`);
},
create: async (friendChat: Partial<FriendChat>): Promise<FriendChat> => {
return await request<FriendChat>('/friend-chat', {
method: 'POST',
body: JSON.stringify(friendChat),
});
},
update: async (friendChat: Partial<FriendChat>): Promise<FriendChat> => {
return await request<FriendChat>('/friend-chat', {
method: 'PUT',
body: JSON.stringify(friendChat),
});
},
delete: async (id: string): Promise<void> => {
return await request<void>(`/friend-chat/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 用户搜索接口 ====================
export interface UserSearchRecord {
id: number;
userId: number;
searchKeyword: string;
searchTime: string;
}
export const userSearchApi = {
searchByEmail: async (email: string): Promise<User[]> => {
return await request<User[]>(`/user/search/email?email=${encodeURIComponent(email)}`);
},
searchByPhoneNumber: async (phoneNumber: string): Promise<User> => {
return await request<User>(`/user/search/phone?phoneNumber=${encodeURIComponent(phoneNumber)}`);
},
getSearchHistory: async (userId: number): Promise<UserSearchRecord[]> => {
return await request<UserSearchRecord[]>(`/user-search/list/${userId}`);
},
getTopSearches: async (userId: number, limit: number): Promise<UserSearchRecord[]> => {
return await request<UserSearchRecord[]>(`/user-search/list/top/${userId}/${limit}`);
},
saveSearch: async (userSearch: Partial<UserSearchRecord>): Promise<UserSearchRecord> => {
return await request<UserSearchRecord>('/user-search', {
method: 'POST',
body: JSON.stringify(userSearch),
});
},
};
// ==================== 查询收藏接口 ====================
export interface QueryCollection {
id: number;
userId: number;
collectionName: string;
description?: string;
createTime: string;
}
export interface CollectionRecord {
id: string;
collectionId: number;
queryLogId: number;
addTime: string;
}
export const queryCollectionApi = {
getByUser: async (userId: number): Promise<QueryCollection[]> => {
return await request<QueryCollection[]>(`/query-collection/list/${userId}`);
},
create: async (collection: Partial<QueryCollection>): Promise<QueryCollection> => {
return await request<QueryCollection>('/query-collection', {
method: 'POST',
body: JSON.stringify(collection),
});
},
update: async (collection: Partial<QueryCollection>): Promise<QueryCollection> => {
return await request<QueryCollection>('/query-collection', {
method: 'PUT',
body: JSON.stringify(collection),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/query-collection/${id}`, {
method: 'DELETE',
});
},
};
export const collectionRecordApi = {
getByCollection: async (collectionId: string): Promise<CollectionRecord[]> => {
return await request<CollectionRecord[]>(`/collection-record/list/query/${collectionId}`);
},
create: async (record: Partial<CollectionRecord>): Promise<CollectionRecord> => {
return await request<CollectionRecord>('/collection-record', {
method: 'POST',
body: JSON.stringify(record),
});
},
delete: async (id: string): Promise<void> => {
return await request<void>(`/collection-record/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 操作日志接口 ====================
export interface OperationLog {
id: number;
userId: number;
module: string;
operateType: string;
operateDesc: string;
operateTime: string;
ip: string;
status: number;
}
export const operationLogApi = {
getList: async (): Promise<OperationLog[]> => {
return await request<OperationLog[]>('/operation-log/list');
},
getByUser: async (userId: number): Promise<OperationLog[]> => {
return await request<OperationLog[]>(`/operation-log/list/user/${userId}`);
},
getByModule: async (module: string): Promise<OperationLog[]> => {
return await request<OperationLog[]>(`/operation-log/list/module/${encodeURIComponent(module)}`);
},
getFailed: async (): Promise<OperationLog[]> => {
return await request<OperationLog[]>('/operation-log/list/failed');
},
getById: async (id: number): Promise<OperationLog> => {
return await request<OperationLog>(`/operation-log/${id}`);
},
create: async (log: Partial<OperationLog>): Promise<OperationLog> => {
return await request<OperationLog>('/operation-log', {
method: 'POST',
body: JSON.stringify(log),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/operation-log/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 错误日志接口 ====================
export interface ErrorLog {
id: number;
errorTypeId: number;
errorCount: number;
statPeriod: string;
statTime: string;
}
export const errorLogApi = {
getList: async (): Promise<ErrorLog[]> => {
return await request<ErrorLog[]>('/error-log/list');
},
getByErrorType: async (errorTypeId: number): Promise<ErrorLog[]> => {
return await request<ErrorLog[]>(`/error-log/list/type/${errorTypeId}`);
},
getByPeriod: async (period: string): Promise<ErrorLog[]> => {
return await request<ErrorLog[]>(`/error-log/list/period/${encodeURIComponent(period)}`);
},
getById: async (id: number): Promise<ErrorLog> => {
return await request<ErrorLog>(`/error-log/${id}`);
},
create: async (log: Partial<ErrorLog>): Promise<ErrorLog> => {
return await request<ErrorLog>('/error-log', {
method: 'POST',
body: JSON.stringify(log),
});
},
update: async (log: Partial<ErrorLog>): Promise<ErrorLog> => {
return await request<ErrorLog>('/error-log', {
method: 'PUT',
body: JSON.stringify(log),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/error-log/${id}`, {
method: 'DELETE',
});
},
};
// ==================== 数据库连接日志接口 ====================
export interface DbConnectionLog {
id: number;
dbConnectionId: number;
connectTime: string;
disconnectTime?: string;
status: number;
errorMessage?: string;
}
export const dbConnectionLogApi = {
getList: async (): Promise<DbConnectionLog[]> => {
return await request<DbConnectionLog[]>('/db-connection-log/list');
},
getByConnection: async (dbConnectionId: number): Promise<DbConnectionLog[]> => {
return await request<DbConnectionLog[]>(`/db-connection-log/list/connection/${dbConnectionId}`);
},
getById: async (id: number): Promise<DbConnectionLog> => {
return await request<DbConnectionLog>(`/db-connection-log/${id}`);
},
create: async (log: Partial<DbConnectionLog>): Promise<DbConnectionLog> => {
return await request<DbConnectionLog>('/db-connection-log', {
method: 'POST',
body: JSON.stringify(log),
});
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/db-connection-log/${id}`, {
method: 'DELETE',
});
},
};

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["*.d.ts", "**/*.ts", "**/*.tsx"]
}

@ -0,0 +1,193 @@
// Basic types
export type UserRole = 'sys-admin' | 'data-admin' | 'normal-user';
export type MessageRole = 'user' | 'ai';
// Page navigation types
export type Page = 'query' | 'history' | 'notifications' | 'account' | 'friends' | 'comparison';
export type SysAdminPageType = 'dashboard' | 'user-management' | 'notification-management' | 'system-log' | 'llm-config' | 'account';
export type DataAdminPageType = 'dashboard' | 'query' | 'history' | 'datasource' | 'user-permission' | 'notification-management' | 'connection-log' | 'notifications' | 'account' | 'friends' | 'comparison';
// Data structure types
export interface ModelOption {
name: string;
disabled: boolean;
description: string;
}
export interface ChartData {
type: 'bar' | 'line' | 'pie';
labels: string[];
datasets: {
label: string;
data: number[];
backgroundColor: string | string[];
}[];
}
export interface TableData {
headers: string[];
rows: string[][];
}
export interface QueryResultData {
id: string;
userPrompt: string;
sqlQuery: string;
conversationId: string;
queryTime: string;
executionTime: string;
tableData: TableData;
chartData: ChartData;
database: string;
model:string;
}
export interface Message {
role: MessageRole;
content: string | QueryResultData;
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
createTime: string;
}
export interface Notification {
id: string;
type: 'system' | 'share';
title: string;
content: string;
timestamp: string;
isRead: boolean;
isPinned: boolean;
fromUser?: { name: string, avatarUrl: string };
relatedShareId?: string;
}
export interface UserProfile {
id: string;
userId: string;
name: string;
email: string;
phoneNumber: string;
avatarUrl: string;
registrationDate: string;
accountStatus: 'normal' | 'disabled';
preferences: {
defaultModel: string;
defaultDatabase: string;
};
}
export interface Friend {
id: string;
name: string;
avatarUrl: string;
isOnline: boolean;
email: string;
remark?: string;
}
export interface FriendRequest {
id: string;
fromUser: { name: string; avatarUrl: string };
timestamp: string;
}
export interface QueryShare {
id: string;
sender: Friend;
recipientId: string; // The ID of the user receiving the share
querySnapshot: QueryResultData;
timestamp: string;
status: 'unread' | 'read';
}
// Admin Panel Types
export interface AdminNotification {
id: number;
title: string;
content: string;
role: 'all' | UserRole;
priority: 'urgent' | 'important' | 'normal';
pinned: boolean;
publisher: string;
publishTime: string;
status: 'published' | 'draft';
dataSourceTopic?: string;
}
export interface AdminUser {
id: number;
username: string;
role: UserRole;
email: string;
regTime: string;
status: 'active' | 'disabled';
}
export interface SystemLog {
id: string;
time: string;
user: string;
action: string;
model: string;
ip: string;
status: 'success' | 'failure';
details?: string;
}
export interface LLMConfig {
id: string;
name: string;
version: string;
apiKey: string;
endpoint: string;
status: 'available' | 'unstable' | 'unavailable' | 'testing' | 'disabled';
}
// Data Admin Types
export interface DataSource {
id: string;
name: string;
type: 'MySQL' | 'PostgreSQL' | 'Oracle' | 'SQL Server';
address: string;
status: 'connected' | 'disconnected' | 'error' | 'testing' | 'disabled';
}
export interface DataSourcePermission {
dataSourceId: string;
dataSourceName: string;
tables: string[];
}
export interface UserPermissionAssignment {
id: string;
userId: string;
username: string;
permissions: DataSourcePermission[];
}
export interface UnassignedUser {
id: string;
username: string;
email: string;
regTime: string;
}
export interface ConnectionLog {
id: string;
time: string;
datasource: string;
status: '成功' | '失败';
details?: string;
}
export interface PermissionLog {
id: string;
timestamp: string;
text: string;
}

@ -0,0 +1,35 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', ''); // 加载环境变量
return {
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
// 代理通义千问请求(关键配置)
'/api/qwen': {
target: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
changeOrigin: true, // 开启跨域代理
rewrite: (path) => path.replace(/^\/api\/qwen/, ''), // 重写路径
headers: {
// 注入API密钥仅开发环境安全生产需用后端代理
Authorization: `Bearer ${env.VITE_QWEN_API_KEY}`,
},
},
},
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

@ -0,0 +1,507 @@
### 一、完整表/集合清单按MySQL + MongoDB分类
#### MySQL 8.4 数据表(结构化数据)
##### 1. 基础信息表(用户、角色、字典)
1.1 users用户表
1.2 roles角色表
1.3 db_types数据库类型表
1.4 notification_targets通知目标表
1.5 priorities通知优先级表
1.6 error_types错误类型表
1.7 llm_status大模型状态表
##### 2. 数据资源表(数据库、表、字段元数据)
2.1 db_connections数据库连接表
2.2 table_metadata表元数据表
2.3 column_metadata字段元数据表
##### 3. 权限与关系表(用户权限、好友关系)
3.1 user_db_permissions用户数据权限表
3.2 friend_relations好友关系表
3.3 friend_requests好友请求表
3.4 query_shares查询分享记录表
##### 4. 系统日志与监控表
4.1 system_health系统健康表
4.2 operation_logs系统操作日志表
4.3 llm_configs大模型配置表
4.4 notifications通知表
4.5 token_consumetoken消耗表
4.6 error_logs错误分析表
4.7 performance_metrics性能趋势表
4.8 db_connection_logs数据库连接日志表
4.9 query_logs查询日志表
4.10 user_searches用户搜索表
#### MongoDB 8.2 集合(非结构化数据)
1. query_collections收藏查询表-组)
2. collection_records收藏记录表-具体记录)
3. dialog_records多轮对话列表
4. dialog_details多轮对话具体内容表
5. sql_cacheSQL缓存集合
6. ai_interaction_logsAI交互日志集合
7. friend_chats好友聊天记录表
---
### 二、表/集合详细字段(严格遵循文档)
#### MySQL 数据表字段
##### 1. 基础信息表
###### 1.1 users用户表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|--------------------------|--------------------------------------------|-----------------------------------------------------------|
| id | 用户ID | bigint(20) | 是(自增) | - | 唯一标识用户 | PRIMARY KEY (id) |
| username | 用户名 | varchar(50) | - | - | 登录账号,唯一 | UNIQUE (username), NOT NULL |
| password | 密码 | varchar(100)| - | - | BCrypt加密存储 | NOT NULL |
| email | 邮箱 | varchar(100)| - | - | 用于好友添加、通知 | UNIQUE (email), NOT NULL |
| phonenumber | 手机号 | varchar(50) | - | - | 用户绑定的手机号 | UNIQUE (phonenumber), NOT NULL |
| role_id | 角色ID | tinyint(2) | - | 是关联roles.id | 关联角色类型 | NOT NULL, FOREIGN KEY (role_id) REFERENCES roles(id) |
| avatar_url | 头像URL | varchar(255)| - | - | 头像路径,默认"/default-avatar.png" | DEFAULT '/default-avatar.png' |
| status | 账号状态 | tinyint(1) | - | - | 0-禁用1-正常 | DEFAULT 1, NOT NULL |
| online_status | 在线状态 | tinyint(1) | - | - | 0-离线1-在线 | DEFAULT 0, NOT NULL |
| create_time | 创建时间 | datetime | - | - | 账号创建时间(系统管理员创建) | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| update_time | 更新时间 | datetime | - | - | 信息更新时间 | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, NOT NULL |
###### 1.2 roles角色表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|------|--------------------------------------------|---------------------------|
| id | 角色ID | tinyint(2) | 是(自增) | - | 唯一标识角色 | PRIMARY KEY (id) |
| role_name | 角色名称 | varchar(30) | - | - | 如"系统管理员"、"数据管理员"、"普通用户" | UNIQUE (role_name), NOT NULL |
| role_code | 角色编码 | varchar(20) | - | - | 如"sys_admin"、"data_admin"、"normal_user" | UNIQUE (role_code), NOT NULL |
| description | 角色描述 | varchar(500)| - | - | 角色权限范围说明 | - |
###### 1.3 db_types数据库类型表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|------|--------------------------------------------|---------------------------|
| id | 类型ID | tinyint(2) | 是(自增) | - | 唯一标识数据库类型 | PRIMARY KEY (id) |
| type_name | 类型名称 | varchar(50) | - | - | 如"MySQL"、"MongoDB"、"SQL Server" | UNIQUE (type_name), NOT NULL |
| type_code | 类型编码 | varchar(20) | - | - | 如"mysql"、"mongodb"、"mssql" | UNIQUE (type_code), NOT NULL |
| description | 类型描述 | varchar(500)| - | - | 数据库特性说明 | - |
###### 1.4 notification_targets通知目标表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|------|--------------------------------------------|---------------------------|
| id | 目标ID | tinyint(2) | 是(自增) | - | 唯一标识通知目标 | PRIMARY KEY (id) |
| target_name | 目标名称 | varchar(30) | - | - | 如"所有用户"、"普通用户" | UNIQUE (target_name), NOT NULL |
| target_code | 目标编码 | varchar(20) | - | - | 如"all"、"normal_user" | UNIQUE (target_code), NOT NULL |
| description | 目标描述 | varchar(200)| - | - | 接收范围说明 | - |
###### 1.5 priorities通知优先级表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|------|--------------------------------------------|---------------------------|
| id | 优先级ID | tinyint(2) | 是(自增) | - | 唯一标识优先级 | PRIMARY KEY (id) |
| priority_name | 优先级名称 | varchar(20) | - | - | 如"紧急"、"普通"、"低" | UNIQUE (priority_name), NOT NULL |
| priority_code | 优先级编码 | varchar(20) | - | - | 如"urgent"、"normal"、"low" | UNIQUE (priority_code), NOT NULL |
| sort | 排序权重 | int(11) | - | - | 数值越小越优先展示1-紧急2-普通) | NOT NULL |
###### 1.6 error_types错误类型表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|------|--------------------------------------------|---------------------------|
| id | 错误ID | tinyint(2) | 是(自增) | - | 唯一标识错误类型 | PRIMARY KEY (id) |
| error_name | 错误名称 | varchar(50) | - | - | 如"模型调用超时"、"数据库连接错误" | UNIQUE (error_name), NOT NULL |
| error_code | 错误编码 | varchar(50) | - | - | 如"llm_timeout"、"db_connection_error" | UNIQUE (error_code), NOT NULL |
| description | 错误描述 | varchar(500)| - | - | 错误原因说明 | - |
###### 1.7 llm_status大模型状态表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|------|--------------------------------------------|---------------------------|
| id | 状态ID | tinyint(2) | 是(自增) | - | 唯一标识大模型状态 | PRIMARY KEY (id) |
| status_name | 状态名称 | varchar(20) | - | - | 如"可用"、"不可用"、"不稳定" | UNIQUE (status_name), NOT NULL |
| status_code | 状态编码 | varchar(20) | - | - | 如"available"、"unavailable"、"unstable" | UNIQUE (status_code), NOT NULL |
| description | 状态描述 | varchar(200)| - | - | 状态含义说明(如"可用API成功率≥95%" | - |
##### 2. 数据资源表
###### 2.1 db_connections数据库连接表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|--------------|-------------|------------|--------------------------|--------------------------------------------|-----------------------------------------------------------|
| id | 连接ID | bigint(20) | 是(自增) | - | 唯一标识数据库连接 | PRIMARY KEY (id) |
| name | 连接名称 | varchar(100)| - | - | 用户自定义名称(如"订单主库" | UNIQUE (name), NOT NULL |
| db_type_id | 数据库类型ID | tinyint(2) | - | 是关联db_types.id | 关联数据库类型 | NOT NULL, FOREIGN KEY (db_type_id) REFERENCES db_types(id) |
| url | 连接地址 | varchar(255)| - | - | 如"192.168.1.101:3306/orders_db" | NOT NULL |
| username | 数据库账号 | varchar(50) | - | - | 访问数据库的账号(加密存储) | NOT NULL |
| password | 数据库密码 | varchar(100)| - | - | 访问数据库的密码(加密存储) | NOT NULL |
| status | 连接状态 | varchar(20) | - | - | "connected"-已连接、"error"-连接错误、"disabled"-已禁用 | DEFAULT 'disconnected', NOT NULL |
| create_user_id | 创建者ID | bigint(20) | - | 是关联users.id | 仅数据管理员可创建 | NOT NULL, FOREIGN KEY (create_user_id) REFERENCES users(id) |
| create_time | 创建时间 | datetime | - | - | 连接创建时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| update_time | 更新时间 | datetime | - | - | 连接信息更新时间 | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, NOT NULL |
###### 2.2 table_metadata表元数据表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|------------------|-------------|------------|------------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 表ID | bigint(20) | 是(自增) | - | 唯一标识表元数据 | PRIMARY KEY (id) |
| db_connection_id | 数据库连接ID | bigint(20) | - | 是关联db_connections.id | 所属数据库连接 | NOT NULL, FOREIGN KEY (db_connection_id) REFERENCES db_connections(id) ON DELETE CASCADE |
| table_name | 表名 | varchar(100)| - | - | 数据库中实际表名(如"orders_2023" | NOT NULL |
| description | 表描述 | varchar(500)| - | - | 用于AI理解表含义如"存储2023年订单数据" | - |
| create_time | 创建时间 | datetime | - | - | 元数据录入时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| update_time | 更新时间 | datetime | - | - | 元数据更新时间 | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, NOT NULL |
###### 2.3 column_metadata字段元数据表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|------------------|-------------|------------|----------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 字段ID | bigint(20) | 是(自增) | - | 唯一标识字段元数据 | PRIMARY KEY (id) |
| table_id | 表ID | bigint(20) | - | 是关联table_metadata.id | 所属表 | NOT NULL, FOREIGN KEY (table_id) REFERENCES table_metadata(id) ON DELETE CASCADE |
| column_name | 字段名 | varchar(100)| - | - | 表中实际字段名(如"order_amount" | NOT NULL |
| data_type | 数据类型 | varchar(50) | - | - | 字段数据类型(如"int"、"varchar(50)" | NOT NULL |
| description | 字段描述 | varchar(500)| - | - | 用于AI理解字段含义如"订单金额,单位:元" | - |
| is_primary | 是否主键 | tinyint(1) | - | - | 0-否1-是 | DEFAULT 0, NOT NULL |
| create_time | 创建时间 | datetime | - | - | 元数据录入时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
##### 3. 权限与关系表
###### 3.1 user_db_permissions用户数据权限表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 权限ID | bigint(20) | 是(自增) | - | 唯一标识权限记录 | PRIMARY KEY (id) |
| user_id | 用户ID | bigint(20) | - | 是关联users.id | 被授权用户(普通用户) | NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
| permission_details | 权限详情 | json | - | - | 可访问的表列表,格式:[{"db_connection_id":1, "table_ids":[1,2]},...] | NOT NULL |
| last_grant_user_id | 最后授权管理员ID | bigint(20) | - | 是关联users.id | 最后修改权限的数据管理员/系统管理员 | NOT NULL, FOREIGN KEY (last_grant_user_id) REFERENCES users(id) |
| is_assigned | 是否已分配 | tinyint(1) | - | - | 0-未分配1-已分配默认0 | DEFAULT 0, NOT NULL |
| last_grant_time | 最后授权时间 | datetime | - | - | 最后一次权限修改时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| update_time | 更新时间 | datetime | - | - | 记录更新时间 | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, NOT NULL |
###### 3.2 friend_relations好友关系表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 关系ID | bigint(20) | 是(自增) | - | 唯一标识好友关系 | PRIMARY KEY (id) |
| user_id | 用户ID | bigint(20) | - | 是关联users.id | 主动添加方 | NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
| friend_id | 好友ID | bigint(20) | - | 是关联users.id | 被动添加方 | NOT NULL, FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE |
| friend_username | 好友用户名 | varchar(50) | - | - | 冗余存储,便于列表展示 | NOT NULL |
| online_status | 好友在线状态 | tinyint(1) | - | - | 0-离线1-在线 | DEFAULT 0, NOT NULL |
| remark_name | 备注名 | varchar(50) | - | - | 用户对好友的自定义备注 | - |
| create_time | 添加时间 | datetime | - | - | 好友关系建立时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
###### 3.3 friend_requests好友请求表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 请求ID | bigint(20) | 是(自增) | - | 唯一标识好友请求 | PRIMARY KEY (id) |
| applicant_id | 申请人ID | bigint(20) | - | 是关联users.id | 发起请求的用户 | NOT NULL, FOREIGN KEY (applicant_id) REFERENCES users(id) ON DELETE CASCADE |
| recipient_id | 接收人ID | bigint(20) | - | 是关联users.id | 收到请求的用户 | NOT NULL, FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE |
| apply_msg | 申请留言 | varchar(200)| - | - | 申请人留言(如"我是张三,加个好友" | - |
| status | 请求状态 | tinyint(1) | - | - | 0-待处理1-已同意2-已拒绝 | DEFAULT 0, NOT NULL |
| create_time | 申请时间 | datetime | - | - | 请求发起时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| handle_time | 处理时间 | datetime | - | - | 接收人处理时间未处理为NULL | - |
###### 3.4 query_shares查询分享记录表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 分享ID | bigint(20) | 是(自增) | - | 唯一标识分享记录 | PRIMARY KEY (id) |
| share_user_id | 分享人ID | bigint(20) | - | 是关联users.id | 发起分享的用户 | NOT NULL, FOREIGN KEY (share_user_id) REFERENCES users(id) ON DELETE CASCADE |
| receive_user_id | 接收人ID | bigint(20) | - | 是关联users.id | 接收分享的好友 | NOT NULL, FOREIGN KEY (receive_user_id) REFERENCES users(id) ON DELETE CASCADE |
| dialog_id | 会话ID | varchar(50) | - | - | 关联MongoDB的dialog_details集合定位具体对话文档 | NOT NULL |
| target_rounds | 会话轮次数组 | json | - | - | 筛选指定轮次序号(roundNum)的元素 | NOT NULL |
| query_title | 查询标题 | varchar(200)| - | - | 冗余存储,便于接收人查看 | NOT NULL |
| share_time | 分享时间 | datetime | - | - | 分享发起时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| receive_status | 接收状态 | tinyint(1) | - | - | 0-未处理1-已保存2-已删除 | DEFAULT 0, NOT NULL |
##### 4. 系统日志与监控表
###### 4.1 system_health系统健康表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|------|--------------------------------------------|---------------------------|
| id | 记录ID | bigint(20) | 是(自增) | - | 唯一标识健康记录 | PRIMARY KEY (id) |
| db_delay | 数据库延迟 | int(11) | - | - | 数据库服务响应延迟ms | NOT NULL |
| cache_delay | 缓存延迟 | int(11) | - | - | 缓存服务响应延迟ms | NOT NULL |
| llm_delay | 大模型延迟 | int(11) | - | - | 大模型API响应延迟ms | NOT NULL |
| storage_usage | 存储使用率 | decimal(5,2)| - | - | 存储服务使用率(%如95.50 | NOT NULL |
| collect_time | 采集时间 | datetime | - | - | 数据采集时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
###### 4.2 operation_logs系统操作日志表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 日志ID | bigint(20) | 是(自增) | - | 唯一标识操作日志 | PRIMARY KEY (id) |
| user_id | 用户ID | bigint(20) | - | 是关联users.id | 操作人ID匿名操作为0 | NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) |
| username | 用户名 | varchar(50) | - | - | 操作人用户名(冗余,便于查询) | NOT NULL |
| operation | 操作名称 | varchar(100)| - | - | 如"创建数据库连接"、"分配用户权限" | NOT NULL |
| module | 操作模块 | varchar(50) | - | - | 如"数据源管理"、"用户管理" | NOT NULL |
| related_llm | 涉及模型 | varchar(50) | - | - | 关联大模型名称无则为NULL | - |
| ip_address | IP地址 | varchar(50) | - | - | 操作人IP地址 | NOT NULL |
| operate_time | 操作时间 | datetime | - | - | 操作执行时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| result | 操作结果 | tinyint(1) | - | - | 0-失败1-成功 | NOT NULL |
| error_msg | 错误信息 | text | - | - | 失败原因成功为NULL | - |
###### 4.3 llm_configs大模型配置表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 配置ID | bigint(20) | 是(自增) | - | 唯一标识大模型配置 | PRIMARY KEY (id) |
| name | 模型名称 | varchar(50) | - | - | 如"智谱AI"、"通义千问" | UNIQUE (name), NOT NULL |
| version | 模型版本 | varchar(20) | - | - | 如"4.0"、"3.0" | NOT NULL |
| api_key | API密钥 | varchar(200)| - | - | 大模型API KeyAES加密 | NOT NULL |
| api_url | API地址 | varchar(255)| - | - | 调用地址(如"https://api.zhipuai.com/v4/chat/completions" | NOT NULL |
| status_id | 状态ID | tinyint(2) | - | 是关联llm_status.id | 模型状态(可用/不可用/不稳定) | NOT NULL, FOREIGN KEY (status_id) REFERENCES llm_status(id) |
| is_disabled | 是否禁用 | tinyint(1) | - | - | 0-启用1-禁用(强制下线) | DEFAULT 0, NOT NULL |
| timeout | 超时时间 | int(11) | - | - | API调用超时阈值ms如5000 | NOT NULL |
| create_user_id | 创建人ID | bigint(20) | - | 是关联users.id | 仅系统管理员可创建 | NOT NULL, FOREIGN KEY (create_user_id) REFERENCES users(id) |
| create_time | 创建时间 | datetime | - | - | 配置创建时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| update_time | 更新时间 | datetime | - | - | 配置更新时间 | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, NOT NULL |
###### 4.4 notifications通知表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 通知ID | bigint(20) | 是(自增) | - | 唯一标识通知 | PRIMARY KEY (id) |
| title | 通知标题 | varchar(100)| - | - | 通知标题(如"系统维护通知" | NOT NULL |
| content | 通知内容 | text | - | - | 通知详细内容支持简单HTML | NOT NULL |
| target_id | 目标ID | tinyint(2) | - | 是关联notification_targets.id | 接收目标(所有用户/指定角色) | NOT NULL, FOREIGN KEY (target_id) REFERENCES notification_targets(id) |
| priority_id | 优先级ID | tinyint(2) | - | 是关联priorities.id | 通知优先级(紧急/普通/低) | NOT NULL, FOREIGN KEY (priority_id) REFERENCES priorities(id) |
| publisher_id | 发布者ID | bigint(20) | - | 是关联users.id | 发布通知的管理员 | NOT NULL, FOREIGN KEY (publisher_id) REFERENCES users(id) |
| is_top | 是否置顶 | tinyint(1) | - | - | 0-否1-是(置顶优先展示) | DEFAULT 0, NOT NULL |
| publish_time | 发布时间 | datetime | - | - | 发布时间草稿为NULL | - |
| create_time | 创建时间 | datetime | - | - | 草稿创建时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| Latest_updateTime | 上次更新时间 | datetime | - | - | 最近一次编辑并保存的时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
###### 4.5 token_consumetoken消耗表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|------|--------------------------------------------|---------------------------------------------------------------|
| id | 记录ID | bigint(20) | 是(自增) | - | 唯一标识消耗记录 | PRIMARY KEY (id) |
| llm_name | 模型名称 | varchar(50) | - | - | 消耗token的大模型 | NOT NULL |
| total_tokens | 总消耗 | int(11) | - | - | 当日总消耗(输入+输出) | NOT NULL |
| prompt_tokens | 输入消耗 | int(11) | - | - | 当日输入token消耗 | NOT NULL |
| completion_tokens | 输出消耗 | int(11) | - | - | 当日输出token消耗 | NOT NULL |
| consume_date | 消耗日期 | date | - | - | 消耗日期(如"2025-11-05" | NOT NULL |
| growth_rate | 增长率 | decimal(5,2)| - | - | 较前一日增长率(%,如+12.50 | - |
###### 4.6 error_logs错误分析表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 记录ID | bigint(20) | 是(自增) | - | 唯一标识错误记录 | PRIMARY KEY (id) |
| error_type_id | 错误类型ID | tinyint(2) | - | 是关联error_types.id | 错误类型(模型超时/数据库错误等) | NOT NULL, FOREIGN KEY (error_type_id) REFERENCES error_types(id) |
| error_count | 错误次数 | int(11) | - | - | 统计周期内错误次数 | NOT NULL |
| error_rate | 错误率 | decimal(5,2)| - | - | 错误次数占总请求比例(% | - |
| period | 统计周期 | varchar(20) | - | - | "today"-今日、"7days"-近7日 | NOT NULL |
| stat_time | 统计时间 | datetime | - | - | 统计生成时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
###### 4.7 performance_metrics性能趋势表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|------|--------------------------------------------|---------------------------------------------------------------|
| id | 指标ID | bigint(20) | 是(自增) | - | 唯一标识性能指标 | PRIMARY KEY (id) |
| metric_type | 指标类型 | varchar(20) | - | - | "query_count"-查询量、"response_time"-响应时间 | NOT NULL |
| metric_value | 指标值 | decimal(10,2)| - | - | 指标数值查询量为整数响应时间单位ms | NOT NULL |
| metric_time | 指标时间 | datetime | - | - | 指标采集时间(精确到小时) | NOT NULL |
| trend | 趋势标识 | tinyint(1) | - | - | 0-下降1-上升(较上一周期) | - |
###### 4.8 db_connection_logs数据库连接日志表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 日志ID | bigint(20) | 是(自增) | - | 唯一标识连接日志 | PRIMARY KEY (id) |
| db_connection_id | 数据库连接ID | bigint(20) | - | 是关联db_connections.id | 连接的数据源 | NOT NULL, FOREIGN KEY (db_connection_id) REFERENCES db_connections(id) |
| db_name | 数据库名称 | varchar(100)| - | - | 冗余存储,便于查看 | NOT NULL |
| connect_time | 连接时间 | datetime | - | - | 尝试连接时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| status | 连接状态 | varchar(20) | - | - | "success"-成功、"timeout"-超时、"auth_failed"-认证失败 | NOT NULL |
| remark | 备注信息 | text | - | - | 错误代码 | - |
| handler_id | 处理人ID | bigint(20) | - | 是关联users.id | 触发连接的用户NULL为系统检测 | FOREIGN KEY (handler_id) REFERENCES users(id) |
###### 4.9 query_logs查询日志表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 日志ID | bigint(20) | 是(自增) | - | 唯一标识查询日志 | PRIMARY KEY (id) |
| dialog_id | 对话ID | varchar(50) | - | - | 关联多轮对话ID | NOT NULL |
| data_source_id | 数据源ID | bigint(20) | - | 是关联db_connections.id | 查询的数据源 | NOT NULL, FOREIGN KEY (data_source_id) REFERENCES db_connections(id) |
| user_id | 用户ID | bigint(20) | - | 是关联users.id | 执行查询的用户 | NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) |
| query_date | 查询日期 | date | - | - | 查询执行日期 | NOT NULL |
| query_time | 查询时间 | datetime | - | - | 查询执行时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
| execute_result | 执行结果 | tinyint(1) | - | - | 0-失败1-成功 | NOT NULL |
###### 4.10 user_searches用户搜索表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键 | 字段描述 | 约束条件 |
|----------|----------------------|-------------|------------|--------------------------|--------------------------------------------|---------------------------------------------------------------|
| id | 搜索ID | bigint(20) | 是(自增) | - | 唯一标识搜索记录 | PRIMARY KEY (id) |
| user_id | 用户ID | bigint(20) | - | 是关联users.id | 执行搜索的用户 | NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
| sql_content | SQL语句 | text | - | - | 用户执行的SQL语句 | NOT NULL |
| query_title | 查询标题 | varchar(200)| - | - | 大模型生成的标题 | NOT NULL |
| search_count | 搜索次数 | int(11) | - | - | 该搜索被执行的次数 | DEFAULT 1, NOT NULL |
| last_search_time | 最后搜索时间 | datetime | - | - | 最后一次执行该搜索的时间 | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
#### MongoDB 8.2 集合字段
###### 1. query_collections收藏查询表-组)
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键关联 | 字段描述 | 约束条件 |
|----------|--------------|----------|--------------------|------------|--------------------------------------------|---------------------------|
| _id | 组ID | ObjectId | 是(自动生成) | - | 唯一标识收藏组 | PRIMARY KEY (_id) |
| userId | 用户ID | Long | - | users.id | 所属用户 | NOT NULL |
| groupName | 组名称 | String | - | - | 收藏组名称(由大模型生成,如"销售报表查询" | NOT NULL, 同用户内唯一 |
| createTime | 创建时间 | Date | - | - | 组创建时间 | DEFAULT new Date() |
###### 2. collection_records收藏记录表-具体记录)
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键关联 | 字段描述 | 约束条件 |
|----------|--------------|----------|--------------------|------------------------|--------------------------------------------|---------------------------|
| _id | 记录ID | ObjectId | 是(自动生成) | - | 唯一标识收藏记录 | PRIMARY KEY (_id) |
| queryId | 组ID | ObjectId | - | query_collections._id | 所属收藏组 | NOT NULL |
| userId | 用户ID | Long | - | users.id | 所属用户 | NOT NULL |
| sqlContent | SQL语句 | String | - | - | 收藏的SQL语句 | NOT NULL |
| queryResult | 查询结果 | Document | - | - | 结果数据(如{ "columns": [], "data": [] } | - |
| dbConnectionId | 数据库来源 | Long | - | db_connections.id | 收藏记录来源的数据库连接 | NOT NULL |
| llmConfigId | 大模型来源 | Long | - | llm_configs.id | 收藏记录来源的大模型配置 | NOT NULL |
| createTime | 创建时间 | Date | - | - | 收藏时间 | DEFAULT new Date() |
###### 3. dialog_records多轮对话列表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键关联 | 字段描述 | 约束条件 |
|----------|--------------|----------|--------------------|------------|--------------------------------------------|---------------------------|
| _id | 文档ID | ObjectId | 是(自动生成) | - | 唯一标识对话列表 | PRIMARY KEY (_id) |
| dialogId | 对话ID | String | - | - | 自定义唯一标识(如"USER123_20251105" | NOT NULL, UNIQUE |
| userId | 用户ID | Long | - | users.id | 所属用户 | NOT NULL |
| topic | 对话主题 | String | - | - | 大模型生成的主题(如"2023年订单分析" | NOT NULL |
| totalRounds | 总轮数 | Int | - | - | 对话总轮次 | DEFAULT 0 |
| startTime | 开始时间 | Date | - | - | 对话开始时间 | DEFAULT new Date() |
| lastTime | 最后对话时间 | Date | - | - | 最后一轮对话时间 | DEFAULT new Date() |
###### 4. dialog_details多轮对话具体内容表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键关联 | 字段描述 | 约束条件 |
|----------|--------------|----------|--------------------|------------------------|--------------------------------------------|---------------------------|
| _id | 文档ID | ObjectId | 是(自动生成) | - | 唯一标识对话内容 | PRIMARY KEY (_id) |
| dialogId | 对话ID | String | - | dialog_records.dialogId | 关联的对话列表 | NOT NULL |
| rounds | 对话轮次 | Array | - | - | 每轮对话详情:{ "roundNum": Int, "userInput": String, "aiResponse": String, "generatedSql": String, "roundTime": Date } | NOT NULL |
###### 5. sql_cacheSQL缓存集合
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键关联 | 字段描述 | 约束条件 |
|----------|--------------|----------|--------------------|------------------------|--------------------------------------------|---------------------------|
| _id | 文档ID | ObjectId | 是(自动生成) | - | 唯一标识缓存记录 | PRIMARY KEY (_id) |
| nlHash | 自然语言哈希 | String | - | - | 自然语言查询的MD5哈希快速匹配 | NOT NULL |
| userId | 用户ID | Long | - | users.id | 关联用户NULL为全局缓存 | - |
| connectionId | 数据源ID | Long | - | db_connections.id | 关联数据源 | NOT NULL |
| tableIds | 表ID列表 | Array | - | table_metadata.id | 涉及的表ID | NOT NULL |
| dbType | 数据库类型 | String | - | db_types.type_code | 如"mysql"、"mongodb" | NOT NULL |
| generatedSql | 生成的SQL | String | - | - | 缓存的SQL语句 | NOT NULL |
| hitCount | 命中次数 | Int | - | - | 缓存被复用次数 | DEFAULT 0 |
| expireTime | 过期时间 | Date | - | - | 缓存过期时间默认7天 | NOT NULL |
###### 6. ai_interaction_logsAI交互日志集合
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键关联 | 字段描述 | 约束条件 |
|----------|--------------|----------|--------------------|------------------------|--------------------------------------------|---------------------------|
| _id | 文档ID | ObjectId | 是(自动生成) | - | 唯一标识交互记录 | PRIMARY KEY (_id) |
| userId | 用户ID | Long | - | users.id | 发起请求的用户 | NOT NULL |
| requestType | 请求类型 | String | - | - | "nl2sql"-自然语言转SQL、"sql_optimize"-SQL优化 | NOT NULL |
| llmName | 模型名称 | String | - | llm_configs.name | 调用的大模型 | NOT NULL |
| requestParams | 请求参数 | Document | - | - | 请求参数:{ "naturalLanguage": String, "metadata": Object, "temperature": Double } | NOT NULL |
| responseResult | 响应结果 | Document | - | - | 响应结果:{ "sql": String, "confidence": Double, "suggestion": String } | - |
| tokenUsage | Token消耗 | Document | - | - | { "promptTokens": Int, "completionTokens": Int, "totalTokens": Int } | NOT NULL |
| responseTime | 响应时间 | Int | - | - | 响应耗时ms | NOT NULL |
| status | 交互状态 | String | - | - | "success"-成功、"fail"-失败 | NOT NULL |
| errorMsg | 错误信息 | String | - | - | 失败原因成功为NULL | - |
| createTime | 创建时间 | Date | - | - | 请求发起时间 | DEFAULT new Date() |
###### 7. friend_chats好友聊天记录表
| 字段代码 | 字段名 | 数据类型 | 主键 | 外键关联 | 字段描述 | 约束条件 |
|----------|--------------|----------|--------------------|------------|--------------------------------------------|---------------------------|
| _id | 记录ID | ObjectId | 是(自动生成) | - | 唯一标识聊天记录 | PRIMARY KEY (_id) |
| user_id | 发送人ID | Long | - | users.id | 发送消息的用户ID关联MySQL的users表 | NOT NULL |
| friend_id | 接收人ID | Long | - | users.id | 接收消息的好友ID关联MySQL的users表 | NOT NULL |
| content_type | 内容类型 | String | - | - | 消息类型text文本、query_share查询分享、image图片等 | NOT NULL |
| content | 消息内容 | Document | - | - | 动态内容:文本:{text: "Hello"};分享:{query_id: "xxx", title: "订单查询"};图片:{url: "xxx.png", size: 1024} | NOT NULL |
| send_time | 发送时间 | Date | - | - | 消息发送时间(精确到毫秒) | NOT NULL |
| is_read | 是否已读 | Boolean | - | - | true已读/false未读默认false | DEFAULT false |
| extra | 额外信息 | Document | - | - | 扩展字段如quote_msg_id引用消息ID、is_recalled是否撤回等 | - |
---
### 二、表/集合之间的关系(一对多/多对一/一对一)
#### MySQL 表关系
| 关联表1 | 关联表2 | 关系类型 | 说明 |
|---------------------------|---------------------------|----------|----------------------------------------|
| users用户表 | roles角色表 | 多对一 | 多个用户属于同一角色(如多个普通用户) |
| db_connections数据库连接表 | db_types数据库类型表 | 多对一 | 多个数据库连接属于同一类型如多个MySQL连接 |
| table_metadata表元数据表 | db_connections数据库连接表 | 多对一 | 多个表属于同一数据库连接 |
| column_metadata字段元数据表 | table_metadata表元数据表 | 多对一 | 多个字段属于同一表 |
| user_db_permissions用户数据权限表 | users被授权用户 | 一对一 | 一个用户对应一条权限记录(每个用户一行) |
| user_db_permissions用户数据权限表 | users授权管理员 | 多对一 | 多个权限记录可由同一管理员修改 |
| friend_relations好友关系表 | users用户 | 多对一 | 一个用户可添加多个好友 |
| friend_relations好友关系表 | users好友 | 多对一 | 一个用户可被多个用户添加为好友 |
| friend_requests好友请求表 | users申请人 | 多对一 | 一个用户可发起多个好友请求 |
| friend_requests好友请求表 | users接收人 | 多对一 | 一个用户可接收多个好友请求 |
| query_shares查询分享记录表 | users分享人 | 多对一 | 一个用户可分享多个查询 |
| query_shares查询分享记录表 | users接收人 | 多对一 | 一个用户可接收多个分享 |
| llm_configs大模型配置表 | llm_status大模型状态表 | 多对一 | 多个大模型配置属于同一状态 |
| llm_configs大模型配置表 | users创建人 | 多对一 | 一个管理员可创建多个大模型配置 |
| notifications通知表 | notification_targets通知目标表 | 多对一 | 多个通知属于同一接收目标 |
| notifications通知表 | priorities通知优先级表 | 多对一 | 多个通知属于同一优先级 |
| notifications通知表 | users发布者 | 多对一 | 一个管理员可发布多个通知 |
| error_logs错误分析表 | error_types错误类型表 | 多对一 | 多个错误记录属于同一错误类型 |
| db_connection_logs数据库连接日志表 | db_connections数据库连接表 | 多对一 | 一个数据库连接有多个连接日志 |
| query_logs查询日志表 | db_connections数据库连接表 | 多对一 | 一个数据源有多个查询日志 |
| query_logs查询日志表 | users用户 | 多对一 | 一个用户有多个查询日志 |
| user_searches用户搜索表 | users用户 | 多对一 | 一个用户有多个搜索记录 |
#### MongoDB 集合关系
| 关联集合1 | 关联集合2 | 关系类型 | 说明 |
|-----------------------------|-----------------------------|----------|----------------------------------------|
| collection_records收藏记录表 | query_collections收藏查询表 | 多对一 | 一个收藏组包含多个收藏记录 |
| collection_records收藏记录表 | db_connections数据库连接表 | 多对一 | 一个数据库连接有多个收藏记录 |
| collection_records收藏记录表 | llm_configs大模型配置表 | 多对一 | 一个大模型配置有多个收藏记录 |
| dialog_details多轮对话具体内容表 | dialog_records多轮对话列表 | 一对一 | 一个对话列表对应一个具体内容集合 |
| sql_cacheSQL缓存集合 | db_connections数据库连接表 | 多对一 | 一个数据源有多个SQL缓存 |
| sql_cacheSQL缓存集合 | users用户表 | 多对一 | 一个用户有多个个人SQL缓存 |
| ai_interaction_logsAI交互日志集合 | users用户表 | 多对一 | 一个用户有多个AI交互记录 |
| ai_interaction_logsAI交互日志集合 | llm_configs大模型配置表 | 多对一 | 一个大模型有多个交互记录 |
| friend_chats好友聊天记录表 | users用户表 | 多对一 | 一个用户可发送多条聊天消息 |
| friend_chats好友聊天记录表 | users用户表 | 多对一 | 一个用户可接收多条聊天消息 |
---
### 三、完整索引设计(按表/集合分类)
#### MySQL 数据表索引
| 表名 | 索引类型 | 索引字段 | 索引说明 |
|-----------------------|----------------|-------------------------------------------|--------------------------------------------|
| users | 普通索引 | role_id | 按角色查询用户 |
| users | 普通索引 | status | 按账号状态筛选用户 |
| users | 唯一索引 | username | 用户名唯一,加速登录查询 |
| users | 唯一索引 | email | 邮箱唯一,加速好友添加查询 |
| users | 唯一索引 | phonenumber | 手机号唯一,加速账号验证 |
| roles | 主键索引 | id | 主键默认索引,加速角色关联查询 |
| db_types | 主键索引 | id | 主键默认索引,加速数据库类型关联查询 |
| notification_targets | 主键索引 | id | 主键默认索引,加速通知目标关联查询 |
| priorities | 普通索引 | sort | 按排序权重查询优先级 |
| priorities | 主键索引 | id | 主键默认索引,加速通知优先级关联查询 |
| error_types | 主键索引 | id | 主键默认索引,加速错误类型关联查询 |
| llm_status | 主键索引 | id | 主键默认索引,加速大模型状态关联查询 |
| db_connections | 普通索引 | db_type_id | 按数据库类型查询连接 |
| db_connections | 普通索引 | status | 按连接状态筛选连接 |
| db_connections | 普通索引 | create_user_id | 按创建人查询连接 |
| db_connections | 唯一索引 | name | 连接名称唯一,加速连接查询 |
| table_metadata | 唯一复合索引 | db_connection_id, table_name | 同一连接下表名唯一,加速表元数据查询 |
| table_metadata | 普通索引 | db_connection_id | 按数据库连接查询表 |
| column_metadata | 唯一复合索引 | table_id, column_name | 同一表下字段名唯一,加速字段元数据查询 |
| column_metadata | 普通索引 | table_id | 按表查询字段 |
| user_db_permissions | 唯一索引 | user_id | 每个用户一行权限,加速权限查询 |
| user_db_permissions | 普通索引 | last_grant_user_id | 按授权管理员查询权限记录 |
| user_db_permissions | 普通索引 | is_assigned | 按是否分配筛选权限 |
| friend_relations | 唯一复合索引 | user_id, friend_id | 避免重复添加好友 |
| friend_relations | 普通索引 | user_id | 按用户查询好友列表 |
| friend_relations | 普通索引 | friend_id | 按好友ID查询关联关系 |
| friend_requests | 唯一复合索引 | applicant_id, recipient_id | 避免重复发送好友请求 |
| friend_requests | 普通复合索引 | recipient_id, status | 接收人查询待处理请求 |
| query_shares | 普通复合索引 | receive_user_id, receive_status | 接收人查询未处理分享 |
| query_shares | 普通索引 | share_user_id | 按分享人查询分享记录 |
| system_health | 普通索引 | collect_time | 按时间查询系统健康趋势 |
| operation_logs | 普通索引 | operate_time | 按时间查询操作日志 |
| operation_logs | 普通索引 | user_id | 按用户查询操作日志 |
| operation_logs | 普通索引 | module | 按模块筛选操作日志 |
| llm_configs | 普通索引 | status_id | 按状态查询大模型配置 |
| llm_configs | 普通索引 | is_disabled | 按是否禁用筛选配置 |
| llm_configs | 唯一索引 | name | 模型名称唯一,加速模型查询 |
| notifications | 普通索引 | target_id | 按目标筛选通知 |
| notifications | 普通索引 | priority_id | 按优先级筛选通知 |
| notifications | 普通复合索引 | is_top DESC, publish_time DESC | 置顶通知优先,按时间排序 |
| token_consume | 唯一复合索引 | llm_name, consume_date | 同一模型每日一条记录,加速消耗查询 |
| token_consume | 普通索引 | consume_date | 按日期查询消耗趋势 |
| error_logs | 普通复合索引 | error_type_id, period | 按错误类型+周期查询错误记录 |
| error_logs | 普通索引 | stat_time | 按统计时间查询错误记录 |
| performance_metrics | 普通复合索引 | metric_type, metric_time | 按类型+时间查询性能趋势 |
| db_connection_logs | 普通索引 | db_connection_id | 按连接查询日志 |
| db_connection_logs | 普通索引 | connect_time | 按时间查询连接日志 |
| db_connection_logs | 普通索引 | status | 按状态筛选连接日志 |
| query_logs | 普通复合索引 | data_source_id, query_date | 统计数据源每日查询量 |
| query_logs | 普通索引 | user_id | 按用户查询查询日志 |
| query_logs | 普通索引 | dialog_id | 按对话ID查询查询日志 |
| user_searches | 普通复合索引 | user_id, last_search_time | 清理30天前的搜索记录 |
| user_searches | 普通复合索引 | user_id, search_count DESC | 查询用户常用搜索(按次数排序) |
#### MongoDB 集合索引
| 集合名 | 索引类型 | 索引字段 | 索引说明 |
|-------------------------|----------------|-------------------------------------------|--------------------------------------------|
| query_collections | 复合索引 | { "userId": 1, "groupName": 1 } | 同用户内组名唯一,加速组查询 |
| collection_records | 复合索引 | { "queryId": 1, "userId": 1 } | 关联收藏组和用户,加速记录查询 |
| collection_records | 普通索引 | { "dbConnectionId": 1 } | 按数据库来源查询收藏记录 |
| collection_records | 普通索引 | { "llmConfigId": 1 } | 按大模型来源查询收藏记录 |
| dialog_records | 复合索引 | { "userId": 1, "lastTime": -1 } | 用户按时间查询对话列表(降序) |
| dialog_records | 唯一索引 | { "dialogId": 1 } | 对话ID唯一加速对话定位 |
| dialog_details | 单字段索引 | { "dialogId": 1 } | 关联对话列表,加速内容查询 |
| sql_cache | 复合索引 | { "nlHash": 1, "connectionId": 1, "tableIds": 1 } | 精确匹配缓存加速SQL复用 |
| sql_cache | TTL索引 | { "expireTime": 1 } | 自动删除过期缓存默认7天 |
| ai_interaction_logs | 复合索引 | { "userId": 1, "createTime": -1 } | 用户按时间查询交互记录(降序) |
| ai_interaction_logs | 复合索引 | { "llmName": 1, "status": 1 } | 按模型+状态查询交互记录 |
| friend_chats | 复合索引 | { "user_id": 1, "friend_id": 1, "send_time": -1 } | 按用户+好友+时间查询聊天记录(最新在前) |
| friend_chats | 复合索引 | { "friend_id": 1, "is_read": 1 } | 查询好友未读消息 |

@ -0,0 +1,493 @@
// MongoDB数据库集合和索引创建脚本 - 严格结构定义
// 完全按照数据库设计文档
db = db.getSiblingDB('natural_language_query_system');
print("开始初始化 MongoDB 集合...");
try {
// 1. query_collections收藏查询表-组)
db.createCollection("query_collections", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["userId", "groupName", "createTime"],
properties: {
userId: {
bsonType: "long",
description: "用户ID关联users.id"
},
groupName: {
bsonType: "string",
description: "收藏组名称(由大模型生成)"
},
createTime: {
bsonType: "date",
description: "组创建时间"
}
}
}
}
});
db.query_collections.createIndex({ "userId": 1, "groupName": 1 }, { unique: true });
print("创建 query_collections 完成");
} catch(e) {
print("query_collections 创建错误: " + e);
}
try {
// 2. collection_records收藏记录表-具体记录)
db.createCollection("collection_records", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["queryId", "userId", "sqlContent", "dbConnectionId", "llmConfigId", "createTime"],
properties: {
queryId: {
bsonType: "objectId",
description: "组ID关联query_collections._id"
},
userId: {
bsonType: "long",
description: "用户ID关联users.id"
},
sqlContent: {
bsonType: "string",
description: "收藏的SQL语句"
},
queryResult: {
bsonType: "object",
description: "结果数据"
},
dbConnectionId: {
bsonType: "long",
description: "数据库来源关联db_connections.id"
},
llmConfigId: {
bsonType: "long",
description: "大模型来源关联llm_configs.id"
},
createTime: {
bsonType: "date",
description: "收藏时间"
}
}
}
}
});
db.collection_records.createIndex({ "queryId": 1, "userId": 1 });
db.collection_records.createIndex({ "dbConnectionId": 1 });
db.collection_records.createIndex({ "llmConfigId": 1 });
print("创建 collection_records 完成");
} catch(e) {
print("collection_records 创建错误: " + e);
}
try {
// 3. dialog_records多轮对话列表
db.createCollection("dialog_records", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["dialogId", "userId", "topic", "totalRounds", "startTime", "lastTime"],
properties: {
dialogId: {
bsonType: "string",
description: "对话ID自定义唯一标识"
},
userId: {
bsonType: "long",
description: "用户ID关联users.id"
},
topic: {
bsonType: "string",
description: "对话主题(大模型生成)"
},
totalRounds: {
bsonType: "int",
description: "对话总轮次",
minimum: 0
},
startTime: {
bsonType: "date",
description: "对话开始时间"
},
lastTime: {
bsonType: "date",
description: "最后一轮对话时间"
}
}
}
}
});
db.dialog_records.createIndex({ "userId": 1, "lastTime": -1 });
db.dialog_records.createIndex({ "dialogId": 1 }, { unique: true });
print("创建 dialog_records 完成");
} catch(e) {
print("dialog_records 创建错误: " + e);
}
try {
// 4. dialog_details多轮对话具体内容表
db.createCollection("dialog_details", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["dialogId", "rounds"],
properties: {
dialogId: {
bsonType: "string",
description: "对话ID关联dialog_records.dialogId"
},
rounds: {
bsonType: "array",
description: "对话轮次数组",
items: {
bsonType: "object",
required: ["roundNum", "userInput", "aiResponse", "generatedSql", "roundTime"],
properties: {
roundNum: {
bsonType: "int",
description: "轮次序号",
minimum: 1
},
userInput: {
bsonType: "string",
description: "用户输入"
},
aiResponse: {
bsonType: "string",
description: "AI回复"
},
generatedSql: {
bsonType: "string",
description: "生成的SQL"
},
roundTime: {
bsonType: "date",
description: "轮次时间"
}
}
}
}
}
}
}
});
db.dialog_details.createIndex({ "dialogId": 1 });
print("创建 dialog_details 完成");
} catch(e) {
print("dialog_details 创建错误: " + e);
}
try {
// 5. sql_cacheSQL缓存集合
db.createCollection("sql_cache", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["nlHash", "connectionId", "tableIds", "dbType", "generatedSql", "hitCount", "expireTime"],
properties: {
nlHash: {
bsonType: "string",
description: "自然语言查询的MD5哈希"
},
userId: {
bsonType: ["long", "null"],
description: "用户IDNULL为全局缓存"
},
connectionId: {
bsonType: "long",
description: "数据源ID关联db_connections.id"
},
tableIds: {
bsonType: "array",
description: "涉及的表ID列表",
items: {
bsonType: "long"
}
},
dbType: {
bsonType: "string",
description: "数据库类型"
},
generatedSql: {
bsonType: "string",
description: "缓存的SQL语句"
},
hitCount: {
bsonType: "int",
description: "命中次数",
minimum: 0
},
expireTime: {
bsonType: "date",
description: "缓存过期时间"
}
}
}
}
});
db.sql_cache.createIndex({ "nlHash": 1, "connectionId": 1, "tableIds": 1 });
db.sql_cache.createIndex({ "expireTime": 1 }, { expireAfterSeconds: 0 });
print("创建 sql_cache 完成");
} catch(e) {
print("sql_cache 创建错误: " + e);
}
try {
// 6. ai_interaction_logsAI交互日志集合
db.createCollection("ai_interaction_logs", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["userId", "requestType", "llmName", "requestParams", "tokenUsage", "responseTime", "status", "createTime"],
properties: {
userId: {
bsonType: "long",
description: "用户ID关联users.id"
},
requestType: {
bsonType: "string",
enum: ["nl2sql", "sql_optimize"],
description: "请求类型"
},
llmName: {
bsonType: "string",
description: "模型名称关联llm_configs.name"
},
requestParams: {
bsonType: "object",
description: "请求参数",
properties: {
naturalLanguage: {
bsonType: "string",
description: "自然语言查询"
},
metadata: {
bsonType: "object",
description: "元数据信息"
},
temperature: {
bsonType: "double",
description: "温度参数",
minimum: 0,
maximum: 2
}
}
},
responseResult: {
bsonType: "object",
description: "响应结果",
properties: {
sql: {
bsonType: "string",
description: "生成的SQL"
},
confidence: {
bsonType: "double",
description: "置信度",
minimum: 0,
maximum: 1
},
suggestion: {
bsonType: "string",
description: "优化建议"
}
}
},
tokenUsage: {
bsonType: "object",
description: "Token消耗",
required: ["promptTokens", "completionTokens", "totalTokens"],
properties: {
promptTokens: {
bsonType: "int",
description: "输入token数",
minimum: 0
},
completionTokens: {
bsonType: "int",
description: "输出token数",
minimum: 0
},
totalTokens: {
bsonType: "int",
description: "总token数",
minimum: 0
}
}
},
responseTime: {
bsonType: "int",
description: "响应耗时ms",
minimum: 0
},
status: {
bsonType: "string",
enum: ["success", "fail"],
description: "交互状态"
},
errorMsg: {
bsonType: ["string", "null"],
description: "错误信息"
},
createTime: {
bsonType: "date",
description: "请求发起时间"
}
}
}
}
});
db.ai_interaction_logs.createIndex({ "userId": 1, "createTime": -1 });
db.ai_interaction_logs.createIndex({ "llmName": 1, "status": 1 });
print("创建 ai_interaction_logs 完成");
} catch(e) {
print("ai_interaction_logs 创建错误: " + e);
}
try {
// 7. friend_chats好友聊天记录表- 完全按照设计文档
db.createCollection("friend_chats", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["user_id", "friend_id", "content_type", "content", "send_time", "is_read"],
properties: {
user_id: {
bsonType: "long",
description: "发送人ID关联users.id"
},
friend_id: {
bsonType: "long",
description: "接收人ID关联users.id"
},
content_type: {
bsonType: "string",
enum: ["text", "query_share", "image"],
description: "消息类型"
},
content: {
bsonType: "object",
description: "动态消息内容",
properties: {
text: {
bsonType: "string",
description: "文本内容"
},
query_id: {
bsonType: "string",
description: "查询ID"
},
title: {
bsonType: "string",
description: "查询标题"
},
url: {
bsonType: "string",
description: "图片URL"
},
size: {
bsonType: "int",
description: "文件大小",
minimum: 0
}
}
},
send_time: {
bsonType: "date",
description: "消息发送时间"
},
is_read: {
bsonType: "bool",
description: "是否已读"
},
extra: {
bsonType: "object",
description: "额外信息(扩展字段)",
properties: {
quote_msg_id: {
bsonType: "objectId",
description: "引用消息ID"
},
is_recalled: {
bsonType: "bool",
description: "是否撤回"
}
}
}
}
}
}
});
db.friend_chats.createIndex({ "user_id": 1, "friend_id": 1, "send_time": -1 });
db.friend_chats.createIndex({ "friend_id": 1, "is_read": 1 });
print("创建 friend_chats 完成");
} catch(e) {
print("friend_chats 创建错误: " + e);
}
// 验证创建结果
var collections = db.getCollectionNames();
var userCollections = collections.filter(function(name) {
return !name.startsWith('system.');
});
print("\n==========================================");
print("MongoDB 集合初始化完成");
print("用户集合数量: " + userCollections.length);
print("集合列表: " + JSON.stringify(userCollections.sort()));
print("==========================================");
// 检查是否所有集合都创建成功
var expectedCollections = [
"query_collections",
"collection_records",
"dialog_records",
"dialog_details",
"sql_cache",
"ai_interaction_logs",
"friend_chats"
].sort();
var missingCollections = expectedCollections.filter(function(name) {
return userCollections.indexOf(name) === -1;
});
if (missingCollections.length > 0) {
print("缺失的集合: " + JSON.stringify(missingCollections));
} else {
print("所有集合创建成功!");
// 显示各集合的索引信息
print("\n📈 各集合索引详情:");
userCollections.forEach(function(collectionName) {
var indexes = db[collectionName].getIndexes();
var userIndexes = indexes.filter(function(index) {
return index.name !== "_id_";
});
print(" " + collectionName + ": " + userIndexes.length + " 个索引");
userIndexes.forEach(function(index) {
print(" - " + index.name + ": " + JSON.stringify(index.key));
});
});
}
print("\n集合结构验证:");
userCollections.forEach(function(collectionName) {
var stats = db[collectionName].stats();
print(" " + collectionName + ": " + stats.count + " 个文档, " + stats.size + " 字节");
});
print("\nMongoDB 严格结构定义初始化完成!");
// ========================================
// 插入初始示例数据(可选)
// ========================================
print("\n开始插入初始示例数据...");
// 注意MongoDB 的初始数据通常不需要预先插入,
// 因为应用运行时会自动创建文档。
// 这里仅作为演示,实际项目中可以删除或保留为空。
print("初始数据插入完成(当前为空,应用运行时自动生成)");

295
src/test/mvnw vendored

@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

@ -0,0 +1,397 @@
-- MySQL数据库建表脚本
-- 基于last.md文档设计
-- 1. 基础信息表(用户、角色、字典)
-- 1.1 roles角色表- 需要先创建因为users依赖它
CREATE TABLE `roles` (
`id` TINYINT(2) PRIMARY KEY AUTO_INCREMENT COMMENT '角色ID',
`role_name` VARCHAR(30) NOT NULL UNIQUE COMMENT '角色名称',
`role_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '角色编码',
`description` VARCHAR(500) COMMENT '角色描述'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
-- 1.2 users用户表
CREATE TABLE `users` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
`password` VARCHAR(100) NOT NULL COMMENT '密码BCrypt加密存储',
`email` VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱',
`phonenumber` VARCHAR(50) NOT NULL UNIQUE COMMENT '手机号',
`role_id` TINYINT(2) NOT NULL COMMENT '角色ID',
`avatar_url` MEDIUMTEXT COMMENT '头像URL或Base64编码',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '账号状态0-禁用1-正常',
`online_status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '在线状态0-离线1-在线',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_role_id` (`role_id`),
INDEX `idx_status` (`status`),
UNIQUE INDEX `uk_username` (`username`),
UNIQUE INDEX `uk_email` (`email`),
UNIQUE INDEX `uk_phonenumber` (`phonenumber`),
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 1.3 db_types数据库类型表
CREATE TABLE `db_types` (
`id` TINYINT(2) PRIMARY KEY AUTO_INCREMENT COMMENT '类型ID',
`type_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '类型名称',
`type_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '类型编码',
`description` VARCHAR(500) COMMENT '类型描述'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库类型表';
-- 1.4 notification_targets通知目标表
CREATE TABLE `notification_targets` (
`id` TINYINT(2) PRIMARY KEY AUTO_INCREMENT COMMENT '目标ID',
`target_name` VARCHAR(30) NOT NULL UNIQUE COMMENT '目标名称',
`target_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '目标编码',
`description` VARCHAR(200) COMMENT '目标描述'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知目标表';
-- 1.5 priorities通知优先级表
CREATE TABLE `priorities` (
`id` TINYINT(2) PRIMARY KEY AUTO_INCREMENT COMMENT '优先级ID',
`priority_name` VARCHAR(20) NOT NULL UNIQUE COMMENT '优先级名称',
`priority_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '优先级编码',
`sort` INT(11) NOT NULL COMMENT '排序权重',
INDEX `idx_sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知优先级表';
-- 1.6 error_types错误类型表
CREATE TABLE `error_types` (
`id` TINYINT(2) PRIMARY KEY AUTO_INCREMENT COMMENT '错误ID',
`error_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '错误名称',
`error_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '错误编码',
`description` VARCHAR(500) COMMENT '错误描述'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='错误类型表';
-- 1.7 llm_status大模型状态表
CREATE TABLE `llm_status` (
`id` TINYINT(2) PRIMARY KEY AUTO_INCREMENT COMMENT '状态ID',
`status_name` VARCHAR(20) NOT NULL UNIQUE COMMENT '状态名称',
`status_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '状态编码',
`description` VARCHAR(200) COMMENT '状态描述'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大模型状态表';
-- 2. 数据资源表
-- 2.1 db_connections数据库连接表
CREATE TABLE `db_connections` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '连接ID',
`name` VARCHAR(100) NOT NULL UNIQUE COMMENT '连接名称',
`db_type_id` TINYINT(2) NOT NULL COMMENT '数据库类型ID',
`url` VARCHAR(255) NOT NULL COMMENT '连接地址',
`username` VARCHAR(50) NOT NULL COMMENT '数据库账号(加密存储)',
`password` VARCHAR(100) NOT NULL COMMENT '数据库密码(加密存储)',
`status` VARCHAR(20) NOT NULL DEFAULT 'disconnected' COMMENT '连接状态',
`create_user_id` BIGINT(20) NOT NULL COMMENT '创建者ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_db_type_id` (`db_type_id`),
INDEX `idx_status` (`status`),
INDEX `idx_create_user_id` (`create_user_id`),
UNIQUE INDEX `uk_name` (`name`),
FOREIGN KEY (`db_type_id`) REFERENCES `db_types`(`id`),
FOREIGN KEY (`create_user_id`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库连接表';
-- 2.2 table_metadata表元数据表
CREATE TABLE `table_metadata` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '表ID',
`db_connection_id` BIGINT(20) NOT NULL COMMENT '数据库连接ID',
`table_name` VARCHAR(100) NOT NULL COMMENT '表名',
`description` VARCHAR(500) COMMENT '表描述',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE INDEX `uk_db_connection_table` (`db_connection_id`, `table_name`),
INDEX `idx_db_connection_id` (`db_connection_id`),
FOREIGN KEY (`db_connection_id`) REFERENCES `db_connections`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表元数据表';
-- 2.3 column_metadata字段元数据表
CREATE TABLE `column_metadata` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '字段ID',
`table_id` BIGINT(20) NOT NULL COMMENT '表ID',
`column_name` VARCHAR(100) NOT NULL COMMENT '字段名',
`data_type` VARCHAR(50) NOT NULL COMMENT '数据类型',
`description` VARCHAR(500) COMMENT '字段描述',
`is_primary` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否主键0-否1-是',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE INDEX `uk_table_column` (`table_id`, `column_name`),
INDEX `idx_table_id` (`table_id`),
FOREIGN KEY (`table_id`) REFERENCES `table_metadata`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字段元数据表';
-- 3. 权限与关系表
-- 3.1 user_db_permissions用户数据权限表
CREATE TABLE `user_db_permissions` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '权限ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`permission_details` JSON NOT NULL COMMENT '权限详情',
`last_grant_user_id` BIGINT(20) NOT NULL COMMENT '最后授权管理员ID',
`is_assigned` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已分配0-未分配1-已分配',
`last_grant_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后授权时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE INDEX `uk_user_id` (`user_id`),
INDEX `idx_last_grant_user_id` (`last_grant_user_id`),
INDEX `idx_is_assigned` (`is_assigned`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`last_grant_user_id`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户数据权限表';
-- 3.2 friend_relations好友关系表
CREATE TABLE `friend_relations` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '关系ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`friend_id` BIGINT(20) NOT NULL COMMENT '好友ID',
`friend_username` VARCHAR(50) NOT NULL COMMENT '好友用户名',
`online_status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '好友在线状态0-离线1-在线',
`remark_name` VARCHAR(50) COMMENT '备注名',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '添加时间',
UNIQUE INDEX `uk_user_friend` (`user_id`, `friend_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_friend_id` (`friend_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`friend_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';
-- 3.3 friend_requests好友请求表
CREATE TABLE `friend_requests` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '请求ID',
`applicant_id` BIGINT(20) NOT NULL COMMENT '申请人ID',
`recipient_id` BIGINT(20) NOT NULL COMMENT '接收人ID',
`apply_msg` VARCHAR(200) COMMENT '申请留言',
`status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '请求状态0-待处理1-已同意2-已拒绝',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间',
`handle_time` DATETIME COMMENT '处理时间',
UNIQUE INDEX `uk_applicant_recipient` (`applicant_id`, `recipient_id`),
INDEX `idx_recipient_status` (`recipient_id`, `status`),
FOREIGN KEY (`applicant_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`recipient_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友请求表';
-- 3.4 query_shares查询分享记录表
CREATE TABLE `query_shares` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '分享ID',
`share_user_id` BIGINT(20) NOT NULL COMMENT '分享人ID',
`receive_user_id` BIGINT(20) NOT NULL COMMENT '接收人ID',
`dialog_id` VARCHAR(50) NOT NULL COMMENT '会话ID',
`target_rounds` JSON NOT NULL COMMENT '会话轮次数组',
`query_title` VARCHAR(200) NOT NULL COMMENT '查询标题',
`share_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '分享时间',
`receive_status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '接收状态0-未处理1-已保存2-已删除',
INDEX `idx_receive_user_status` (`receive_user_id`, `receive_status`),
INDEX `idx_share_user_id` (`share_user_id`),
FOREIGN KEY (`share_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`receive_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='查询分享记录表';
-- 4. 系统日志与监控表
-- 4.1 system_health系统健康表
CREATE TABLE `system_health` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID',
`db_delay` INT(11) NOT NULL COMMENT '数据库延迟ms',
`cache_delay` INT(11) NOT NULL COMMENT '缓存延迟ms',
`llm_delay` INT(11) NOT NULL COMMENT '大模型延迟ms',
`storage_usage` DECIMAL(5,2) NOT NULL COMMENT '存储使用率(%',
`collect_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '采集时间',
INDEX `idx_collect_time` (`collect_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统健康表';
-- 4.2 operation_logs系统操作日志表
CREATE TABLE `operation_logs` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`operation` VARCHAR(100) NOT NULL COMMENT '操作名称',
`module` VARCHAR(50) NOT NULL COMMENT '操作模块',
`related_llm` VARCHAR(50) COMMENT '涉及模型',
`ip_address` VARCHAR(50) NOT NULL COMMENT 'IP地址',
`operate_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
`result` TINYINT(1) NOT NULL COMMENT '操作结果0-失败1-成功',
`error_msg` TEXT COMMENT '错误信息',
INDEX `idx_operate_time` (`operate_time`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_module` (`module`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统操作日志表';
-- 4.3 llm_configs大模型配置表
CREATE TABLE `llm_configs` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
`name` VARCHAR(50) NOT NULL UNIQUE COMMENT '模型名称',
`version` VARCHAR(20) NOT NULL COMMENT '模型版本',
`api_key` VARCHAR(200) NOT NULL COMMENT 'API密钥AES加密',
`api_url` VARCHAR(255) NOT NULL COMMENT 'API地址',
`status_id` TINYINT(2) NOT NULL COMMENT '状态ID',
`is_disabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否禁用0-启用1-禁用',
`timeout` INT(11) NOT NULL COMMENT '超时时间ms',
`create_user_id` BIGINT(20) NOT NULL COMMENT '创建人ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_status_id` (`status_id`),
INDEX `idx_is_disabled` (`is_disabled`),
UNIQUE INDEX `uk_name` (`name`),
FOREIGN KEY (`status_id`) REFERENCES `llm_status`(`id`),
FOREIGN KEY (`create_user_id`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大模型配置表';
-- 4.4 notifications通知表
CREATE TABLE `notifications` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '通知ID',
`title` VARCHAR(100) NOT NULL COMMENT '通知标题',
`content` TEXT NOT NULL COMMENT '通知内容',
`target_id` TINYINT(2) NOT NULL COMMENT '目标ID',
`priority_id` TINYINT(2) NOT NULL COMMENT '优先级ID',
`publisher_id` BIGINT(20) NOT NULL COMMENT '发布者ID',
`is_top` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否置顶0-否1-是',
`publish_time` DATETIME COMMENT '发布时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`latest_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上次更新时间',
INDEX `idx_target_id` (`target_id`),
INDEX `idx_priority_id` (`priority_id`),
INDEX `idx_is_top_publish_time` (`is_top` DESC, `publish_time` DESC),
FOREIGN KEY (`target_id`) REFERENCES `notification_targets`(`id`),
FOREIGN KEY (`priority_id`) REFERENCES `priorities`(`id`),
FOREIGN KEY (`publisher_id`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知表';
-- 4.5 token_consumetoken消耗表
CREATE TABLE `token_consume` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID',
`llm_name` VARCHAR(50) NOT NULL COMMENT '模型名称',
`total_tokens` BIGINT(11) NOT NULL COMMENT '总消耗',
`prompt_tokens` BIGINT(11) NOT NULL COMMENT '输入消耗',
`completion_tokens` BIGINT(11) NOT NULL COMMENT '输出消耗',
`consume_date` DATE NOT NULL COMMENT '消耗日期',
`growth_rate` DECIMAL(5,2) COMMENT '增长率(%',
UNIQUE INDEX `uk_llm_date` (`llm_name`, `consume_date`),
INDEX `idx_consume_date` (`consume_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='token消耗表';
-- 4.6 error_logs错误分析表
CREATE TABLE `error_logs` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID',
`error_type_id` TINYINT(2) NOT NULL COMMENT '错误类型ID',
`error_count` INT(11) NOT NULL COMMENT '错误次数',
`error_rate` DECIMAL(5,2) COMMENT '错误率(%',
`period` VARCHAR(20) NOT NULL COMMENT '统计周期',
`stat_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '统计时间',
INDEX `idx_error_type_period` (`error_type_id`, `period`),
INDEX `idx_stat_time` (`stat_time`),
FOREIGN KEY (`error_type_id`) REFERENCES `error_types`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='错误分析表';
-- 4.7 performance_metrics性能趋势表
CREATE TABLE `performance_metrics` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '指标ID',
`metric_type` VARCHAR(20) NOT NULL COMMENT '指标类型',
`metric_value` DECIMAL(10,2) NOT NULL COMMENT '指标值',
`metric_time` DATETIME NOT NULL COMMENT '指标时间',
`trend` TINYINT(1) COMMENT '趋势标识0-下降1-上升',
INDEX `idx_metric_type_time` (`metric_type`, `metric_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='性能趋势表';
-- 4.8 db_connection_logs数据库连接日志表
CREATE TABLE `db_connection_logs` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
`db_connection_id` BIGINT(20) NOT NULL COMMENT '数据库连接ID',
`db_name` VARCHAR(100) NOT NULL COMMENT '数据库名称',
`connect_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '连接时间',
`status` VARCHAR(20) NOT NULL COMMENT '连接状态',
`remark` TEXT COMMENT '备注信息',
`handler_id` BIGINT(20) COMMENT '处理人ID',
INDEX `idx_db_connection_id` (`db_connection_id`),
INDEX `idx_connect_time` (`connect_time`),
INDEX `idx_status` (`status`),
FOREIGN KEY (`db_connection_id`) REFERENCES `db_connections`(`id`),
FOREIGN KEY (`handler_id`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库连接日志表';
-- 4.9 query_logs查询日志表
CREATE TABLE `query_logs` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
`dialog_id` VARCHAR(50) NOT NULL COMMENT '对话ID',
`data_source_id` BIGINT(20) NOT NULL COMMENT '数据源ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`query_date` DATE NOT NULL COMMENT '查询日期',
`query_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '查询时间',
`execute_result` TINYINT(1) NOT NULL COMMENT '执行结果0-失败1-成功',
INDEX `idx_data_source_date` (`data_source_id`, `query_date`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_dialog_id` (`dialog_id`),
FOREIGN KEY (`data_source_id`) REFERENCES `db_connections`(`id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='查询日志表';
-- 4.10 user_searches用户搜索表
CREATE TABLE `user_searches` (
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '搜索ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`sql_content` TEXT NOT NULL COMMENT 'SQL语句',
`query_title` VARCHAR(200) NOT NULL COMMENT '查询标题',
`search_count` INT(11) NOT NULL DEFAULT 1 COMMENT '搜索次数',
`last_search_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后搜索时间',
INDEX `idx_user_last_search` (`user_id`, `last_search_time`),
INDEX `idx_user_search_count` (`user_id`, `search_count` DESC),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户搜索表';
-- ========================================
-- 初始化基础数据(必须数据)
-- ========================================
-- 1. 角色数据
INSERT INTO `roles` (`id`, `role_name`, `role_code`, `description`) VALUES
(1, '系统管理员', 'sys_admin', '拥有系统所有权限,可管理用户、配置大模型'),
(2, '数据管理员', 'data_admin', '可管理数据源连接、分配用户数据权限'),
(3, '普通用户', 'normal_user', '可执行自然语言查询、查看历史记录');
-- 2. 数据库类型
INSERT INTO `db_types` (`id`, `type_name`, `type_code`, `description`) VALUES
(1, 'MySQL', 'mysql', 'MySQL关系型数据库'),
(2, 'MongoDB', 'mongodb', 'MongoDB文档型数据库'),
(3, 'SQL Server', 'mssql', 'Microsoft SQL Server'),
(4, 'PostgreSQL', 'postgresql', 'PostgreSQL关系型数据库');
-- 3. 大模型状态
INSERT INTO `llm_status` (`id`, `status_name`, `status_code`, `description`) VALUES
(1, '可用', 'available', 'API成功率≥95%,响应时间正常'),
(2, '不可用', 'unavailable', 'API无法连接或持续失败'),
(3, '不稳定', 'unstable', 'API成功率在60%-95%之间');
-- 4. 通知目标
INSERT INTO `notification_targets` (`id`, `target_name`, `target_code`, `description`) VALUES
(1, '所有用户', 'all', '系统内所有用户'),
(2, '系统管理员', 'sys_admin', '仅系统管理员可见'),
(3, '数据管理员', 'data_admin', '仅数据管理员可见'),
(4, '普通用户', 'normal_user', '仅普通用户可见');
-- 5. 通知优先级
INSERT INTO `priorities` (`id`, `priority_name`, `priority_code`, `sort`) VALUES
(1, '紧急', 'urgent', 1),
(2, '普通', 'normal', 2),
(3, '', 'low', 3);
-- 6. 错误类型
INSERT INTO `error_types` (`id`, `error_name`, `error_code`, `description`) VALUES
(1, '模型调用超时', 'llm_timeout', '大模型API响应超时'),
(2, '数据库连接错误', 'db_connection_error', '数据库连接失败或超时'),
(3, 'SQL语法错误', 'sql_syntax_error', '生成的SQL语句语法错误'),
(4, '权限不足', 'permission_denied', '用户无权访问指定数据源');
-- 7. 内置用户(三个角色各一个)
-- 密码统一为 "123456" 的 BCrypt 加密结果:$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi
INSERT INTO `users` (`id`, `username`, `password`, `email`, `phonenumber`, `role_id`, `status`) VALUES
(1, 'sys_admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'sys_admin@example.com', '13800138001', 1, 1),
(2, 'data_admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'data_admin@example.com', '13800138002', 2, 1),
(3, 'normal_user', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'normal_user@example.com', '13800138003', 3, 1);
-- 8. 用户数据权限初始化(为所有用户创建权限记录)
INSERT INTO `user_db_permissions` (`user_id`, `permission_details`, `is_assigned`, `last_grant_user_id`) VALUES
(1, '[]', 1, 1),
(2, '[]', 1, 1),
(3, '[]', 0, 1);

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>springboot_demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot_demo</name>
<description>springboot_demo</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MyBatis Plus操作 MySQL-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.9</version>
</dependency>
<!-- MongoDB -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Redis可选用于缓存-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lombok简化代码-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
<scope>provided</scope>
</dependency>
<!-- FastJSON 2JSON 处理)-->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<!-- Validation参数校验-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Actuator健康检查、监控-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

@ -0,0 +1,27 @@
@echo off
REM Windows 版本的数据导出脚本
echo 开始导出数据...
REM 创建导出目录
if not exist "data-backup" mkdir data-backup
REM 导出 MySQL 数据
echo 导出 MySQL 数据...
docker exec nlq_mysql mysqldump -uroot -proot123456 --no-create-info --skip-triggers --complete-insert natural_language_query_system > data-backup\mysql-data.sql
REM 导出 MongoDB 数据
echo 导出 MongoDB 数据...
docker exec nlq_mongodb mongodump --username=admin --password=admin123456 --authenticationDatabase=admin --db=natural_language_query_system --out=/tmp/mongodb-backup
docker cp nlq_mongodb:/tmp/mongodb-backup/natural_language_query_system data-backup\mongodb-data
echo.
echo 数据导出完成!
echo 文件位置:
echo - MySQL: data-backup\mysql-data.sql
echo - MongoDB: data-backup\mongodb-data\
echo.
echo 请将 data-backup 文件夹分享给团队成员
pause

@ -0,0 +1,39 @@
#!/bin/bash
# 导出 Docker 容器中的数据到本地文件
# 用于团队成员之间共享测试数据
echo "开始导出数据..."
# 创建导出目录
mkdir -p ./data-backup
# 导出 MySQL 数据(仅数据,不含表结构)
echo "导出 MySQL 数据..."
docker exec nlq_mysql mysqldump \
-uroot -proot123456 \
--no-create-info \
--skip-triggers \
--complete-insert \
natural_language_query_system \
> ./data-backup/mysql-data.sql
# 导出 MongoDB 数据
echo "导出 MongoDB 数据..."
docker exec nlq_mongodb mongodump \
--username=admin \
--password=admin123456 \
--authenticationDatabase=admin \
--db=natural_language_query_system \
--out=/tmp/mongodb-backup
docker cp nlq_mongodb:/tmp/mongodb-backup/natural_language_query_system ./data-backup/mongodb-data
echo "数据导出完成!"
echo "文件位置:"
echo " - MySQL: ./data-backup/mysql-data.sql"
echo " - MongoDB: ./data-backup/mongodb-data/"
echo ""
echo "请将 data-backup 文件夹分享给团队成员"

@ -0,0 +1,32 @@
@echo off
REM Windows 版本的数据导入脚本
echo 开始导入数据...
REM 检查备份文件
if not exist "data-backup\mysql-data.sql" (
echo 错误:找不到 MySQL 备份文件 data-backup\mysql-data.sql
pause
exit /b 1
)
if not exist "data-backup\mongodb-data" (
echo 错误:找不到 MongoDB 备份目录 data-backup\mongodb-data
pause
exit /b 1
)
REM 导入 MySQL 数据
echo 导入 MySQL 数据...
docker exec -i nlq_mysql mysql -uroot -proot123456 natural_language_query_system < data-backup\mysql-data.sql
REM 导入 MongoDB 数据
echo 导入 MongoDB 数据...
docker cp data-backup\mongodb-data nlq_mongodb:/tmp/mongodb-restore
docker exec nlq_mongodb mongorestore --username=admin --password=admin123456 --authenticationDatabase=admin --db=natural_language_query_system /tmp/mongodb-restore
echo.
echo 数据导入完成!
pause

@ -0,0 +1,38 @@
#!/bin/bash
# 导入团队共享的测试数据到 Docker 容器
# 使用前确保已经运行 docker compose up -d
echo "开始导入数据..."
# 检查备份文件是否存在
if [ ! -f "./data-backup/mysql-data.sql" ]; then
echo "错误:找不到 MySQL 备份文件 ./data-backup/mysql-data.sql"
exit 1
fi
if [ ! -d "./data-backup/mongodb-data" ]; then
echo "错误:找不到 MongoDB 备份目录 ./data-backup/mongodb-data"
exit 1
fi
# 导入 MySQL 数据
echo "导入 MySQL 数据..."
docker exec -i nlq_mysql mysql \
-uroot -proot123456 \
natural_language_query_system \
< ./data-backup/mysql-data.sql
# 导入 MongoDB 数据
echo "导入 MongoDB 数据..."
docker cp ./data-backup/mongodb-data nlq_mongodb:/tmp/mongodb-restore
docker exec nlq_mongodb mongorestore \
--username=admin \
--password=admin123456 \
--authenticationDatabase=admin \
--db=natural_language_query_system \
/tmp/mongodb-restore
echo "数据导入完成!"

@ -0,0 +1,13 @@
package com.example.springboot_demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringbootDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootDemoApplication.class, args);
}
}

@ -0,0 +1,58 @@
package com.example.springboot_demo.common;
public class Result<T> {
private Integer code;
private String message;
private T data;
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}

@ -0,0 +1,26 @@
package com.example.springboot_demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.setAllowCredentials(true);
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}

@ -0,0 +1,61 @@
package com.example.springboot_demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import com.example.springboot_demo.utils.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception {
// Allow OPTIONS requests (CORS preflight)
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String authHeader = request.getHeader("Authorization");
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.validateToken(token)) {
// Optionally set user info in request attributes
request.setAttribute("userId", jwtUtil.getUserIdFromToken(token));
request.setAttribute("username", jwtUtil.getUsernameFromToken(token));
return true;
}
}
// 如果没有 token尝试从 userId header 获取(用于开发环境)
String userIdHeader = request.getHeader("userId");
if (StringUtils.hasText(userIdHeader)) {
try {
Long userId = Long.parseLong(userIdHeader);
request.setAttribute("userId", userId);
return true;
} catch (NumberFormatException e) {
// 忽略
}
}
// 对于查询接口,如果没有 token 也允许通过(使用默认 userId=1
// 生产环境应该移除这个逻辑
if (request.getRequestURI().startsWith("/query/")) {
request.setAttribute("userId", 1L);
return true;
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}

@ -0,0 +1,62 @@
package com.example.springboot_demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import com.example.springboot_demo.service.impl.CustomUserDetailsService;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 使 UserDetailsService
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(customUserDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* AuthenticationManager Bean使
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // Disable CSRF for REST APIs
.authorizeHttpRequests(auth -> auth
.requestMatchers("/**").permitAll() // Allow all requests for now, we'll handle auth with Interceptor for specific paths or refine this later.
// Note: The plan mentioned "Initially disable Spring Security's default login page".
// Permitting all here effectively bypasses Spring Security's default auth flow, relying on our custom JWT flow.
)
.authenticationProvider(authenticationProvider()); // 使用自定义的认证提供者
return http.build();
}
}

@ -0,0 +1,31 @@
package com.example.springboot_demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
@NonNull
private JwtInterceptor jwtInterceptor;
@Override
@SuppressWarnings("null")
public void addInterceptors(@NonNull InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/**", // 登录接口免认证
"/actuator/**", // 健康检查接口免认证(开发环境)
"/role", // 仅 POST /role 免认证
"/user", // 用户注册免认证
"/query/**", // 查询接口暂时免认证或确保前端发送token
"/error"
);
}
}

@ -0,0 +1,42 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mongodb.AiInteractionLog;
import com.example.springboot_demo.service.AiInteractionLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/ai-interaction-log")
public class AiInteractionLogController {
@Autowired
private AiInteractionLogService aiInteractionLogService;
@GetMapping("/list/user/{userId}")
public Result<List<AiInteractionLog>> listByUserId(@PathVariable Long userId) {
return Result.success(aiInteractionLogService.listByUserId(userId));
}
@GetMapping("/list/llm/{llmName}")
public Result<List<AiInteractionLog>> listByLlmName(@PathVariable String llmName) {
return Result.success(aiInteractionLogService.listByLlmName(llmName));
}
@GetMapping("/list/llm/{llmName}/{status}")
public Result<List<AiInteractionLog>> listByLlmNameAndStatus(@PathVariable String llmName, @PathVariable String status) {
return Result.success(aiInteractionLogService.listByLlmNameAndStatus(llmName, status));
}
@PostMapping
public Result<AiInteractionLog> save(@RequestBody AiInteractionLog aiInteractionLog) {
aiInteractionLog.setCreateTime(LocalDateTime.now());
AiInteractionLog saved = aiInteractionLogService.save(aiInteractionLog);
return Result.success(saved);
}
}

@ -0,0 +1,31 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.dto.LoginDTO;
import com.example.springboot_demo.service.AuthService;
import com.example.springboot_demo.vo.LoginVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthService authService;
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody LoginDTO loginDTO) {
try {
LoginVO loginVO = authService.login(loginDTO);
return Result.success(loginVO);
} catch (Exception e) {
return Result.error(e.getMessage());
}
}
}

@ -0,0 +1,53 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mongodb.CollectionRecord;
import com.example.springboot_demo.service.CollectionRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/collection-record")
public class CollectionRecordController {
@Autowired
private CollectionRecordService collectionRecordService;
@GetMapping("/list/query/{queryId}")
public Result<List<CollectionRecord>> listByQueryId(@PathVariable String queryId) {
return Result.success(collectionRecordService.listByQueryId(queryId));
}
@GetMapping("/list/user/{userId}")
public Result<List<CollectionRecord>> listByUserId(@PathVariable Long userId) {
return Result.success(collectionRecordService.listByUserId(userId));
}
@GetMapping("/list/db/{dbConnectionId}")
public Result<List<CollectionRecord>> listByDbConnectionId(@PathVariable Long dbConnectionId) {
return Result.success(collectionRecordService.listByDbConnectionId(dbConnectionId));
}
@GetMapping("/{id}")
public Result<CollectionRecord> getById(@PathVariable String id) {
return Result.success(collectionRecordService.getById(id));
}
@PostMapping
public Result<CollectionRecord> save(@RequestBody CollectionRecord collectionRecord) {
collectionRecord.setCreateTime(LocalDateTime.now());
CollectionRecord saved = collectionRecordService.save(collectionRecord);
return Result.success(saved);
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
collectionRecordService.deleteById(id);
return Result.success();
}
}

@ -0,0 +1,76 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.ColumnMetadata;
import com.example.springboot_demo.service.ColumnMetadataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/column-metadata")
public class ColumnMetadataController {
@Autowired
private ColumnMetadataService columnMetadataService;
/**
*
*/
@GetMapping("/list")
public Result<List<ColumnMetadata>> list() {
return Result.success(columnMetadataService.list());
}
/**
* ID
*/
@GetMapping("/list/{tableId}")
public Result<List<ColumnMetadata>> listByTable(@PathVariable Long tableId) {
return Result.success(columnMetadataService.listByTableId(tableId));
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<ColumnMetadata> getById(@PathVariable Long id) {
return Result.success(columnMetadataService.getById(id));
}
/**
*
*/
@PostMapping
public Result<ColumnMetadata> save(@RequestBody ColumnMetadata columnMetadata) {
columnMetadata.setCreateTime(LocalDateTime.now());
if (columnMetadata.getIsPrimary() == null) {
columnMetadata.setIsPrimary(0);
}
columnMetadataService.save(columnMetadata);
return Result.success(columnMetadata);
}
/**
*
*/
@PutMapping
public Result<ColumnMetadata> update(@RequestBody ColumnMetadata columnMetadata) {
columnMetadataService.updateById(columnMetadata);
return Result.success(columnMetadata);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
columnMetadataService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,109 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.DbConnection;
import com.example.springboot_demo.service.DbConnectionService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/db-connection")
public class DbConnectionController {
@Autowired
private DbConnectionService dbConnectionService;
/**
*
*/
@GetMapping("/list")
public Result<List<DbConnection>> list() {
return Result.success(dbConnectionService.list());
}
/**
* ID
*/
@GetMapping("/list/{createUserId}")
public Result<List<DbConnection>> listByUser(@PathVariable Long createUserId) {
return Result.success(dbConnectionService.listByCreateUserId(createUserId));
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<DbConnection> getById(@PathVariable Long id) {
return Result.success(dbConnectionService.getById(id));
}
/**
*
*/
@PostMapping
public Result<DbConnection> save(@RequestBody DbConnection dbConnection) {
// 验证必填字段
if (dbConnection.getName() == null || dbConnection.getName().trim().isEmpty()) {
return Result.error("连接名称不能为空");
}
if (dbConnection.getDbTypeId() == null) {
return Result.error("数据库类型不能为空");
}
if (dbConnection.getUrl() == null || dbConnection.getUrl().trim().isEmpty()) {
return Result.error("连接地址不能为空");
}
if (dbConnection.getUsername() == null || dbConnection.getUsername().trim().isEmpty()) {
return Result.error("数据库账号不能为空");
}
if (dbConnection.getPassword() == null || dbConnection.getPassword().trim().isEmpty()) {
return Result.error("数据库密码不能为空");
}
// 设置默认值
dbConnection.setCreateTime(LocalDateTime.now());
dbConnection.setUpdateTime(LocalDateTime.now());
if (dbConnection.getStatus() == null) {
dbConnection.setStatus("disconnected");
}
if (dbConnection.getCreateUserId() == null) {
// 从请求头或session获取当前用户ID这里暂时使用默认值1
dbConnection.setCreateUserId(1L);
}
dbConnectionService.save(dbConnection);
return Result.success(dbConnection);
}
/**
*
*/
@PutMapping
public Result<DbConnection> update(@RequestBody DbConnection dbConnection) {
dbConnection.setUpdateTime(LocalDateTime.now());
dbConnectionService.updateById(dbConnection);
return Result.success(dbConnection);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
dbConnectionService.removeById(id);
return Result.success();
}
/**
*
*/
@GetMapping("/test/{id}")
public Result<Boolean> testConnection(@PathVariable Long id) {
boolean result = dbConnectionService.testConnection(id);
return Result.success(result);
}
}

@ -0,0 +1,49 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.DbConnectionLog;
import com.example.springboot_demo.service.DbConnectionLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/db-connection-log")
public class DbConnectionLogController {
@Autowired
private DbConnectionLogService dbConnectionLogService;
@GetMapping("/list")
public Result<List<DbConnectionLog>> list() {
return Result.success(dbConnectionLogService.list());
}
@GetMapping("/list/connection/{dbConnectionId}")
public Result<List<DbConnectionLog>> listByDbConnectionId(@PathVariable Long dbConnectionId) {
return Result.success(dbConnectionLogService.listByDbConnectionId(dbConnectionId));
}
@GetMapping("/list/status/{status}")
public Result<List<DbConnectionLog>> listByStatus(@PathVariable String status) {
return Result.success(dbConnectionLogService.listByStatus(status));
}
@GetMapping("/{id}")
public Result<DbConnectionLog> getById(@PathVariable Long id) {
return Result.success(dbConnectionLogService.getById(id));
}
@PostMapping
public Result<DbConnectionLog> save(@RequestBody DbConnectionLog dbConnectionLog) {
if (dbConnectionLog.getConnectTime() == null) {
dbConnectionLog.setConnectTime(LocalDateTime.now());
}
dbConnectionLogService.save(dbConnectionLog);
return Result.success(dbConnectionLog);
}
}

@ -0,0 +1,71 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.DbType;
import com.example.springboot_demo.service.DbTypeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/db-type")
public class DbTypeController {
@Autowired
private DbTypeService dbTypeService;
/**
*
*/
@GetMapping("/list")
public Result<List<DbType>> list() {
return Result.success(dbTypeService.list());
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<DbType> getById(@PathVariable Integer id) {
return Result.success(dbTypeService.getById(id));
}
/**
*
*/
@GetMapping("/code/{typeCode}")
public Result<DbType> getByTypeCode(@PathVariable String typeCode) {
return Result.success(dbTypeService.getByTypeCode(typeCode));
}
/**
*
*/
@PostMapping
public Result<DbType> save(@RequestBody DbType dbType) {
dbTypeService.save(dbType);
return Result.success(dbType);
}
/**
*
*/
@PutMapping
public Result<DbType> update(@RequestBody DbType dbType) {
dbTypeService.updateById(dbType);
return Result.success(dbType);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Integer id) {
dbTypeService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,59 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mongodb.DialogRecord;
import com.example.springboot_demo.service.DialogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/dialog")
public class DialogController {
@Autowired
private DialogService dialogService;
@GetMapping("/list")
public Result<List<DialogRecord>> getUserDialogs(@RequestHeader(value = "userId", defaultValue = "1") Long userId) {
List<DialogRecord> dialogs = dialogService.getUserDialogs(userId);
return Result.success(dialogs);
}
@GetMapping("/{dialogId}")
public Result<DialogRecord> getDialogById(@PathVariable String dialogId) {
DialogRecord dialog = dialogService.getDialogById(dialogId);
if (dialog != null) {
return Result.success(dialog);
}
return Result.error("对话不存在");
}
@PostMapping
public Result<DialogRecord> createDialog(@RequestBody DialogRecord dialogRecord,
@RequestHeader(value = "userId", defaultValue = "1") Long userId) {
dialogRecord.setUserId(userId);
DialogRecord created = dialogService.createDialog(dialogRecord);
return Result.success(created);
}
@DeleteMapping("/{dialogId}")
public Result<Void> deleteDialog(@PathVariable String dialogId,
@RequestHeader(value = "userId", defaultValue = "1") Long userId) {
dialogService.deleteDialog(dialogId, userId);
return Result.success();
}
@PutMapping("/{dialogId}")
public Result<DialogRecord> updateDialog(@PathVariable String dialogId,
@RequestBody DialogRecord dialogRecord,
@RequestHeader(value = "userId", defaultValue = "1") Long userId) {
dialogRecord.setDialogId(dialogId);
dialogRecord.setUserId(userId);
DialogRecord updated = dialogService.updateDialog(dialogRecord);
return Result.success(updated);
}
}

@ -0,0 +1,40 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mongodb.DialogDetail;
import com.example.springboot_demo.service.DialogDetailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/dialog-detail")
public class DialogDetailController {
@Autowired
private DialogDetailService dialogDetailService;
@GetMapping("/{dialogId}")
public Result<DialogDetail> getByDialogId(@PathVariable String dialogId) {
return Result.success(dialogDetailService.getByDialogId(dialogId));
}
@PostMapping
public Result<DialogDetail> save(@RequestBody DialogDetail dialogDetail) {
DialogDetail saved = dialogDetailService.save(dialogDetail);
return Result.success(saved);
}
@PutMapping
public Result<DialogDetail> update(@RequestBody DialogDetail dialogDetail) {
DialogDetail saved = dialogDetailService.save(dialogDetail);
return Result.success(saved);
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
dialogDetailService.deleteById(id);
return Result.success();
}
}

@ -0,0 +1,86 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.ErrorLog;
import com.example.springboot_demo.service.ErrorLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/error-log")
public class ErrorLogController {
@Autowired
private ErrorLogService errorLogService;
/**
*
*/
@GetMapping("/list")
public Result<List<ErrorLog>> list() {
return Result.success(errorLogService.list());
}
/**
*
*/
@GetMapping("/list/type/{errorTypeId}")
public Result<List<ErrorLog>> listByErrorType(@PathVariable Integer errorTypeId) {
return Result.success(errorLogService.listByErrorTypeId(errorTypeId));
}
/**
*
*/
@GetMapping("/list/period/{period}")
public Result<List<ErrorLog>> listByPeriod(@PathVariable String period) {
return Result.success(errorLogService.listByPeriod(period));
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<ErrorLog> getById(@PathVariable Long id) {
return Result.success(errorLogService.getById(id));
}
/**
*
*/
@PostMapping
public Result<ErrorLog> save(@RequestBody ErrorLog errorLog) {
if (errorLog.getStatTime() == null) {
errorLog.setStatTime(LocalDateTime.now());
}
errorLogService.save(errorLog);
return Result.success(errorLog);
}
/**
*
*/
@PutMapping
public Result<ErrorLog> update(@RequestBody ErrorLog errorLog) {
errorLogService.updateById(errorLog);
return Result.success(errorLog);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
errorLogService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,74 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.ErrorType;
import com.example.springboot_demo.service.ErrorTypeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/error-type")
public class ErrorTypeController {
@Autowired
private ErrorTypeService errorTypeService;
/**
*
*/
@GetMapping("/list")
public Result<List<ErrorType>> list() {
return Result.success(errorTypeService.list());
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<ErrorType> getById(@PathVariable Integer id) {
return Result.success(errorTypeService.getById(id));
}
/**
*
*/
@GetMapping("/code/{errorCode}")
public Result<ErrorType> getByErrorCode(@PathVariable String errorCode) {
return Result.success(errorTypeService.getByErrorCode(errorCode));
}
/**
*
*/
@PostMapping
public Result<ErrorType> save(@RequestBody ErrorType errorType) {
errorTypeService.save(errorType);
return Result.success(errorType);
}
/**
*
*/
@PutMapping
public Result<ErrorType> update(@RequestBody ErrorType errorType) {
errorTypeService.updateById(errorType);
return Result.success(errorType);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Integer id) {
errorTypeService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,52 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mongodb.FriendChat;
import com.example.springboot_demo.service.FriendChatService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/friend-chat")
public class FriendChatController {
@Autowired
private FriendChatService friendChatService;
@GetMapping("/list/{userId}/{friendId}")
public Result<List<FriendChat>> listByUserIdAndFriendId(@PathVariable Long userId, @PathVariable Long friendId) {
return Result.success(friendChatService.listByUserIdAndFriendId(userId, friendId));
}
@GetMapping("/unread/{friendId}")
public Result<List<FriendChat>> listUnreadByFriendId(@PathVariable Long friendId) {
return Result.success(friendChatService.listUnreadByFriendId(friendId));
}
@PostMapping
public Result<FriendChat> save(@RequestBody FriendChat friendChat) {
friendChat.setSendTime(LocalDateTime.now());
if (friendChat.getIsRead() == null) {
friendChat.setIsRead(false);
}
FriendChat saved = friendChatService.save(friendChat);
return Result.success(saved);
}
@PutMapping
public Result<FriendChat> update(@RequestBody FriendChat friendChat) {
FriendChat saved = friendChatService.save(friendChat);
return Result.success(saved);
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
friendChatService.deleteById(id);
return Result.success();
}
}

@ -0,0 +1,58 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.FriendRelation;
import com.example.springboot_demo.service.FriendRelationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/friend-relation")
public class FriendRelationController {
@Autowired
private FriendRelationService friendRelationService;
@GetMapping("/list/{userId}")
public Result<List<FriendRelation>> listByUserId(@PathVariable Long userId) {
return Result.success(friendRelationService.listByUserId(userId));
}
@GetMapping("/{userId}/{friendId}")
public Result<FriendRelation> getByUserIdAndFriendId(@PathVariable Long userId, @PathVariable Long friendId) {
return Result.success(friendRelationService.getByUserIdAndFriendId(userId, friendId));
}
@PostMapping
public Result<FriendRelation> save(@RequestBody FriendRelation friendRelation) {
friendRelation.setCreateTime(LocalDateTime.now());
if (friendRelation.getOnlineStatus() == null) {
friendRelation.setOnlineStatus(0);
}
friendRelationService.save(friendRelation);
return Result.success(friendRelation);
}
@PutMapping
public Result<FriendRelation> update(@RequestBody FriendRelation friendRelation) {
friendRelationService.updateById(friendRelation);
return Result.success(friendRelation);
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
friendRelationService.removeById(id);
return Result.success();
}
@DeleteMapping("/{userId}/{friendId}")
public Result<Void> deleteByUserIdAndFriendId(@PathVariable Long userId, @PathVariable Long friendId) {
friendRelationService.removeByUserIdAndFriendId(userId, friendId);
return Result.success();
}
}

@ -0,0 +1,86 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.FriendRequest;
import com.example.springboot_demo.service.FriendRequestService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/friend-request")
public class FriendRequestController {
@Autowired
private FriendRequestService friendRequestService;
@GetMapping("/list/{recipientId}")
public Result<List<FriendRequest>> listByRecipientId(@PathVariable Long recipientId) {
return Result.success(friendRequestService.listByRecipientId(recipientId));
}
@GetMapping("/list/{recipientId}/{status}")
public Result<List<FriendRequest>> listByRecipientIdAndStatus(@PathVariable Long recipientId, @PathVariable Integer status) {
return Result.success(friendRequestService.listByRecipientIdAndStatus(recipientId, status));
}
@GetMapping("/{id}")
public Result<FriendRequest> getById(@PathVariable Long id) {
return Result.success(friendRequestService.getById(id));
}
@PostMapping
public Result<FriendRequest> save(@RequestBody FriendRequest friendRequest) {
friendRequest.setCreateTime(LocalDateTime.now());
if (friendRequest.getStatus() == null) {
friendRequest.setStatus(0);
}
friendRequestService.save(friendRequest);
return Result.success(friendRequest);
}
@PutMapping
public Result<FriendRequest> update(@RequestBody FriendRequest friendRequest) {
if (friendRequest.getStatus() != null && friendRequest.getStatus() != 0) {
friendRequest.setHandleTime(LocalDateTime.now());
}
friendRequestService.updateById(friendRequest);
return Result.success(friendRequest);
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
friendRequestService.removeById(id);
return Result.success();
}
@PostMapping("/{id}/accept")
public Result<String> acceptRequest(@PathVariable Long id) {
try {
boolean success = friendRequestService.acceptRequest(id);
if (success) {
return Result.success("已接受好友请求");
}
return Result.error("接受好友请求失败");
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
@PostMapping("/{id}/reject")
public Result<String> rejectRequest(@PathVariable Long id) {
try {
boolean success = friendRequestService.rejectRequest(id);
if (success) {
return Result.success("已拒绝好友请求");
}
return Result.error("拒绝好友请求失败");
} catch (RuntimeException e) {
return Result.error(e.getMessage());
}
}
}

@ -0,0 +1,117 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.LlmConfig;
import com.example.springboot_demo.service.LlmConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/llm-config")
public class LlmConfigController {
@Autowired
private LlmConfigService llmConfigService;
/**
*
*/
@GetMapping("/list")
public Result<List<LlmConfig>> list() {
return Result.success(llmConfigService.list());
}
/**
*
*/
@GetMapping("/list/available")
public Result<List<LlmConfig>> listAvailable() {
return Result.success(llmConfigService.listAvailable());
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<LlmConfig> getById(@PathVariable Long id) {
return Result.success(llmConfigService.getById(id));
}
/**
*
*/
@PostMapping
public Result<LlmConfig> save(@RequestBody LlmConfig llmConfig) {
// 验证必填字段
if (llmConfig.getName() == null || llmConfig.getName().trim().isEmpty()) {
return Result.error("模型名称不能为空");
}
if (llmConfig.getVersion() == null || llmConfig.getVersion().trim().isEmpty()) {
return Result.error("模型版本不能为空");
}
if (llmConfig.getApiKey() == null || llmConfig.getApiKey().trim().isEmpty()) {
return Result.error("API密钥不能为空");
}
if (llmConfig.getApiUrl() == null || llmConfig.getApiUrl().trim().isEmpty()) {
return Result.error("API地址不能为空");
}
// 设置默认值
llmConfig.setCreateTime(LocalDateTime.now());
llmConfig.setUpdateTime(LocalDateTime.now());
if (llmConfig.getIsDisabled() == null) {
llmConfig.setIsDisabled(0);
}
if (llmConfig.getStatusId() == null) {
llmConfig.setStatusId(1); // 默认状态ID为1
}
if (llmConfig.getTimeout() == null) {
llmConfig.setTimeout(60000); // 默认60秒
}
if (llmConfig.getCreateUserId() == null) {
// 从请求头或session获取当前用户ID这里暂时使用默认值1
llmConfig.setCreateUserId(1L);
}
llmConfigService.save(llmConfig);
return Result.success(llmConfig);
}
/**
*
*/
@PutMapping
public Result<LlmConfig> update(@RequestBody LlmConfig llmConfig) {
llmConfig.setUpdateTime(LocalDateTime.now());
llmConfigService.updateById(llmConfig);
return Result.success(llmConfig);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
llmConfigService.removeById(id);
return Result.success();
}
/**
* /
*/
@PutMapping("/{id}/toggle")
public Result<Void> toggle(@PathVariable Long id) {
LlmConfig config = llmConfigService.getById(id);
if (config != null) {
config.setIsDisabled(config.getIsDisabled() == 0 ? 1 : 0);
config.setUpdateTime(LocalDateTime.now());
llmConfigService.updateById(config);
}
return Result.success();
}
}

@ -0,0 +1,71 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.LlmStatus;
import com.example.springboot_demo.service.LlmStatusService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/llm-status")
public class LlmStatusController {
@Autowired
private LlmStatusService llmStatusService;
/**
*
*/
@GetMapping("/list")
public Result<List<LlmStatus>> list() {
return Result.success(llmStatusService.list());
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<LlmStatus> getById(@PathVariable Integer id) {
return Result.success(llmStatusService.getById(id));
}
/**
*
*/
@GetMapping("/code/{statusCode}")
public Result<LlmStatus> getByStatusCode(@PathVariable String statusCode) {
return Result.success(llmStatusService.getByStatusCode(statusCode));
}
/**
*
*/
@PostMapping
public Result<LlmStatus> save(@RequestBody LlmStatus llmStatus) {
llmStatusService.save(llmStatus);
return Result.success(llmStatus);
}
/**
*
*/
@PutMapping
public Result<LlmStatus> update(@RequestBody LlmStatus llmStatus) {
llmStatusService.updateById(llmStatus);
return Result.success(llmStatus);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Integer id) {
llmStatusService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,125 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.Notification;
import com.example.springboot_demo.service.NotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/notification")
public class NotificationController {
@Autowired
private NotificationService notificationService;
/**
*
*/
@GetMapping("/list")
public Result<List<Notification>> list() {
return Result.success(notificationService.list());
}
/**
*
*/
@GetMapping("/list/published")
public Result<List<Notification>> listPublished() {
return Result.success(notificationService.listPublished());
}
/**
* 稿
*/
@GetMapping("/list/drafts")
public Result<List<Notification>> listDrafts() {
return Result.success(notificationService.listDrafts());
}
/**
* ID
*/
@GetMapping("/list/target/{targetId}")
public Result<List<Notification>> listByTarget(@PathVariable Integer targetId) {
return Result.success(notificationService.listByTargetId(targetId));
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<Notification> getById(@PathVariable Long id) {
return Result.success(notificationService.getById(id));
}
/**
* 稿
*/
@PostMapping
public Result<Notification> save(@RequestBody Notification notification) {
notification.setCreateTime(LocalDateTime.now());
notification.setLatestUpdateTime(LocalDateTime.now());
if (notification.getIsTop() == null) {
notification.setIsTop(0);
}
notificationService.save(notification);
return Result.success(notification);
}
/**
*
*/
@PutMapping
public Result<Notification> update(@RequestBody Notification notification) {
notification.setLatestUpdateTime(LocalDateTime.now());
notificationService.updateById(notification);
return Result.success(notification);
}
/**
*
*/
@PutMapping("/{id}/publish")
public Result<Void> publish(@PathVariable Long id) {
Notification notification = notificationService.getById(id);
if (notification != null) {
notification.setPublishTime(LocalDateTime.now());
notification.setLatestUpdateTime(LocalDateTime.now());
notificationService.updateById(notification);
}
return Result.success();
}
/**
* /
*/
@PutMapping("/{id}/toggle-top")
public Result<Void> toggleTop(@PathVariable Long id) {
Notification notification = notificationService.getById(id);
if (notification != null) {
notification.setIsTop(notification.getIsTop() == 0 ? 1 : 0);
notification.setLatestUpdateTime(LocalDateTime.now());
notificationService.updateById(notification);
}
return Result.success();
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
notificationService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,74 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.NotificationTarget;
import com.example.springboot_demo.service.NotificationTargetService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/notification-target")
public class NotificationTargetController {
@Autowired
private NotificationTargetService notificationTargetService;
/**
*
*/
@GetMapping("/list")
public Result<List<NotificationTarget>> list() {
return Result.success(notificationTargetService.list());
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<NotificationTarget> getById(@PathVariable Integer id) {
return Result.success(notificationTargetService.getById(id));
}
/**
*
*/
@GetMapping("/code/{targetCode}")
public Result<NotificationTarget> getByTargetCode(@PathVariable String targetCode) {
return Result.success(notificationTargetService.getByTargetCode(targetCode));
}
/**
*
*/
@PostMapping
public Result<NotificationTarget> save(@RequestBody NotificationTarget notificationTarget) {
notificationTargetService.save(notificationTarget);
return Result.success(notificationTarget);
}
/**
*
*/
@PutMapping
public Result<NotificationTarget> update(@RequestBody NotificationTarget notificationTarget) {
notificationTargetService.updateById(notificationTarget);
return Result.success(notificationTarget);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Integer id) {
notificationTargetService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,82 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.OperationLog;
import com.example.springboot_demo.service.OperationLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/operation-log")
public class OperationLogController {
@Autowired
private OperationLogService operationLogService;
/**
*
*/
@GetMapping("/list")
public Result<List<OperationLog>> list() {
return Result.success(operationLogService.list());
}
/**
* ID
*/
@GetMapping("/list/user/{userId}")
public Result<List<OperationLog>> listByUser(@PathVariable Long userId) {
return Result.success(operationLogService.listByUserId(userId));
}
/**
*
*/
@GetMapping("/list/module/{module}")
public Result<List<OperationLog>> listByModule(@PathVariable String module) {
return Result.success(operationLogService.listByModule(module));
}
/**
*
*/
@GetMapping("/list/failed")
public Result<List<OperationLog>> listFailed() {
return Result.success(operationLogService.listFailed());
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<OperationLog> getById(@PathVariable Long id) {
return Result.success(operationLogService.getById(id));
}
/**
*
*/
@PostMapping
public Result<OperationLog> save(@RequestBody OperationLog operationLog) {
if (operationLog.getOperateTime() == null) {
operationLog.setOperateTime(LocalDateTime.now());
}
operationLogService.save(operationLog);
return Result.success(operationLog);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
operationLogService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,43 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.PerformanceMetric;
import com.example.springboot_demo.service.PerformanceMetricService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/performance-metric")
public class PerformanceMetricController {
@Autowired
private PerformanceMetricService performanceMetricService;
@GetMapping("/list")
public Result<List<PerformanceMetric>> list() {
return Result.success(performanceMetricService.list());
}
@GetMapping("/list/{metricType}")
public Result<List<PerformanceMetric>> listByMetricType(@PathVariable String metricType) {
return Result.success(performanceMetricService.listByMetricType(metricType));
}
@GetMapping("/range")
public Result<List<PerformanceMetric>> listByTimeRange(@RequestParam String startTime, @RequestParam String endTime) {
LocalDateTime start = LocalDateTime.parse(startTime);
LocalDateTime end = LocalDateTime.parse(endTime);
return Result.success(performanceMetricService.listByTimeRange(start, end));
}
@PostMapping
public Result<PerformanceMetric> save(@RequestBody PerformanceMetric performanceMetric) {
performanceMetricService.save(performanceMetric);
return Result.success(performanceMetric);
}
}

@ -0,0 +1,74 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.Priority;
import com.example.springboot_demo.service.PriorityService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/priority")
public class PriorityController {
@Autowired
private PriorityService priorityService;
/**
*
*/
@GetMapping("/list")
public Result<List<Priority>> list() {
return Result.success(priorityService.listOrderBySort());
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<Priority> getById(@PathVariable Integer id) {
return Result.success(priorityService.getById(id));
}
/**
*
*/
@GetMapping("/code/{priorityCode}")
public Result<Priority> getByPriorityCode(@PathVariable String priorityCode) {
return Result.success(priorityService.getByPriorityCode(priorityCode));
}
/**
*
*/
@PostMapping
public Result<Priority> save(@RequestBody Priority priority) {
priorityService.save(priority);
return Result.success(priority);
}
/**
*
*/
@PutMapping
public Result<Priority> update(@RequestBody Priority priority) {
priorityService.updateById(priority);
return Result.success(priority);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Integer id) {
priorityService.removeById(id);
return Result.success();
}
}

@ -0,0 +1,43 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mongodb.QueryCollection;
import com.example.springboot_demo.service.QueryCollectionService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/query-collection")
public class QueryCollectionController {
@Autowired
private QueryCollectionService queryCollectionService;
@GetMapping("/list/{userId}")
public Result<List<QueryCollection>> listByUserId(@PathVariable Long userId) {
return Result.success(queryCollectionService.listByUserId(userId));
}
@GetMapping("/{id}")
public Result<QueryCollection> getById(@PathVariable String id) {
return Result.success(queryCollectionService.getById(id));
}
@PostMapping
public Result<QueryCollection> save(@RequestBody QueryCollection queryCollection) {
queryCollection.setCreateTime(LocalDateTime.now());
QueryCollection saved = queryCollectionService.save(queryCollection);
return Result.success(saved);
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
queryCollectionService.deleteById(id);
return Result.success();
}
}

@ -0,0 +1,29 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.dto.QueryRequestDTO;
import com.example.springboot_demo.service.QueryService;
import com.example.springboot_demo.vo.QueryResponseVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/query")
public class QueryController {
@Autowired
private QueryService queryService;
@PostMapping("/execute")
public Result<QueryResponseVO> executeQuery(@RequestBody QueryRequestDTO request,
@RequestHeader(value = "userId", defaultValue = "1") Long userId) {
try {
QueryResponseVO response = queryService.executeQuery(request, userId);
return Result.success(response);
} catch (Exception e) {
return Result.error(e.getMessage());
}
}
}

@ -0,0 +1,78 @@
package com.example.springboot_demo.controller;
import com.example.springboot_demo.common.Result;
import com.example.springboot_demo.entity.mysql.QueryLog;
import com.example.springboot_demo.service.QueryLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/query-log")
public class QueryLogController {
@Autowired
private QueryLogService queryLogService;
/**
*
*/
@GetMapping("/list")
public Result<List<QueryLog>> list() {
return Result.success(queryLogService.list());
}
/**
* ID
*/
@GetMapping("/list/user/{userId}")
public Result<List<QueryLog>> listByUser(@PathVariable Long userId) {
return Result.success(queryLogService.listByUserId(userId));
}
/**
* ID
*/
@GetMapping("/list/dialog/{dialogId}")
public Result<List<QueryLog>> listByDialog(@PathVariable String dialogId) {
return Result.success(queryLogService.listByDialogId(dialogId));
}
/**
* ID
*/
@GetMapping("/{id}")
public Result<QueryLog> getById(@PathVariable Long id) {
return Result.success(queryLogService.getById(id));
}
/**
*
*/
@PostMapping
public Result<QueryLog> save(@RequestBody QueryLog queryLog) {
if (queryLog.getQueryDate() == null) {
queryLog.setQueryDate(LocalDate.now());
}
if (queryLog.getQueryTime() == null) {
queryLog.setQueryTime(LocalDateTime.now());
}
queryLogService.save(queryLog);
return Result.success(queryLog);
}
/**
*
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
queryLogService.removeById(id);
return Result.success();
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save