commit afffb8340ca65e94698dbee41471f4d190cfdae7 Author: hewei <502211108@qq.com> Date: Tue Mar 3 14:41:39 2026 +0800 “提交项目” diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95da2fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# IDEs +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.classpath +.project +.settings/ +.factorypath +.trae + +# OS +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini + +# Maven +target/ +.mvn/ +mvnw +mvnw.cmd +*.log + +# Frontend (Vue/Vite) +ui/node_modules/ +ui/dist/ +ui/npm-debug.log* +ui/yarn-debug.log* +ui/yarn-error.log* +ui/pnpm-debug.log* +ui/.env.local +ui/.env.*.local +ui/.DS_Store +ui/coverage/ +ui/.nyc_output/ + +# Application Logs +logs/ +*.log + +# Temp files +*.tmp +*.bak +*.swp + +uploads/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c398952 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 hertz_admin_springboot + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4d9e3d --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Hertz Admin + +Hertz Admin 是一个基于 Spring Boot 3 和 Vue 3 的前后端分离架构的轻量级权限管理平台。系统集成了用户管理、角色管理、菜单管理等核心功能,采用 RBAC(Role-Based Access Control)模型实现细粒度的权限控制。 + +## 🛠 技术栈 + +### 后端 (Backend) + +- **核心框架**: Spring Boot 3.4.1 +- **持久层**: MyBatis-Plus 3.5.8 +- **安全认证**: Spring Security + JJWT 0.12.6 (Stateless JWT) +- **数据库**: MySQL 8.0+ +- **构建工具**: Maven +- **运行环境**: JDK 21 + +### 前端 (Frontend) + +- **核心框架**: Vue 3.5.24 +- **构建工具**: Vite 7.2.4 +- **UI 组件库**: Element Plus 2.13.1 +- **状态管理**: Pinia 3.0.4 +- **路由管理**: Vue Router 4.6.4 +- **HTTP 客户端**: Axios 1.13.2 + +## ✨ 主要功能 + +- **用户管理**: 用户增删改查、角色分配、状态控制。 +- **角色管理**: 角色创建与权限分配(菜单/按钮级)。 +- **菜单管理**: 动态路由配置,支持目录、菜单、按钮三种类型。 +- **个人中心**: 用户资料更新、密码修改。 +- **文件上传**: 头像上传与静态资源访问(默认存放在项目 `./uploads`)。 +- **监控模块**: 系统资源(CPU、内存、磁盘、JVM)实时监控。 +- **AI 助手**: 集成 Spring AI,支持智能对话、历史记录与知识库(RAG)。 + +## 🚀 快速开始 + +### 环境要求 + +- JDK 21+ +- Node.js 18+ +- MySQL 8.0+ +- Ollama(本地大模型运行时,用于 AI 对话与知识库向量化) + +### 后端启动 + +1. 数据库配置: + - 创建数据库 `hertz_springboot`。 + - 导入初始化脚本 `db/init.sql`。 + - ~~额外导入以下脚本以启用监控与知识库功能:~~ + - ~~`src/main/resources/schema/monitor_schema.sql`~~ + - ~~`src/main/resources/schema/knowledge_schema.sql`~~ + - 修改 `src/main/resources/application.yml` 中的数据库连接配置。 +2. 启动服务: + ```bash + mvn spring-boot:run + ``` + 服务默认运行在 `http://localhost:8088`。 + +### AI(Ollama)准备 + +本项目的 AI 模块依赖 Ollama: + +- **聊天模型**:由 `spring.ai.ollama.chat.model` 指定(例如 `deepseek-llm:7b`) +- **向量化模型(Embedding)**:由 `spring.ai.ollama.embedding.model` 指定(默认 `nomic-embed-text`) + +首次使用知识库(RAG)前,请在运行后端的机器上拉取向量化模型: + +```bash +ollama pull nomic-embed-text +``` + +如需拉取聊天模型(根据你的配置决定): + +```bash +ollama pull deepseek-llm:7b +``` + +### 前端启动 + +1. 进入前端目录: + ```bash + cd ui + ``` +2. 安装依赖: + ```bash + npm install + ``` +3. 启动开发服务: + ```bash + npm run dev + ``` + 服务默认运行在 `http://localhost:5173`。 + +## 👤 初始账号 + +| 角色 | 用户名 | 密码 | 权限 | +| :--- | :--- | :--- | :--- | +| **管理员** | `hertz` | `hertz` | 拥有所有系统权限 | +| **普通用户** | `demo` | `123456` | 仅拥有基本查看权限 | + +## 📂 项目结构 + +```text +HertzAdmin-SpringBoot/ +├── db/ # 数据库初始化脚本 +├── src/ # 后端源码 (Spring Boot) +├── ui/ # 前端源码 (Vue 3 + Vite) +├── pom.xml # Maven 依赖配置 +├── 项目说明文档.md # 详细项目文档 +├── 数据库说明文档.md # 数据库设计文档 +└── README.md # 项目概览 (本文档) +``` + +## 📄 许可证 + +本项目采用 MIT 许可证。 diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..f340698 --- /dev/null +++ b/db/init.sql @@ -0,0 +1,230 @@ +/* + Navicat Premium Dump SQL + + Source Server : demo + Source Server Type : MySQL + Source Server Version : 80040 (8.0.40) + Source Host : localhost:3306 + Source Schema : hertz_springboot + + Target Server Type : MySQL + Target Server Version : 80040 (8.0.40) + File Encoding : 65001 + + Date: 02/02/2026 14:04:10 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for ai_conversations +-- ---------------------------- +DROP TABLE IF EXISTS `ai_conversations`; +CREATE TABLE `ai_conversations` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT '用户ID', + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '对话标题', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 51 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '对话记录表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of ai_conversations +-- ---------------------------- + +-- ---------------------------- +-- Table structure for ai_messages +-- ---------------------------- +DROP TABLE IF EXISTS `ai_messages`; +CREATE TABLE `ai_messages` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `conversation_id` bigint NOT NULL COMMENT '所属对话ID', + `role` enum('user','assistant') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '消息角色', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '消息内容', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_conversation_id`(`conversation_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 249 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '对话消息表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of ai_messages +-- ---------------------------- + +-- ---------------------------- +-- Table structure for knowledge_base +-- ---------------------------- +DROP TABLE IF EXISTS `knowledge_base`; +CREATE TABLE `knowledge_base` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '知识库名称', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '知识库描述', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` bigint NULL DEFAULT NULL COMMENT '创建人ID', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of knowledge_base +-- ---------------------------- + +-- ---------------------------- +-- Table structure for knowledge_document +-- ---------------------------- +DROP TABLE IF EXISTS `knowledge_document`; +CREATE TABLE `knowledge_document` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `kb_id` bigint NOT NULL COMMENT '知识库ID', + `original_name` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '原始文件名', + `stored_name` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '存储文件名', + `stored_path` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '存储相对路径', + `content_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件类型', + `size_bytes` bigint NOT NULL COMMENT '文件大小(字节)', + `sha256` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件SHA256摘要', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '处理状态(PROCESSING/READY/FAILED)', + `chunk_count` int NULL DEFAULT 0 COMMENT '分片数量', + `error_message` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '失败原因', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `deleted` tinyint(1) NULL DEFAULT 0 COMMENT '是否删除(软删除标记)', + `deleted_time` datetime NULL DEFAULT NULL COMMENT '删除时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_kb_id`(`kb_id` ASC) USING BTREE, + INDEX `idx_kb_deleted`(`kb_id` ASC, `deleted` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 11 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of knowledge_document +-- ---------------------------- + +-- ---------------------------- +-- Table structure for sys_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_menu`; +CREATE TABLE `sys_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单ID', + `parent_id` bigint NULL DEFAULT 0 COMMENT '父菜单ID', + `type` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'D-目录 M-菜单 B-按钮', + `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '菜单名称', + `path` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '路由路径', + `component` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '组件路径', + `perms` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限标识', + `icon` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '菜单图标', + `sort` int NULL DEFAULT 0 COMMENT '排序', + `visible` tinyint NULL DEFAULT 1 COMMENT '0-隐藏 1-显示', + `status` tinyint NULL DEFAULT 1 COMMENT '0-禁用 1-启用', + `created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 24 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统菜单表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_menu +-- ---------------------------- +INSERT INTO `sys_menu` VALUES (1, 0, 'M', '仪表盘', '/admin/dashboard', 'admin/Dashboard', NULL, 'House', 0, 1, 1, '2026-01-19 17:30:21', '2026-01-30 18:06:30'); +INSERT INTO `sys_menu` VALUES (2, 0, 'D', '系统管理', '/admin/system', NULL, NULL, 'Setting', 10, 1, 1, '2026-01-19 17:30:21', '2026-01-19 17:30:21'); +INSERT INTO `sys_menu` VALUES (3, 2, 'M', '用户管理', '/admin/system/user', 'admin/system/User', 'system:user:view', 'User', 0, 1, 1, '2026-01-19 17:30:21', '2026-01-30 18:06:21'); +INSERT INTO `sys_menu` VALUES (4, 2, 'M', '角色管理', '/admin/system/role', 'admin/system/Role', 'system:role:view', 'Avatar', 1, 1, 1, '2026-01-19 17:30:21', '2026-01-30 17:05:27'); +INSERT INTO `sys_menu` VALUES (5, 2, 'M', '菜单管理', '/admin/system/menu', 'admin/system/Menu', 'system:menu:view', 'Menu', 2, 1, 1, '2026-01-19 17:30:21', '2026-01-19 17:30:21'); +INSERT INTO `sys_menu` VALUES (6, 0, 'M', '知识库管理', '/admin/ai/knowledge-base', 'admin/ai/KnowledgeBase', '', 'Document', 0, 1, 1, '2026-01-26 16:18:39', '2026-01-30 18:06:27'); +INSERT INTO `sys_menu` VALUES (7, 3, 'B', '用户新增', '', '', 'system:user:add', '', 0, 1, 1, '2026-01-30 16:05:29', '2026-01-30 17:12:03'); +INSERT INTO `sys_menu` VALUES (8, 3, 'B', '用户删除', '', '', 'system:user:remove', '', 0, 1, 1, '2026-01-30 16:27:10', '2026-01-30 17:12:09'); +INSERT INTO `sys_menu` VALUES (9, 3, 'B', '用户查询', '', '', 'system:user:view', '', 0, 1, 1, '2026-01-30 17:12:33', '2026-01-30 17:12:33'); +INSERT INTO `sys_menu` VALUES (10, 3, 'B', '用户修改', '', '', 'system:user:edit', '', 0, 1, 1, '2026-01-30 17:12:53', '2026-01-30 17:12:53'); +INSERT INTO `sys_menu` VALUES (15, 4, 'B', '角色查询', '', '', 'system:role:view', '', 0, 1, 1, '2026-01-30 17:31:55', '2026-01-30 17:31:55'); +INSERT INTO `sys_menu` VALUES (16, 4, 'B', '角色新增', '', '', 'system:role:add', '', 0, 1, 1, '2026-01-30 17:32:10', '2026-01-30 17:32:10'); +INSERT INTO `sys_menu` VALUES (17, 4, 'B', '角色修改', '', '', 'system:role:edit', '', 0, 1, 1, '2026-01-30 17:32:22', '2026-01-30 17:32:22'); +INSERT INTO `sys_menu` VALUES (18, 4, 'B', '角色删除', '', '', 'system:role:remove', '', 0, 1, 1, '2026-01-30 17:32:35', '2026-01-30 17:32:35'); +INSERT INTO `sys_menu` VALUES (19, 4, 'B', '分配权限', '', '', 'system:role:assign', '', 0, 1, 1, '2026-01-30 17:33:05', '2026-01-30 17:34:32'); +INSERT INTO `sys_menu` VALUES (20, 3, 'B', '角色分配', '', '', 'system:user:assign', '', 0, 1, 1, '2026-01-30 17:33:29', '2026-01-30 17:33:29'); +INSERT INTO `sys_menu` VALUES (21, 5, 'B', '菜单新增', '', '', 'system:menu:add', '', 0, 1, 1, '2026-01-30 17:34:56', '2026-01-30 17:34:56'); +INSERT INTO `sys_menu` VALUES (22, 5, 'B', '菜单修改', '', '', 'system:menu:edit', '', 0, 1, 1, '2026-01-30 17:35:10', '2026-01-30 17:35:10'); +INSERT INTO `sys_menu` VALUES (23, 5, 'B', '菜单删除', '', '', 'system:menu:remove', '', 0, 1, 1, '2026-01-30 17:35:21', '2026-01-30 17:35:21'); + +-- ---------------------------- +-- Table structure for sys_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role`; +CREATE TABLE `sys_role` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID', + `role_key` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色标识', + `role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称', + `status` tinyint NULL DEFAULT 1 COMMENT '0-禁用 1-启用', + `created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_role_key`(`role_key` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统角色表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_role +-- ---------------------------- +INSERT INTO `sys_role` VALUES (1, 'ADMIN', '管理员', 1, '2026-01-19 17:30:21', '2026-01-19 17:30:21'); + +-- ---------------------------- +-- Table structure for sys_role_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_menu`; +CREATE TABLE `sys_role_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role_id` bigint NOT NULL COMMENT '角色ID', + `menu_id` bigint NOT NULL COMMENT '菜单ID', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_role_menu`(`role_id` ASC, `menu_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 87 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色菜单关联表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_role_menu +-- ---------------------------- +INSERT INTO `sys_role_menu` VALUES (1, 1, 1); +INSERT INTO `sys_role_menu` VALUES (2, 1, 2); +INSERT INTO `sys_role_menu` VALUES (3, 1, 3); +INSERT INTO `sys_role_menu` VALUES (4, 1, 4); +INSERT INTO `sys_role_menu` VALUES (5, 1, 5); + +-- ---------------------------- +-- Table structure for sys_user +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名', + `password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '加密密码', + `nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户昵称', + `avatar_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '头像路径', + `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号', + `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱', + `gender` tinyint(1) NULL DEFAULT 0 COMMENT '0-未知 1-男 2-女', + `status` tinyint NULL DEFAULT 1 COMMENT '0-禁用 1-启用', + `created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_username`(`username` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 26 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统用户表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_user +-- ---------------------------- +INSERT INTO `sys_user` VALUES (1, 'hertz', '$2a$10$Gker6.ggCxG3wfZ13rE/Eu7aDnB.DX2JmP6h6vct30RTtBr9.q5Pq', '赫兹', '', '18888888888', 'hertz@hertz.com', 1, 1, '2026-01-19 17:30:21', '2026-01-27 11:33:55'); +INSERT INTO `sys_user` VALUES (2, 'demo', '$2a$10$PSIz9pWXAwXfB32HWSxTjeGhVi0bixsSKxzeX8YAdKnRRXPxJC3Xe', 'demo', '', '13888888888', 'demo@hertz.com', 1, 1, '2026-01-19 17:30:21', '2026-01-26 18:13:29'); + +-- ---------------------------- +-- Table structure for sys_user_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_role`; +CREATE TABLE `sys_user_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT '用户ID', + `role_id` bigint NOT NULL COMMENT '角色ID', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_user_role`(`user_id` ASC, `role_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色关联表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of sys_user_role +-- ---------------------------- +INSERT INTO `sys_user_role` VALUES (1, 1, 1); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f35dd1f --- /dev/null +++ b/pom.xml @@ -0,0 +1,176 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.1 + + + + com.hertz + hertz-springboot + 0.0.1-SNAPSHOT + hertz-springboot + Hertz 权限管理系统后端 + + + 21 + 21 + 3.5.8 + 0.12.6 + 1.0.0-M5 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.ai + spring-ai-ollama-spring-boot-starter + + + org.springframework.ai + spring-ai-tika-document-reader + + + org.springframework.boot + spring-boot-starter-actuator + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + org.springframework.boot + spring-boot-starter-data-redis + + + cn.hutool + hutool-all + 5.8.25 + + + + + com.github.oshi + oshi-core + 6.6.5 + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce-java + + enforce + + + + + [21,) + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/hertz/HertzApplication.java b/src/main/java/com/hertz/HertzApplication.java new file mode 100644 index 0000000..e1c2645 --- /dev/null +++ b/src/main/java/com/hertz/HertzApplication.java @@ -0,0 +1,14 @@ +package com.hertz; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("com.hertz.**.mapper") +public class HertzApplication { + + public static void main(String[] args) { + SpringApplication.run(HertzApplication.class, args); + } +} diff --git a/src/main/java/com/hertz/common/api/ApiResponse.java b/src/main/java/com/hertz/common/api/ApiResponse.java new file mode 100644 index 0000000..beff6df --- /dev/null +++ b/src/main/java/com/hertz/common/api/ApiResponse.java @@ -0,0 +1,26 @@ +/** + * 统一接口响应体。 + * + * @param code 业务状态码 + * @param message 提示信息 + * @param data 返回数据 + */ +package com.hertz.common.api; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse(int code, String message, T data) { + public static ApiResponse ok(T data) { + return new ApiResponse<>(0, "ok", data); + } + + public static ApiResponse ok() { + return new ApiResponse<>(0, "ok", null); + } + + public static ApiResponse fail(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} + diff --git a/src/main/java/com/hertz/common/exception/BusinessException.java b/src/main/java/com/hertz/common/exception/BusinessException.java new file mode 100644 index 0000000..65437aa --- /dev/null +++ b/src/main/java/com/hertz/common/exception/BusinessException.java @@ -0,0 +1,20 @@ +/** + * 业务异常。 + * + *

用于在业务流程中主动抛出可预期的错误,并携带业务错误码。

+ */ +package com.hertz.common.exception; + +public class BusinessException extends RuntimeException { + private final int code; + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} + diff --git a/src/main/java/com/hertz/common/exception/GlobalExceptionHandler.java b/src/main/java/com/hertz/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..f79aebd --- /dev/null +++ b/src/main/java/com/hertz/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,74 @@ +/** + * 全局异常处理器。 + * + *

将各类异常统一转换为标准的接口响应结构,并设置对应的 HTTP 状态码。

+ */ +package com.hertz.common.exception; + +import com.hertz.common.api.ApiResponse; +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException e) { + var status = HttpStatus.BAD_REQUEST; + if (e.getCode() == 40100) { + status = HttpStatus.UNAUTHORIZED; + } else if (e.getCode() == 40300) { + status = HttpStatus.FORBIDDEN; + } else if (e.getCode() == 50300) { + status = HttpStatus.SERVICE_UNAVAILABLE; + } + return ResponseEntity.status(status).body(ApiResponse.fail(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleMethodArgumentNotValid(MethodArgumentNotValidException e) { + var first = e.getBindingResult().getFieldErrors().stream().findFirst().orElse(null); + var message = first == null ? "参数错误" : first.getDefaultMessage(); + return ApiResponse.fail(40001, message); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleConstraintViolation(ConstraintViolationException e) { + return ApiResponse.fail(40001, e.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ApiResponse handleAuthentication(AuthenticationException e) { + return ApiResponse.fail(40100, "未登录或登录已过期"); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ApiResponse handleAccessDenied(AccessDeniedException e) { + return ApiResponse.fail(40300, "无权限"); + } + + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ApiResponse handleNoResourceFoundException(NoResourceFoundException e) { + return ApiResponse.fail(40400, "资源未找到"); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleException(Exception e) { + e.printStackTrace(); // 打印堆栈信息到控制台 + return ApiResponse.fail(50000, "系统异常"); + } +} diff --git a/src/main/java/com/hertz/common/filter/RequestLogFilter.java b/src/main/java/com/hertz/common/filter/RequestLogFilter.java new file mode 100644 index 0000000..6a355e1 --- /dev/null +++ b/src/main/java/com/hertz/common/filter/RequestLogFilter.java @@ -0,0 +1,41 @@ +/** + * 请求日志过滤器。 + * + *

记录每次 HTTP 请求的方法、URI、响应状态码与耗时。

+ */ +package com.hertz.common.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@Slf4j +public class RequestLogFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) + throws ServletException, IOException { + + long startTime = System.currentTimeMillis(); + String method = request.getMethod(); + String uri = request.getRequestURI(); + + try { + filterChain.doFilter(request, response); + } finally { + long endTime = System.currentTimeMillis(); + long timeTaken = endTime - startTime; + int status = response.getStatus(); + + log.info("Request: [{}] {} | Status: {} | Time: {}ms", method, uri, status, timeTaken); + } + } +} diff --git a/src/main/java/com/hertz/config/AppPathResolver.java b/src/main/java/com/hertz/config/AppPathResolver.java new file mode 100644 index 0000000..0a5f451 --- /dev/null +++ b/src/main/java/com/hertz/config/AppPathResolver.java @@ -0,0 +1,31 @@ +/** + * 应用路径解析器。 + * + *

用于将配置中的相对路径解析为基于应用工作目录的绝对路径。

+ */ +package com.hertz.config; + +import java.io.File; +import org.springframework.stereotype.Service; + +@Service +public class AppPathResolver { + + private final File baseDir; + + public AppPathResolver() { + String userDir = System.getProperty("user.dir"); + this.baseDir = userDir == null || userDir.isBlank() ? new File(".") : new File(userDir); + } + + public String resolve(String path) { + if (path == null || path.isBlank()) { + return path; + } + File file = new File(path); + if (file.isAbsolute()) { + return file.getAbsolutePath(); + } + return new File(baseDir, path).getAbsolutePath(); + } +} diff --git a/src/main/java/com/hertz/config/AppProperties.java b/src/main/java/com/hertz/config/AppProperties.java new file mode 100644 index 0000000..60bdfbf --- /dev/null +++ b/src/main/java/com/hertz/config/AppProperties.java @@ -0,0 +1,32 @@ +/** + * 应用自定义配置属性。 + * + *

对应 application.yml 中以 {@code app} 为前缀的配置项。

+ */ +package com.hertz.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "app") +public class AppProperties { + + private Jwt jwt = new Jwt(); + private Upload upload = new Upload(); + + @Data + public static class Jwt { + private String secret; + private long expireSeconds; + } + + @Data + public static class Upload { + private String rootPath; + private String avatarPath; + private String knowledgePath = "knowledge/"; + } +} diff --git a/src/main/java/com/hertz/config/MybatisPlusConfig.java b/src/main/java/com/hertz/config/MybatisPlusConfig.java new file mode 100644 index 0000000..02af2c5 --- /dev/null +++ b/src/main/java/com/hertz/config/MybatisPlusConfig.java @@ -0,0 +1,22 @@ +/** + * MyBatis-Plus 配置。 + * + *

主要用于注册分页等拦截器。

+ */ +package com.hertz.config; + +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MybatisPlusConfig { + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + var interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); + return interceptor; + } +} + diff --git a/src/main/java/com/hertz/config/WebMvcConfig.java b/src/main/java/com/hertz/config/WebMvcConfig.java new file mode 100644 index 0000000..d4d179e --- /dev/null +++ b/src/main/java/com/hertz/config/WebMvcConfig.java @@ -0,0 +1,33 @@ +/** + * Web MVC 配置。 + * + *

用于配置静态资源映射,例如上传文件的访问路径。

+ */ +package com.hertz.config; + +import java.io.File; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Value("${app.upload.root-path}") + private String uploadRootPath; + + private final AppPathResolver pathResolver; + + public WebMvcConfig(AppPathResolver pathResolver) { + this.pathResolver = pathResolver; + } + + @Override + public void addResourceHandlers(@NonNull ResourceHandlerRegistry registry) { + String absoluteUploadRootPath = new File(pathResolver.resolve(uploadRootPath)).getAbsolutePath().replace("\\", "/"); + registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:" + absoluteUploadRootPath + "/"); + } +} diff --git a/src/main/java/com/hertz/modules/ai/config/VectorStoreConfig.java b/src/main/java/com/hertz/modules/ai/config/VectorStoreConfig.java new file mode 100644 index 0000000..8565b1d --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/config/VectorStoreConfig.java @@ -0,0 +1,27 @@ +package com.hertz.modules.ai.config; + +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.File; + +@Configuration +public class VectorStoreConfig { + + @Value("${spring.ai.vectorstore.simple.store.path:vector-store.json}") + private String vectorStorePath; + + @Bean + public VectorStore vectorStore(EmbeddingModel embeddingModel) { + SimpleVectorStore simpleVectorStore = SimpleVectorStore.builder(embeddingModel).build(); + File file = new File(vectorStorePath); + if (file.exists() && file.isFile()) { + simpleVectorStore.load(file); + } + return simpleVectorStore; + } +} diff --git a/src/main/java/com/hertz/modules/ai/controller/AiController.java b/src/main/java/com/hertz/modules/ai/controller/AiController.java new file mode 100644 index 0000000..60810c6 --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/controller/AiController.java @@ -0,0 +1,117 @@ +package com.hertz.modules.ai.controller; + +import com.hertz.modules.ai.dto.ChatRequest; +import com.hertz.modules.ai.entity.Conversation; +import com.hertz.modules.ai.entity.Message; +import com.hertz.modules.ai.service.AiService; +import com.hertz.modules.ai.service.ConversationService; +import com.hertz.common.api.ApiResponse; +import com.hertz.common.exception.BusinessException; +import com.hertz.security.SecurityUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import java.util.List; + +@RestController +@RequestMapping("/api/ai") +@RequiredArgsConstructor +public class AiController { + + private final AiService aiService; + private final ConversationService conversationService; + + @PostMapping("/chat") + public ApiResponse chat(@RequestBody ChatRequest request) { + Long userId = null; + if (request.getConversationId() != null) { + userId = SecurityUtils.getCurrentUserId(); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), userId); + } + String response = aiService.chat( + request.getMessage(), + request.getTemperature(), + request.getKnowledgeBaseId(), + request.getConversationId(), + userId + ); + if (request.getConversationId() != null) { + conversationService.saveMessage(request.getConversationId(), "assistant", response, userId); + } + return ApiResponse.ok(response); + } + + @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux streamChat(@RequestBody ChatRequest request) { + // Frontend handles message persistence + Long userId = null; + if (request.getConversationId() != null) { + userId = SecurityUtils.getCurrentUserId(); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + } + return aiService.streamChat( + request.getMessage(), + request.getTemperature(), + request.getKnowledgeBaseId(), + request.getConversationId(), + userId + ); + } + + @PostMapping("/conversations") + public ApiResponse createConversation(@RequestBody Conversation conversation) { + Long userId = SecurityUtils.getCurrentUserId(); + return ApiResponse.ok(conversationService.createConversation(conversation.getTitle(), userId)); + } + + @GetMapping("/conversations") + public ApiResponse> listConversations() { + Long userId = SecurityUtils.getCurrentUserId(); + return ApiResponse.ok(conversationService.getConversations(userId)); + } + + @DeleteMapping("/conversations/{id}") + public ApiResponse deleteConversation(@PathVariable Long id) { + Long userId = SecurityUtils.getCurrentUserId(); + conversationService.deleteConversation(id, userId); + return ApiResponse.ok(); + } + + @PutMapping("/conversations/{id}") + public ApiResponse updateConversation(@PathVariable Long id, @RequestBody Conversation conversation) { + Long userId = SecurityUtils.getCurrentUserId(); + conversationService.updateConversationTitle(id, conversation.getTitle(), userId); + return ApiResponse.ok(); + } + + @GetMapping("/conversations/search") + public ApiResponse> searchConversations(@RequestParam String query) { + Long userId = SecurityUtils.getCurrentUserId(); + return ApiResponse.ok(conversationService.searchConversations(query, userId)); + } + + @PostMapping("/conversations/{id}/messages") + public ApiResponse saveMessage(@PathVariable Long id, @RequestBody Message message) { + Long userId = SecurityUtils.getCurrentUserId(); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + return ApiResponse.ok(conversationService.saveMessage(id, message.getRole(), message.getContent(), userId)); + } + + @GetMapping("/conversations/{id}/messages") + public ApiResponse> getMessages(@PathVariable Long id) { + Long userId = SecurityUtils.getCurrentUserId(); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + return ApiResponse.ok(conversationService.getMessages(id, userId)); + } +} diff --git a/src/main/java/com/hertz/modules/ai/dto/ChatRequest.java b/src/main/java/com/hertz/modules/ai/dto/ChatRequest.java new file mode 100644 index 0000000..9191244 --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/dto/ChatRequest.java @@ -0,0 +1,11 @@ +package com.hertz.modules.ai.dto; + +import lombok.Data; + +@Data +public class ChatRequest { + private String message; + private Double temperature; + private Long conversationId; + private Long knowledgeBaseId; +} diff --git a/src/main/java/com/hertz/modules/ai/entity/Conversation.java b/src/main/java/com/hertz/modules/ai/entity/Conversation.java new file mode 100644 index 0000000..aedb699 --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/entity/Conversation.java @@ -0,0 +1,18 @@ +package com.hertz.modules.ai.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("ai_conversations") +public class Conversation { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String title; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/hertz/modules/ai/entity/Message.java b/src/main/java/com/hertz/modules/ai/entity/Message.java new file mode 100644 index 0000000..103e7c6 --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/entity/Message.java @@ -0,0 +1,21 @@ +package com.hertz.modules.ai.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("ai_messages") +public class Message { + @TableId(type = IdType.AUTO) + private Long id; + private Long conversationId; + /** + * user or assistant + */ + private String role; + private String content; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/hertz/modules/ai/mapper/ConversationMapper.java b/src/main/java/com/hertz/modules/ai/mapper/ConversationMapper.java new file mode 100644 index 0000000..0f79004 --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/mapper/ConversationMapper.java @@ -0,0 +1,9 @@ +package com.hertz.modules.ai.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.ai.entity.Conversation; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ConversationMapper extends BaseMapper { +} diff --git a/src/main/java/com/hertz/modules/ai/mapper/MessageMapper.java b/src/main/java/com/hertz/modules/ai/mapper/MessageMapper.java new file mode 100644 index 0000000..5de09fd --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/mapper/MessageMapper.java @@ -0,0 +1,9 @@ +package com.hertz.modules.ai.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.ai.entity.Message; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface MessageMapper extends BaseMapper { +} diff --git a/src/main/java/com/hertz/modules/ai/service/AiService.java b/src/main/java/com/hertz/modules/ai/service/AiService.java new file mode 100644 index 0000000..092cc9c --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/service/AiService.java @@ -0,0 +1,8 @@ +package com.hertz.modules.ai.service; + +import reactor.core.publisher.Flux; + +public interface AiService { + String chat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId); + Flux streamChat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId); +} diff --git a/src/main/java/com/hertz/modules/ai/service/ConversationService.java b/src/main/java/com/hertz/modules/ai/service/ConversationService.java new file mode 100644 index 0000000..f96ec48 --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/service/ConversationService.java @@ -0,0 +1,17 @@ +package com.hertz.modules.ai.service; + +import com.hertz.modules.ai.entity.Conversation; +import com.hertz.modules.ai.entity.Message; +import java.util.List; + +public interface ConversationService { + Conversation createConversation(String title, Long userId); + List getConversations(Long userId); + void deleteConversation(Long id, Long userId); + List searchConversations(String query, Long userId); + Message saveMessage(Long conversationId, String role, String content, Long userId); + List getMessages(Long conversationId, Long userId); + List getRecentMessages(Long conversationId, Long userId, int limit); + Conversation getConversation(Long id, Long userId); + void updateConversationTitle(Long id, String title, Long userId); +} diff --git a/src/main/java/com/hertz/modules/ai/service/impl/AiServiceImpl.java b/src/main/java/com/hertz/modules/ai/service/impl/AiServiceImpl.java new file mode 100644 index 0000000..e1145fd --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/service/impl/AiServiceImpl.java @@ -0,0 +1,164 @@ +package com.hertz.modules.ai.service.impl; + +import com.hertz.modules.ai.service.AiService; +import com.hertz.modules.ai.service.ConversationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.document.Document; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import com.hertz.modules.knowledge.service.KnowledgeVectorStoreManager; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AiServiceImpl implements AiService { + + private final OllamaChatModel chatModel; + private final KnowledgeVectorStoreManager vectorStoreManager; + private final ConversationService conversationService; + + private static final String SYSTEM_PROMPT = "你是赫兹官网的AI助手。赫兹是一个基于Spring Boot的权限控制管理系统框架,用于构建高性能、可扩展的应用程序。" + + "你可以回答关于赫兹的问题,如:赫兹的架构、性能优化、微服务设计模式等。" + + "如果用户有其他问题,也请尽力回答。"; + + private static final int CHAT_MEMORY_MAX_MESSAGES = 10; + + @Override + public String chat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId) { + long startTime = System.currentTimeMillis(); + try { + log.info("Starting AI chat request. Message length: {}", message.length()); + + String systemPromptContent = SYSTEM_PROMPT; + if (knowledgeBaseId != null) { + systemPromptContent = getRAGSystemPrompt(message, knowledgeBaseId); + } + + OllamaOptions options = OllamaOptions.builder() + .temperature(temperature != null ? temperature : 0.7) + .build(); + + List messages = buildPromptMessages(systemPromptContent, conversationId, userId, message); + + String response = chatModel.call(new Prompt(messages, options)).getResult().getOutput().getContent(); + + log.info("AI chat request completed in {}ms", System.currentTimeMillis() - startTime); + return response; + } catch (Exception e) { + log.error("AI chat request failed", e); + throw new RuntimeException("AI service unavailable: " + e.getMessage()); + } + } + + @Override + public Flux streamChat(String message, Double temperature, Long knowledgeBaseId, Long conversationId, Long userId) { + OllamaOptions options = OllamaOptions.builder() + .temperature(temperature != null ? temperature : 0.7) + .build(); + + String systemPromptContent = SYSTEM_PROMPT; + if (knowledgeBaseId != null) { + systemPromptContent = getRAGSystemPrompt(message, knowledgeBaseId); + } + + List messages = buildPromptMessages(systemPromptContent, conversationId, userId, message); + + return chatModel.stream(new Prompt(messages, options)) + .map(response -> { + String content = response.getResult().getOutput().getContent(); + return content != null ? content : ""; + }) + .doOnError(e -> log.error("Error in AI stream", e)) + .onErrorResume(e -> Flux.just(" [Error: " + e.getMessage() + "]")); + } + + private List buildPromptMessages(String systemPromptContent, Long conversationId, Long userId, String userMessage) { + List history = List.of(); + if (conversationId != null && userId != null) { + history = conversationService.getRecentMessages(conversationId, userId, CHAT_MEMORY_MAX_MESSAGES); + } + return assemblePromptMessages(systemPromptContent, history, userMessage); + } + + List assemblePromptMessages(String systemPromptContent, List history, String userMessage) { + List promptMessages = new ArrayList<>(); + promptMessages.add(new SystemMessage(systemPromptContent)); + + if (history != null && !history.isEmpty()) { + for (com.hertz.modules.ai.entity.Message historyMessage : history) { + Message mapped = mapStoredMessage(historyMessage); + if (mapped != null) { + promptMessages.add(mapped); + } + } + } + + if (!isDuplicateLastUserMessage(history, userMessage)) { + promptMessages.add(new UserMessage(userMessage)); + } + + return promptMessages; + } + + boolean isDuplicateLastUserMessage(List history, String userMessage) { + if (history == null || history.isEmpty()) { + return false; + } + com.hertz.modules.ai.entity.Message last = history.get(history.size() - 1); + if (last == null) { + return false; + } + if (!"user".equalsIgnoreCase(last.getRole())) { + return false; + } + return Objects.equals(last.getContent(), userMessage); + } + + Message mapStoredMessage(com.hertz.modules.ai.entity.Message stored) { + if (stored == null || stored.getContent() == null || stored.getContent().isBlank()) { + return null; + } + String role = stored.getRole() == null ? "" : stored.getRole().trim().toLowerCase(); + return switch (role) { + case "assistant" -> new AssistantMessage(stored.getContent()); + case "system" -> new SystemMessage(stored.getContent()); + default -> new UserMessage(stored.getContent()); + }; + } + + private String getRAGSystemPrompt(String message, Long knowledgeBaseId) { + try { + SearchRequest request = SearchRequest.builder().query(message).topK(3).build(); + List similarDocuments = vectorStoreManager.get(knowledgeBaseId).similaritySearch(request); + log.info("RAG retrieved {} documents for kbId={}", similarDocuments.size(), knowledgeBaseId); + + if (similarDocuments.isEmpty()) { + return SYSTEM_PROMPT; + } + + String context = similarDocuments.stream() + .map(doc -> doc.getText()) + .collect(Collectors.joining("\n\n")); + + return SYSTEM_PROMPT + "\n\n以下是相关背景知识:\n" + context + + "\n\n请优先依据背景知识回答;如果背景知识不足以回答,请明确说明“知识库未检索到相关内容”,再给出你的通用建议。"; + } catch (Exception e) { + log.error("RAG retrieval failed", e); + return SYSTEM_PROMPT; + } + } +} diff --git a/src/main/java/com/hertz/modules/ai/service/impl/ConversationServiceImpl.java b/src/main/java/com/hertz/modules/ai/service/impl/ConversationServiceImpl.java new file mode 100644 index 0000000..55bcc01 --- /dev/null +++ b/src/main/java/com/hertz/modules/ai/service/impl/ConversationServiceImpl.java @@ -0,0 +1,129 @@ +package com.hertz.modules.ai.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.hertz.common.exception.BusinessException; +import com.hertz.modules.ai.entity.Conversation; +import com.hertz.modules.ai.entity.Message; +import com.hertz.modules.ai.mapper.ConversationMapper; +import com.hertz.modules.ai.mapper.MessageMapper; +import com.hertz.modules.ai.service.ConversationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ConversationServiceImpl implements ConversationService { + + private final ConversationMapper conversationMapper; + private final MessageMapper messageMapper; + + @Override + @Transactional + public Conversation createConversation(String title, Long userId) { + Conversation conversation = new Conversation(); + conversation.setUserId(userId); + conversation.setTitle(title != null && !title.isEmpty() ? title : "New Chat"); + conversation.setCreatedAt(LocalDateTime.now()); + conversation.setUpdatedAt(LocalDateTime.now()); + conversationMapper.insert(conversation); + return conversation; + } + + @Override + public List getConversations(Long userId) { + return conversationMapper.selectList(new LambdaQueryWrapper() + .eq(Conversation::getUserId, userId) + .orderByDesc(Conversation::getUpdatedAt)); + } + + @Override + @Transactional + public void deleteConversation(Long id, Long userId) { + Conversation conversation = requireOwnedConversation(id, userId); + messageMapper.delete(new LambdaQueryWrapper() + .eq(Message::getConversationId, id)); + conversationMapper.deleteById(conversation.getId()); + } + + @Override + public List searchConversations(String query, Long userId) { + return conversationMapper.selectList(new LambdaQueryWrapper() + .eq(Conversation::getUserId, userId) + .like(Conversation::getTitle, query) + .orderByDesc(Conversation::getUpdatedAt)); + } + + @Override + @Transactional + public Message saveMessage(Long conversationId, String role, String content, Long userId) { + Conversation conversation = requireOwnedConversation(conversationId, userId); + Message message = new Message(); + message.setConversationId(conversationId); + message.setRole(role); + message.setContent(content); + message.setCreatedAt(LocalDateTime.now()); + messageMapper.insert(message); + + conversation.setUpdatedAt(LocalDateTime.now()); + conversationMapper.updateById(conversation); + + return message; + } + + @Override + public List getMessages(Long conversationId, Long userId) { + requireOwnedConversation(conversationId, userId); + return messageMapper.selectList(new LambdaQueryWrapper() + .eq(Message::getConversationId, conversationId) + .orderByAsc(Message::getCreatedAt)); + } + + @Override + public List getRecentMessages(Long conversationId, Long userId, int limit) { + requireOwnedConversation(conversationId, userId); + if (limit <= 0) { + return List.of(); + } + List messages = messageMapper.selectList(new LambdaQueryWrapper() + .eq(Message::getConversationId, conversationId) + .orderByDesc(Message::getCreatedAt) + .last("LIMIT " + limit)); + List copy = new ArrayList<>(messages); + Collections.reverse(copy); + return copy; + } + + @Override + public Conversation getConversation(Long id, Long userId) { + return requireOwnedConversation(id, userId); + } + + @Override + @Transactional + public void updateConversationTitle(Long id, String title, Long userId) { + Conversation conversation = requireOwnedConversation(id, userId); + conversation.setTitle(title); + conversation.setUpdatedAt(LocalDateTime.now()); + conversationMapper.updateById(conversation); + } + + private Conversation requireOwnedConversation(Long conversationId, Long userId) { + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation == null) { + throw new BusinessException(40400, "会话不存在"); + } + if (!userId.equals(conversation.getUserId())) { + throw new BusinessException(40300, "无权限访问该会话"); + } + return conversation; + } +} diff --git a/src/main/java/com/hertz/modules/knowledge/controller/KnowledgeBaseController.java b/src/main/java/com/hertz/modules/knowledge/controller/KnowledgeBaseController.java new file mode 100644 index 0000000..26f51ac --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/controller/KnowledgeBaseController.java @@ -0,0 +1,65 @@ +package com.hertz.modules.knowledge.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.hertz.common.api.ApiResponse; +import com.hertz.modules.knowledge.entity.KnowledgeBase; +import com.hertz.modules.knowledge.entity.KnowledgeDocument; +import com.hertz.modules.knowledge.service.KnowledgeBaseService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api/knowledge-bases") +@RequiredArgsConstructor +public class KnowledgeBaseController { + + private final KnowledgeBaseService knowledgeBaseService; + + @PostMapping + public ApiResponse createKnowledgeBase(@RequestBody KnowledgeBase kb) { + return ApiResponse.ok(knowledgeBaseService.createKnowledgeBase(kb.getName(), kb.getDescription())); + } + + @GetMapping + public ApiResponse> page( + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(200) int size, + @RequestParam(required = false) String keyword + ) { + return ApiResponse.ok(knowledgeBaseService.pageKnowledgeBases(page, size, keyword)); + } + + @PostMapping("/{id}/upload") + public ApiResponse uploadDocument(@PathVariable Long id, @RequestParam("file") MultipartFile file) { + knowledgeBaseService.uploadDocument(id, file); + return ApiResponse.ok(); + } + + @GetMapping("/{id}/documents") + public ApiResponse> listDocuments(@PathVariable Long id) { + return ApiResponse.ok(knowledgeBaseService.listDocuments(id)); + } + + @DeleteMapping("/{id}/documents/{docId}") + public ApiResponse deleteDocument(@PathVariable Long id, @PathVariable Long docId) { + knowledgeBaseService.deleteDocument(id, docId); + return ApiResponse.ok(); + } + + @PostMapping("/{id}/rebuild") + public ApiResponse rebuild(@PathVariable Long id) { + knowledgeBaseService.rebuildVectorStore(id); + return ApiResponse.ok(); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteKnowledgeBase(@PathVariable Long id) { + knowledgeBaseService.deleteKnowledgeBase(id); + return ApiResponse.ok(); + } +} diff --git a/src/main/java/com/hertz/modules/knowledge/entity/KnowledgeBase.java b/src/main/java/com/hertz/modules/knowledge/entity/KnowledgeBase.java new file mode 100644 index 0000000..5fcb458 --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/entity/KnowledgeBase.java @@ -0,0 +1,18 @@ +package com.hertz.modules.knowledge.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("knowledge_base") +public class KnowledgeBase { + @TableId(type = IdType.AUTO) + private Long id; + private String name; + private String description; + private LocalDateTime createTime; + private Long createBy; +} diff --git a/src/main/java/com/hertz/modules/knowledge/entity/KnowledgeDocument.java b/src/main/java/com/hertz/modules/knowledge/entity/KnowledgeDocument.java new file mode 100644 index 0000000..0a428e8 --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/entity/KnowledgeDocument.java @@ -0,0 +1,28 @@ +package com.hertz.modules.knowledge.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("knowledge_document") +public class KnowledgeDocument { + @TableId(type = IdType.AUTO) + private Long id; + private Long kbId; + private String originalName; + private String storedName; + private String storedPath; + private String contentType; + private Long sizeBytes; + private String sha256; + private String status; + private Integer chunkCount; + private String errorMessage; + private LocalDateTime createTime; + private Integer deleted; + private LocalDateTime deletedTime; +} + diff --git a/src/main/java/com/hertz/modules/knowledge/mapper/KnowledgeBaseMapper.java b/src/main/java/com/hertz/modules/knowledge/mapper/KnowledgeBaseMapper.java new file mode 100644 index 0000000..ca6fa2c --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/mapper/KnowledgeBaseMapper.java @@ -0,0 +1,9 @@ +package com.hertz.modules.knowledge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.knowledge.entity.KnowledgeBase; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface KnowledgeBaseMapper extends BaseMapper { +} diff --git a/src/main/java/com/hertz/modules/knowledge/mapper/KnowledgeDocumentMapper.java b/src/main/java/com/hertz/modules/knowledge/mapper/KnowledgeDocumentMapper.java new file mode 100644 index 0000000..301556e --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/mapper/KnowledgeDocumentMapper.java @@ -0,0 +1,10 @@ +package com.hertz.modules.knowledge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.knowledge.entity.KnowledgeDocument; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface KnowledgeDocumentMapper extends BaseMapper { +} + diff --git a/src/main/java/com/hertz/modules/knowledge/service/KnowledgeBaseService.java b/src/main/java/com/hertz/modules/knowledge/service/KnowledgeBaseService.java new file mode 100644 index 0000000..de93c7b --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/service/KnowledgeBaseService.java @@ -0,0 +1,17 @@ +package com.hertz.modules.knowledge.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.hertz.modules.knowledge.entity.KnowledgeBase; +import com.hertz.modules.knowledge.entity.KnowledgeDocument; +import org.springframework.web.multipart.MultipartFile; +import java.util.List; + +public interface KnowledgeBaseService { + KnowledgeBase createKnowledgeBase(String name, String description); + IPage pageKnowledgeBases(int page, int size, String keyword); + void uploadDocument(Long kbId, MultipartFile file); + List listDocuments(Long kbId); + void deleteDocument(Long kbId, Long documentId); + void rebuildVectorStore(Long kbId); + void deleteKnowledgeBase(Long id); +} diff --git a/src/main/java/com/hertz/modules/knowledge/service/KnowledgeVectorStoreManager.java b/src/main/java/com/hertz/modules/knowledge/service/KnowledgeVectorStoreManager.java new file mode 100644 index 0000000..704cf39 --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/service/KnowledgeVectorStoreManager.java @@ -0,0 +1,84 @@ +package com.hertz.modules.knowledge.service; + +import com.hertz.config.AppPathResolver; +import java.io.File; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class KnowledgeVectorStoreManager { + + private final EmbeddingModel embeddingModel; + private final AppPathResolver pathResolver; + + @Value("${spring.ai.vectorstore.simple.store.path:vector-store.json}") + private String vectorStorePath; + + private final Map cache = new ConcurrentHashMap<>(); + private final Map locks = new ConcurrentHashMap<>(); + + public KnowledgeVectorStoreManager(EmbeddingModel embeddingModel, AppPathResolver pathResolver) { + this.embeddingModel = embeddingModel; + this.pathResolver = pathResolver; + } + + public SimpleVectorStore get(Long kbId) { + Object lock = locks.computeIfAbsent(kbId, k -> new Object()); + synchronized (lock) { + return cache.computeIfAbsent(kbId, id -> { + SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build(); + File file = resolveStoreFile(id).toFile(); + if (file.exists()) { + store.load(file); + } + return store; + }); + } + } + + public void save(Long kbId) { + Object lock = locks.computeIfAbsent(kbId, k -> new Object()); + synchronized (lock) { + SimpleVectorStore store = get(kbId); + store.save(resolveStoreFile(kbId).toFile()); + } + } + + public void replace(Long kbId, SimpleVectorStore newStore) { + Object lock = locks.computeIfAbsent(kbId, k -> new Object()); + synchronized (lock) { + cache.put(kbId, newStore); + newStore.save(resolveStoreFile(kbId).toFile()); + } + } + + public void delete(Long kbId) { + Object lock = locks.computeIfAbsent(kbId, k -> new Object()); + synchronized (lock) { + cache.remove(kbId); + File file = resolveStoreFile(kbId).toFile(); + if (file.exists()) { + file.delete(); + } + } + } + + private Path resolveStoreFile(Long kbId) { + File configured = new File(pathResolver.resolve(vectorStorePath)); + File baseDir; + if (vectorStorePath.endsWith(".json")) { + baseDir = configured.getParentFile() == null ? new File(".") : configured.getParentFile(); + } else { + baseDir = configured; + } + if (!baseDir.exists()) { + baseDir.mkdirs(); + } + return new File(baseDir, "vector-store-kb-" + kbId + ".json").toPath(); + } +} diff --git a/src/main/java/com/hertz/modules/knowledge/service/impl/KnowledgeBaseServiceImpl.java b/src/main/java/com/hertz/modules/knowledge/service/impl/KnowledgeBaseServiceImpl.java new file mode 100644 index 0000000..8ed2cee --- /dev/null +++ b/src/main/java/com/hertz/modules/knowledge/service/impl/KnowledgeBaseServiceImpl.java @@ -0,0 +1,278 @@ +package com.hertz.modules.knowledge.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.hertz.common.exception.BusinessException; +import com.hertz.config.AppPathResolver; +import com.hertz.config.AppProperties; +import com.hertz.modules.knowledge.entity.KnowledgeBase; +import com.hertz.modules.knowledge.entity.KnowledgeDocument; +import com.hertz.modules.knowledge.mapper.KnowledgeBaseMapper; +import com.hertz.modules.knowledge.mapper.KnowledgeDocumentMapper; +import com.hertz.modules.knowledge.service.KnowledgeBaseService; +import com.hertz.modules.knowledge.service.KnowledgeVectorStoreManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.reader.tika.TikaDocumentReader; +import org.springframework.ai.transformer.splitter.TokenTextSplitter; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.core.io.FileSystemResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.security.MessageDigest; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KnowledgeBaseServiceImpl implements KnowledgeBaseService { + + private final KnowledgeBaseMapper knowledgeBaseMapper; + private final KnowledgeDocumentMapper knowledgeDocumentMapper; + private final KnowledgeVectorStoreManager vectorStoreManager; + private final EmbeddingModel embeddingModel; + private final AppProperties appProperties; + private final AppPathResolver pathResolver; + + private static final Pattern OLLAMA_MODEL_NOT_FOUND = Pattern.compile("model\\\\s+\\\\\\\"([^\\\\\\\"]+)\\\\\\\"\\\\s+not\\\\s+found", Pattern.CASE_INSENSITIVE); + private static final String STATUS_PROCESSING = "PROCESSING"; + private static final String STATUS_READY = "READY"; + private static final String STATUS_FAILED = "FAILED"; + + @Override + public KnowledgeBase createKnowledgeBase(String name, String description) { + KnowledgeBase kb = new KnowledgeBase(); + kb.setName(name); + kb.setDescription(description); + kb.setCreateTime(LocalDateTime.now()); + kb.setCreateBy(com.hertz.security.SecurityUtils.getCurrentUserId()); + knowledgeBaseMapper.insert(kb); + return kb; + } + + @Override + public IPage pageKnowledgeBases(int page, int size, String keyword) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByDesc(KnowledgeBase::getCreateTime); + if (keyword != null && !keyword.isBlank()) { + wrapper.and(w -> w.like(KnowledgeBase::getName, keyword) + .or().like(KnowledgeBase::getDescription, keyword)); + } + return knowledgeBaseMapper.selectPage(Page.of(page, size), wrapper); + } + + @Override + public void uploadDocument(Long kbId, MultipartFile file) { + String originalName = file.getOriginalFilename(); + log.info("Uploading document to KB {}: {}", kbId, originalName); + + if (kbId == null || knowledgeBaseMapper.selectById(kbId) == null) { + throw new BusinessException(40400, "知识库不存在"); + } + if (file.isEmpty()) { + throw new BusinessException(40001, "文件不能为空"); + } + if (originalName == null || originalName.isBlank()) { + throw new BusinessException(40001, "文件名不能为空"); + } + + String suffix = ""; + int dot = originalName.lastIndexOf("."); + if (dot >= 0 && dot < originalName.length() - 1) { + suffix = originalName.substring(dot); + } + String storedName = UUID.randomUUID().toString().replace("-", "") + suffix; + String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM")); + String relativePath = normalizePath(appProperties.getUpload().getKnowledgePath()) + + "kb-" + kbId + "/" + datePath + "/" + storedName; + String fullPath = pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + relativePath; + File dest = new File(fullPath); + if (!dest.getParentFile().exists()) { + dest.getParentFile().mkdirs(); + } + + KnowledgeDocument docRecord = new KnowledgeDocument(); + docRecord.setKbId(kbId); + docRecord.setOriginalName(originalName); + docRecord.setStoredName(storedName); + docRecord.setStoredPath(relativePath); + docRecord.setContentType(file.getContentType()); + docRecord.setSizeBytes(file.getSize()); + docRecord.setStatus(STATUS_PROCESSING); + docRecord.setChunkCount(0); + docRecord.setCreateTime(LocalDateTime.now()); + knowledgeDocumentMapper.insert(docRecord); + + try { + file.transferTo(dest); + docRecord.setSha256(sha256Hex(dest)); + knowledgeDocumentMapper.updateById(docRecord); + + TikaDocumentReader reader = new TikaDocumentReader(new FileSystemResource(dest)); + List documents = reader.read(); + + for (Document doc : documents) { + doc.getMetadata().put("kbId", kbId); + doc.getMetadata().put("documentId", docRecord.getId()); + doc.getMetadata().put("filename", originalName); + } + + TokenTextSplitter splitter = new TokenTextSplitter(); + List splitDocuments = splitter.apply(documents); + + SimpleVectorStore store = vectorStoreManager.get(kbId); + try { + store.add(splitDocuments); + } catch (RuntimeException e) { + throw translateVectorizeException(e); + } + vectorStoreManager.save(kbId); + + log.info("Added {} chunks to vector store for KB {}", splitDocuments.size(), kbId); + docRecord.setStatus(STATUS_READY); + docRecord.setChunkCount(splitDocuments.size()); + docRecord.setErrorMessage(null); + knowledgeDocumentMapper.updateById(docRecord); + + } catch (Exception e) { + docRecord.setStatus(STATUS_FAILED); + docRecord.setErrorMessage(truncate(e.getMessage(), 1000)); + knowledgeDocumentMapper.updateById(docRecord); + throw e instanceof RuntimeException ? (RuntimeException) e : new RuntimeException(e); + } + } + + @Override + public List listDocuments(Long kbId) { + return knowledgeDocumentMapper.selectList(new LambdaQueryWrapper() + .eq(KnowledgeDocument::getKbId, kbId) + .orderByDesc(KnowledgeDocument::getCreateTime)); + } + + @Override + public void deleteDocument(Long kbId, Long documentId) { + KnowledgeDocument doc = knowledgeDocumentMapper.selectById(documentId); + if (doc == null || !kbId.equals(doc.getKbId())) { + throw new BusinessException(40400, "文档不存在"); + } + + File file = new File(pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + doc.getStoredPath()); + if (file.exists()) { + file.delete(); + } + knowledgeDocumentMapper.deleteById(documentId); + + rebuildVectorStore(kbId); + } + + @Override + public void rebuildVectorStore(Long kbId) { + List docs = knowledgeDocumentMapper.selectList(new LambdaQueryWrapper() + .eq(KnowledgeDocument::getKbId, kbId) + .eq(KnowledgeDocument::getStatus, STATUS_READY) + .orderByAsc(KnowledgeDocument::getId)); + + SimpleVectorStore newStore = SimpleVectorStore.builder(embeddingModel).build(); + TokenTextSplitter splitter = new TokenTextSplitter(); + + int totalChunks = 0; + for (KnowledgeDocument doc : docs) { + File file = new File(pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + doc.getStoredPath()); + if (!file.exists()) { + continue; + } + TikaDocumentReader reader = new TikaDocumentReader(new FileSystemResource(file)); + List documents = reader.read(); + for (Document d : documents) { + d.getMetadata().put("kbId", kbId); + d.getMetadata().put("documentId", doc.getId()); + d.getMetadata().put("filename", doc.getOriginalName()); + } + List split = splitter.apply(documents); + newStore.add(split); + totalChunks += split.size(); + doc.setChunkCount(split.size()); + knowledgeDocumentMapper.updateById(doc); + } + + vectorStoreManager.replace(kbId, newStore); + log.info("Rebuilt vector store for KB {} with {} documents and {} chunks", kbId, docs.size(), totalChunks); + } + + private RuntimeException translateVectorizeException(RuntimeException e) { + var message = e.getMessage(); + if (message == null) { + return e; + } + + if (message.contains("[404]") && message.toLowerCase().contains("not found") && message.toLowerCase().contains("try pulling it first")) { + String model = null; + Matcher matcher = OLLAMA_MODEL_NOT_FOUND.matcher(message); + if (matcher.find()) { + model = matcher.group(1); + } + if (model == null || model.isBlank()) { + model = "nomic-embed-text"; + } + return new BusinessException(50300, "Ollama 嵌入模型未安装: " + model + "。请先执行:ollama pull " + model); + } + return e; + } + + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + return ""; + } + return path.endsWith("/") || path.endsWith("\\") ? path.replace("\\", "/") : path.replace("\\", "/") + "/"; + } + + private String sha256Hex(File file) { + try (InputStream in = java.nio.file.Files.newInputStream(file.toPath())) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + digest.update(buffer, 0, read); + } + byte[] bytes = digest.digest(); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + return null; + } + } + + private String truncate(String s, int max) { + if (s == null) return null; + if (s.length() <= max) return s; + return s.substring(0, max); + } + + @Override + public void deleteKnowledgeBase(Long id) { + List docs = knowledgeDocumentMapper.selectList(new LambdaQueryWrapper() + .eq(KnowledgeDocument::getKbId, id)); + for (KnowledgeDocument doc : docs) { + File file = new File(pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + doc.getStoredPath()); + if (file.exists()) { + file.delete(); + } + knowledgeDocumentMapper.deleteById(doc.getId()); + } + vectorStoreManager.delete(id); + knowledgeBaseMapper.deleteById(id); + } +} diff --git a/src/main/java/com/hertz/modules/monitor/controller/MonitorController.java b/src/main/java/com/hertz/modules/monitor/controller/MonitorController.java new file mode 100644 index 0000000..28c0150 --- /dev/null +++ b/src/main/java/com/hertz/modules/monitor/controller/MonitorController.java @@ -0,0 +1,22 @@ +package com.hertz.modules.monitor.controller; + +import com.hertz.common.api.ApiResponse; +import com.hertz.modules.monitor.dto.MonitorDto; +import com.hertz.modules.monitor.service.MonitorService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/monitor") +@RequiredArgsConstructor +public class MonitorController { + + private final MonitorService monitorService; + + @GetMapping("/server") + public ApiResponse getServerInfo() { + return ApiResponse.ok(monitorService.getServerInfo()); + } +} diff --git a/src/main/java/com/hertz/modules/monitor/dto/MonitorDto.java b/src/main/java/com/hertz/modules/monitor/dto/MonitorDto.java new file mode 100644 index 0000000..cdd486b --- /dev/null +++ b/src/main/java/com/hertz/modules/monitor/dto/MonitorDto.java @@ -0,0 +1,89 @@ +package com.hertz.modules.monitor.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MonitorDto { + private CpuInfo cpu; + private MemInfo mem; + private JvmInfo jvm; + private SysInfo sys; + private List disks; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CpuInfo { + private int cpuNum; + private double total; + private double sys; + private double used; + private double wait; + private double free; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MemInfo { + private double total; + private double used; + private double free; + private double usage; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class JvmInfo { + private double total; + private double max; + private double free; + private String version; + private String home; + private double usage; + private String startTime; + private String runTime; + private String inputArgs; + private String name; + private long gcCount; + private long gcTime; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SysInfo { + private String computerName; + private String computerIp; + private String userDir; + private String osName; + private String osArch; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DiskInfo { + private String dirName; + private String sysTypeName; + private String typeName; + private String total; + private String free; + private String used; + private double usage; + } +} diff --git a/src/main/java/com/hertz/modules/monitor/service/MonitorService.java b/src/main/java/com/hertz/modules/monitor/service/MonitorService.java new file mode 100644 index 0000000..1ac09b0 --- /dev/null +++ b/src/main/java/com/hertz/modules/monitor/service/MonitorService.java @@ -0,0 +1,7 @@ +package com.hertz.modules.monitor.service; + +import com.hertz.modules.monitor.dto.MonitorDto; + +public interface MonitorService { + MonitorDto getServerInfo(); +} diff --git a/src/main/java/com/hertz/modules/monitor/service/impl/MonitorServiceImpl.java b/src/main/java/com/hertz/modules/monitor/service/impl/MonitorServiceImpl.java new file mode 100644 index 0000000..9fb714d --- /dev/null +++ b/src/main/java/com/hertz/modules/monitor/service/impl/MonitorServiceImpl.java @@ -0,0 +1,165 @@ +package com.hertz.modules.monitor.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.net.NetUtil; +import cn.hutool.core.util.NumberUtil; +import com.hertz.modules.monitor.dto.MonitorDto; +import com.hertz.modules.monitor.service.MonitorService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import oshi.SystemInfo; +import oshi.hardware.CentralProcessor; +import oshi.hardware.CentralProcessor.TickType; +import oshi.hardware.GlobalMemory; +import oshi.hardware.HardwareAbstractionLayer; +import oshi.software.os.FileSystem; +import oshi.software.os.OSFileStore; +import oshi.software.os.OperatingSystem; +import oshi.util.Util; + +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.RuntimeMXBean; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Properties; + +@Slf4j +@Service +public class MonitorServiceImpl implements MonitorService { + + private final SystemInfo si = new SystemInfo(); + + @Override + public MonitorDto getServerInfo() { + HardwareAbstractionLayer hal = si.getHardware(); + OperatingSystem os = si.getOperatingSystem(); + + return MonitorDto.builder() + .cpu(getCpuInfo(hal.getProcessor())) + .mem(getMemInfo(hal.getMemory())) + .sys(getSysInfo()) + .jvm(getJvmInfo()) + .disks(getDiskInfo(os)) + .build(); + } + + private MonitorDto.CpuInfo getCpuInfo(CentralProcessor processor) { + long[] prevTicks = processor.getSystemCpuLoadTicks(); + Util.sleep(1000); + long[] ticks = processor.getSystemCpuLoadTicks(); + long nice = ticks[TickType.NICE.getIndex()] - prevTicks[TickType.NICE.getIndex()]; + long irq = ticks[TickType.IRQ.getIndex()] - prevTicks[TickType.IRQ.getIndex()]; + long softirq = ticks[TickType.SOFTIRQ.getIndex()] - prevTicks[TickType.SOFTIRQ.getIndex()]; + long steal = ticks[TickType.STEAL.getIndex()] - prevTicks[TickType.STEAL.getIndex()]; + long cSys = ticks[TickType.SYSTEM.getIndex()] - prevTicks[TickType.SYSTEM.getIndex()]; + long user = ticks[TickType.USER.getIndex()] - prevTicks[TickType.USER.getIndex()]; + long iowait = ticks[TickType.IOWAIT.getIndex()] - prevTicks[TickType.IOWAIT.getIndex()]; + long idle = ticks[TickType.IDLE.getIndex()] - prevTicks[TickType.IDLE.getIndex()]; + long totalCpu = user + nice + cSys + idle + iowait + irq + softirq + steal; + + // Prevent division by zero + if (totalCpu == 0) totalCpu = 1; + + return MonitorDto.CpuInfo.builder() + .cpuNum(processor.getLogicalProcessorCount()) + .total(totalCpu) + .sys(NumberUtil.round(cSys * 100.0 / totalCpu, 2).doubleValue()) + .used(NumberUtil.round(user * 100.0 / totalCpu, 2).doubleValue()) + .wait(NumberUtil.round(iowait * 100.0 / totalCpu, 2).doubleValue()) + .free(NumberUtil.round(idle * 100.0 / totalCpu, 2).doubleValue()) + .build(); + } + + private MonitorDto.MemInfo getMemInfo(GlobalMemory memory) { + double total = NumberUtil.div(memory.getTotal(), (1024 * 1024 * 1024), 2); + double free = NumberUtil.div(memory.getAvailable(), (1024 * 1024 * 1024), 2); + double used = NumberUtil.sub(total, free); + double usage = NumberUtil.mul(NumberUtil.div(used, total, 4), 100); + + return MonitorDto.MemInfo.builder() + .total(total) + .used(used) + .free(free) + .usage(usage) + .build(); + } + + private MonitorDto.SysInfo getSysInfo() { + Properties props = System.getProperties(); + return MonitorDto.SysInfo.builder() + .computerName(NetUtil.getLocalHostName()) + .computerIp(NetUtil.getLocalhostStr()) + .osName(props.getProperty("os.name")) + .osArch(props.getProperty("os.arch")) + .userDir(props.getProperty("user.dir")) + .build(); + } + + private MonitorDto.JvmInfo getJvmInfo() { + Properties props = System.getProperties(); + RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean(); + long time = runtime.getStartTime(); + + // GC Info + long gcCount = 0; + long gcTime = 0; + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + for (GarbageCollectorMXBean gcBean : gcBeans) { + long count = gcBean.getCollectionCount(); + long t = gcBean.getCollectionTime(); + if (count > 0) gcCount += count; + if (t > 0) gcTime += t; + } + + double total = NumberUtil.div(Runtime.getRuntime().totalMemory(), (1024 * 1024), 2); + double max = NumberUtil.div(Runtime.getRuntime().maxMemory(), (1024 * 1024), 2); + double free = NumberUtil.div(Runtime.getRuntime().freeMemory(), (1024 * 1024), 2); + double used = total - free; + double usage = NumberUtil.mul(NumberUtil.div(used, total, 4), 100); + + return MonitorDto.JvmInfo.builder() + .total(total) + .max(max) + .free(free) + .version(props.getProperty("java.version")) + .home(props.getProperty("java.home")) + .name(ManagementFactory.getRuntimeMXBean().getVmName()) + .usage(usage) + .startTime(DateUtil.formatDateTime(new Date(time))) + .runTime(DateUtil.formatBetween(new Date(time), new Date())) + .inputArgs(runtime.getInputArguments().toString()) + .gcCount(gcCount) + .gcTime(gcTime) + .build(); + } + + private List getDiskInfo(OperatingSystem os) { + List list = new ArrayList<>(); + FileSystem fileSystem = os.getFileSystem(); + List fileStores = fileSystem.getFileStores(); + for (OSFileStore fs : fileStores) { + long free = fs.getUsableSpace(); + long total = fs.getTotalSpace(); + long used = total - free; + + if (total == 0) continue; + + double usage = NumberUtil.mul(NumberUtil.div(used, total, 4), 100); + + MonitorDto.DiskInfo disk = MonitorDto.DiskInfo.builder() + .dirName(fs.getMount()) + .sysTypeName(fs.getType()) + .typeName(fs.getName()) + .total(FileUtil.readableFileSize(total)) + .free(FileUtil.readableFileSize(free)) + .used(FileUtil.readableFileSize(used)) + .usage(usage) + .build(); + list.add(disk); + } + return list; + } +} diff --git a/src/main/java/com/hertz/modules/system/controller/AuthController.java b/src/main/java/com/hertz/modules/system/controller/AuthController.java new file mode 100644 index 0000000..132c70d --- /dev/null +++ b/src/main/java/com/hertz/modules/system/controller/AuthController.java @@ -0,0 +1,115 @@ +package com.hertz.modules.system.controller; + +import com.hertz.common.api.ApiResponse; +import com.hertz.common.exception.BusinessException; +import com.hertz.security.JwtService; +import com.hertz.security.SecurityUtils; +import com.hertz.modules.system.dto.AuthDtos; +import com.hertz.modules.system.mapper.SysRoleMapper; +import com.hertz.modules.system.service.CaptchaService; +import com.hertz.modules.system.service.UserService; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.GetMapping; +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("/api/auth") +public class AuthController { + private final UserService userService; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final SysRoleMapper roleMapper; + private final CaptchaService captchaService; + + public AuthController( + UserService userService, + PasswordEncoder passwordEncoder, + JwtService jwtService, + SysRoleMapper roleMapper, + CaptchaService captchaService + ) { + this.userService = userService; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + this.roleMapper = roleMapper; + this.captchaService = captchaService; + } + + @PostMapping("/register") + public ApiResponse register(@Valid @RequestBody AuthDtos.RegisterRequest req) { + captchaService.validateCaptcha(req.uuid(), req.code()); + var user = userService.register(req.username(), req.password(), req.nickname()); + return ApiResponse.ok(new AuthDtos.MeResponse( + user.getId(), + user.getUsername(), + user.getNickname(), + user.getAvatarPath(), + user.getPhone(), + user.getEmail(), + user.getGender(), + List.of() + )); + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody AuthDtos.LoginRequest req) { + captchaService.validateCaptcha(req.uuid(), req.code()); + var user = userService.findByUsername(req.username()); + if (user == null || user.getStatus() == null || user.getStatus() != 1) { + throw new BusinessException(40003, "用户名或密码错误"); + } + if (!passwordEncoder.matches(req.password(), user.getPassword())) { + throw new BusinessException(40003, "用户名或密码错误"); + } + var token = jwtService.createToken(user.getId(), user.getUsername()); + var roles = roleMapper.selectRoleKeysByUserId(user.getId()); + return ApiResponse.ok(new AuthDtos.LoginResponse(token, user.getId(), user.getUsername(), roles)); + } + + @GetMapping("/me") + public ApiResponse me(Authentication authentication) { + var userId = SecurityUtils.getCurrentUserId(authentication); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + var user = userService.findByUsername(authentication.getName()); + var roles = roleMapper.selectRoleKeysByUserId(userId); + return ApiResponse.ok(new AuthDtos.MeResponse( + userId, + authentication.getName(), + user == null ? null : user.getNickname(), + user == null ? null : user.getAvatarPath(), + user == null ? null : user.getPhone(), + user == null ? null : user.getEmail(), + user == null ? null : user.getGender(), + roles + )); + } + + @PostMapping("/profile") + public ApiResponse updateProfile(@RequestBody @Valid AuthDtos.UpdateProfileRequest req) { + var userId = SecurityUtils.getCurrentUserId(); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + userService.updateProfile(userId, req); + return ApiResponse.ok(); + } + + @PostMapping("/password") + public ApiResponse updatePassword(@RequestBody @Valid AuthDtos.UpdatePasswordRequest req) { + var userId = SecurityUtils.getCurrentUserId(); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + userService.updatePassword(userId, req); + return ApiResponse.ok(); + } +} + diff --git a/src/main/java/com/hertz/modules/system/controller/CaptchaController.java b/src/main/java/com/hertz/modules/system/controller/CaptchaController.java new file mode 100644 index 0000000..ee4f8b1 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/controller/CaptchaController.java @@ -0,0 +1,25 @@ +package com.hertz.modules.system.controller; + +import com.hertz.common.api.ApiResponse; +import com.hertz.modules.system.dto.AuthDtos; +import com.hertz.modules.system.service.CaptchaService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth/captcha") +public class CaptchaController { + + private final CaptchaService captchaService; + + public CaptchaController(CaptchaService captchaService) { + this.captchaService = captchaService; + } + + @GetMapping + public ApiResponse getCaptcha() { + var res = captchaService.generateCaptcha(); + return ApiResponse.ok(res); + } +} diff --git a/src/main/java/com/hertz/modules/system/controller/FileController.java b/src/main/java/com/hertz/modules/system/controller/FileController.java new file mode 100644 index 0000000..569f2a8 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/controller/FileController.java @@ -0,0 +1,69 @@ +package com.hertz.modules.system.controller; + +import com.hertz.common.api.ApiResponse; +import com.hertz.common.exception.BusinessException; +import com.hertz.config.AppPathResolver; +import com.hertz.config.AppProperties; +import java.io.File; +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/common/file") +@RequiredArgsConstructor +public class FileController { + + private final AppProperties appProperties; + private final AppPathResolver pathResolver; + + @PostMapping("/upload") + public ApiResponse upload(@RequestParam("file") MultipartFile file) { + if (file.isEmpty()) { + throw new BusinessException(400, "File cannot be empty"); + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new BusinessException(400, "Original filename is required"); + } + String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); + if (!suffix.equalsIgnoreCase(".jpg") && !suffix.equalsIgnoreCase(".png") && !suffix.equalsIgnoreCase(".jpeg")) { + throw new BusinessException(400, "Only JPG/PNG formats are supported"); + } + + if (file.getSize() > 2 * 1024 * 1024) { + throw new BusinessException(400, "File size exceeds 2MB"); + } + + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM")); + String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; + String relativePath = appProperties.getUpload().getAvatarPath() + datePath + "/" + fileName; + // Construct full path + String fullPath = pathResolver.resolve(appProperties.getUpload().getRootPath()) + File.separator + relativePath; + + File dest = new File(fullPath); + if (!dest.getParentFile().exists()) { + dest.getParentFile().mkdirs(); + } + + try { + file.transferTo(dest); + + // Return relative path. Frontend should prepend the base URL. + return ApiResponse.ok(new UploadResult("/uploads/" + relativePath, null)); + } catch (IOException e) { + throw new BusinessException(500, "File upload failed: " + e.getMessage()); + } + } + + public record UploadResult(String url, String thumbUrl) { + } +} diff --git a/src/main/java/com/hertz/modules/system/controller/MenuController.java b/src/main/java/com/hertz/modules/system/controller/MenuController.java new file mode 100644 index 0000000..491f703 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/controller/MenuController.java @@ -0,0 +1,72 @@ +package com.hertz.modules.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.hertz.common.api.ApiResponse; +import com.hertz.common.exception.BusinessException; +import com.hertz.security.SecurityUtils; +import com.hertz.modules.system.dto.MenuDto; +import com.hertz.modules.system.entity.SysMenu; +import com.hertz.modules.system.service.MenuService; +import java.util.List; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/system/menus") +public class MenuController { + private final MenuService menuService; + + public MenuController(MenuService menuService) { + this.menuService = menuService; + } + + @GetMapping("/tree") + public ApiResponse> tree(Authentication authentication) { + var userId = SecurityUtils.getCurrentUserId(authentication); + if (userId == null) { + throw new BusinessException(40100, "未登录或登录已过期"); + } + return ApiResponse.ok(menuService.getMenuTreeByUserId(userId)); + } + + @GetMapping("/page") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:view')") + public ApiResponse> page( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String keyword + ) { + return ApiResponse.ok(menuService.pageMenus(page, size, keyword)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:add')") + public ApiResponse create(@RequestBody SysMenu menu) { + menuService.saveMenu(menu); + return ApiResponse.ok(); + } + + @PutMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:edit')") + public ApiResponse update(@RequestBody SysMenu menu) { + menuService.updateMenu(menu); + return ApiResponse.ok(); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:remove')") + public ApiResponse delete(@PathVariable("id") Long id) { + menuService.deleteMenu(id); + return ApiResponse.ok(); + } +} + diff --git a/src/main/java/com/hertz/modules/system/controller/RoleController.java b/src/main/java/com/hertz/modules/system/controller/RoleController.java new file mode 100644 index 0000000..a22d9e8 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/controller/RoleController.java @@ -0,0 +1,81 @@ +package com.hertz.modules.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.hertz.common.api.ApiResponse; +import com.hertz.modules.system.entity.SysRole; +import com.hertz.modules.system.service.RoleService; +import java.util.List; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/system/roles") +public class RoleController { + private final RoleService roleService; + + public RoleController(RoleService roleService) { + this.roleService = roleService; + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')") + public ApiResponse> list() { + return ApiResponse.ok(roleService.listEnabledRoles()); + } + + @GetMapping("/page") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')") + public ApiResponse> page( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String keyword + ) { + return ApiResponse.ok(roleService.pageRoles(page, size, keyword)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:add')") + public ApiResponse create(@RequestBody SysRole role) { + roleService.saveRole(role); + return ApiResponse.ok(); + } + + @PutMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:edit')") + public ApiResponse update(@RequestBody SysRole role) { + roleService.updateRole(role); + return ApiResponse.ok(); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:remove')") + public ApiResponse delete(@PathVariable("id") Long id) { + roleService.deleteRole(id); + return ApiResponse.ok(); + } + + @GetMapping("/{id}/menus") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')") + public ApiResponse> getRoleMenus(@PathVariable("id") Long id) { + return ApiResponse.ok(roleService.getRoleMenuIds(id)); + } + + public record UpdateRoleMenusRequest(List menuIds) { + } + + @PutMapping("/{id}/menus") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:assign')") + public ApiResponse updateRoleMenus(@PathVariable("id") Long id, @RequestBody UpdateRoleMenusRequest req) { + roleService.updateRolePermissions(id, req.menuIds()); + return ApiResponse.ok(); + } +} + diff --git a/src/main/java/com/hertz/modules/system/controller/UserController.java b/src/main/java/com/hertz/modules/system/controller/UserController.java new file mode 100644 index 0000000..b6dd0f5 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/controller/UserController.java @@ -0,0 +1,161 @@ +package com.hertz.modules.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.hertz.common.api.ApiResponse; +import com.hertz.modules.system.entity.SysRole; +import com.hertz.modules.system.entity.SysUser; +import com.hertz.modules.system.service.UserService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/system/users") +public class UserController { + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')") + public ApiResponse> page( + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(200) int size, + @RequestParam(required = false) String keyword + ) { + var p = userService.pageUsers(page, size, keyword); + var dtoPage = Page.of(p.getCurrent(), p.getSize(), p.getTotal()); + dtoPage.setRecords(p.getRecords().stream().map(u -> { + var roles = userService.getUserRoles(u.getId()).stream() + .map(SysRole::getRoleName) + .toList(); + return new UserListItem( + u.getId(), + u.getUsername(), + u.getNickname(), + u.getAvatarPath(), + u.getPhone(), + u.getEmail(), + u.getGender(), + u.getStatus(), + u.getCreatedAt(), + roles + ); + }).toList()); + return ApiResponse.ok(dtoPage); + } + + public record UserListItem( + Long id, + String username, + String nickname, + String avatarPath, + String phone, + String email, + Integer gender, + Integer status, + LocalDateTime createdAt, + List roles + ) { + } + + public record CreateUserRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "密码不能为空") String password, + @NotBlank(message = "昵称不能为空") String nickname, + String avatarPath, + String phone, + String email, + Integer gender, + Integer status + ) {} + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:add')") + public ApiResponse create(@RequestBody @Valid CreateUserRequest req) { + var u = new SysUser(); + u.setUsername(req.username()); + u.setPassword(req.password()); + u.setNickname(req.nickname()); + u.setAvatarPath(req.avatarPath()); + u.setPhone(req.phone()); + u.setEmail(req.email()); + u.setGender(req.gender()); + u.setStatus(req.status()); + userService.createUser(u); + return ApiResponse.ok(); + } + + public record UpdateUserRequest( + Long id, + String password, + @NotBlank(message = "昵称不能为空") String nickname, + String avatarPath, + String phone, + String email, + Integer gender, + Integer status + ) {} + + @PutMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:edit')") + public ApiResponse update(@RequestBody @Valid UpdateUserRequest req) { + var u = new SysUser(); + u.setId(req.id()); + u.setPassword(req.password()); + u.setNickname(req.nickname()); + u.setAvatarPath(req.avatarPath()); + u.setPhone(req.phone()); + u.setEmail(req.email()); + u.setGender(req.gender()); + u.setStatus(req.status()); + userService.updateUser(u); + return ApiResponse.ok(); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:remove')") + public ApiResponse delete(@PathVariable Long id) { + userService.deleteUser(id); + return ApiResponse.ok(); + } + + @DeleteMapping + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:remove')") + public ApiResponse deleteBatch(@RequestBody List ids) { + userService.deleteUsers(ids); + return ApiResponse.ok(); + } + + @GetMapping("/{id}/roles") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')") + public ApiResponse> roleIds(@PathVariable("id") long userId) { + return ApiResponse.ok(userService.getUserRoleIds(userId)); + } + + public record UpdateRolesRequest(List roleIds) { + } + + @PutMapping("/{id}/roles") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:assign')") + public ApiResponse updateRoles(@PathVariable("id") long userId, @RequestBody UpdateRolesRequest req) { + userService.updateUserRoles(userId, req == null ? List.of() : req.roleIds()); + return ApiResponse.ok(); + } +} diff --git a/src/main/java/com/hertz/modules/system/dto/AuthDtos.java b/src/main/java/com/hertz/modules/system/dto/AuthDtos.java new file mode 100644 index 0000000..67ddfb4 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/dto/AuthDtos.java @@ -0,0 +1,65 @@ +package com.hertz.modules.system.dto; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +public class AuthDtos { + public record LoginRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "密码不能为空") String password, + String uuid, + String code + ) { + } + + public record RegisterRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "密码不能为空") String password, + @NotBlank(message = "昵称不能为空") String nickname, + String uuid, + String code + ) { + } + + public record LoginResponse( + String token, + long userId, + String username, + List roles + ) { + } + + public record MeResponse( + long userId, + String username, + String nickname, + String avatarPath, + String phone, + String email, + Integer gender, + List roles + ) { + } + + public record UpdateProfileRequest( + @NotBlank(message = "昵称不能为空") String nickname, + String avatarPath, + String phone, + String email, + Integer gender + ) { + } + + public record UpdatePasswordRequest( + @NotBlank(message = "旧密码不能为空") String oldPassword, + @NotBlank(message = "新密码不能为空") String newPassword + ) { + } + + public record CaptchaResponse( + @com.fasterxml.jackson.annotation.JsonProperty("uuid") String uuid, + @com.fasterxml.jackson.annotation.JsonProperty("img") String img + ) { + } +} + diff --git a/src/main/java/com/hertz/modules/system/dto/MenuDto.java b/src/main/java/com/hertz/modules/system/dto/MenuDto.java new file mode 100644 index 0000000..4d09fd0 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/dto/MenuDto.java @@ -0,0 +1,21 @@ +package com.hertz.modules.system.dto; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +@Data +public class MenuDto { + private Long id; + private Long parentId; + private String type; + private String name; + private String path; + private String component; + private String perms; + private String icon; + private Integer sort; + private Integer status; + private List children = new ArrayList<>(); +} + diff --git a/src/main/java/com/hertz/modules/system/entity/SysMenu.java b/src/main/java/com/hertz/modules/system/entity/SysMenu.java new file mode 100644 index 0000000..588c9e2 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/entity/SysMenu.java @@ -0,0 +1,27 @@ +package com.hertz.modules.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("sys_menu") +public class SysMenu { + @TableId(type = IdType.AUTO) + private Long id; + private Long parentId; + private String type; + private String name; + private String path; + private String component; + private String perms; + private String icon; + private Integer sort; + private Integer visible; + private Integer status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/hertz/modules/system/entity/SysRole.java b/src/main/java/com/hertz/modules/system/entity/SysRole.java new file mode 100644 index 0000000..cd74fd3 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/entity/SysRole.java @@ -0,0 +1,20 @@ +package com.hertz.modules.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("sys_role") +public class SysRole { + @TableId(type = IdType.AUTO) + private Long id; + private String roleKey; + private String roleName; + private Integer status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/hertz/modules/system/entity/SysRoleMenu.java b/src/main/java/com/hertz/modules/system/entity/SysRoleMenu.java new file mode 100644 index 0000000..d36a4a8 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/entity/SysRoleMenu.java @@ -0,0 +1,16 @@ +package com.hertz.modules.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +@Data +@TableName("sys_role_menu") +public class SysRoleMenu { + @TableId(type = IdType.AUTO) + private Long id; + private Long roleId; + private Long menuId; +} + diff --git a/src/main/java/com/hertz/modules/system/entity/SysUser.java b/src/main/java/com/hertz/modules/system/entity/SysUser.java new file mode 100644 index 0000000..e1f1574 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/entity/SysUser.java @@ -0,0 +1,28 @@ +package com.hertz.modules.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("sys_user") +public class SysUser { + @TableId(type = IdType.AUTO) + private Long id; + private String username; + private String password; + private String nickname; + private String avatarPath; + private String phone; + private String email; + /** + * 0-未知 1-男 2-女 + */ + private Integer gender; + private Integer status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/hertz/modules/system/entity/SysUserRole.java b/src/main/java/com/hertz/modules/system/entity/SysUserRole.java new file mode 100644 index 0000000..fcc3c80 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/entity/SysUserRole.java @@ -0,0 +1,16 @@ +package com.hertz.modules.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +@Data +@TableName("sys_user_role") +public class SysUserRole { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long roleId; +} + diff --git a/src/main/java/com/hertz/modules/system/mapper/SysMenuMapper.java b/src/main/java/com/hertz/modules/system/mapper/SysMenuMapper.java new file mode 100644 index 0000000..acd84a4 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/mapper/SysMenuMapper.java @@ -0,0 +1,56 @@ +package com.hertz.modules.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.system.entity.SysMenu; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface SysMenuMapper extends BaseMapper { + @Select(""" + SELECT DISTINCT m.* + FROM sys_menu m + INNER JOIN sys_role_menu rm ON rm.menu_id = m.id + INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id + WHERE ur.user_id = #{userId} + AND m.status = 1 + AND m.visible = 1 + AND m.type IN ('D','M','B') + ORDER BY m.sort ASC, m.id ASC + """) + List selectMenusByUserId(@Param("userId") long userId); + + @Select(""" + SELECT m.* + FROM sys_menu m + WHERE m.status = 1 + AND m.visible = 1 + AND m.type IN ('D','M','B') + ORDER BY m.sort ASC, m.id ASC + """) + List selectAllVisibleMenus(); + + @Select(""" + SELECT DISTINCT m.perms + FROM sys_menu m + INNER JOIN sys_role_menu rm ON rm.menu_id = m.id + INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id + WHERE ur.user_id = #{userId} + AND m.status = 1 + AND m.perms IS NOT NULL + AND m.perms <> '' + """) + List selectPermsByUserId(@Param("userId") long userId); + + @Select(""" + SELECT DISTINCT m.perms + FROM sys_menu m + WHERE m.status = 1 + AND m.perms IS NOT NULL + AND m.perms <> '' + """) + List selectAllPerms(); +} + diff --git a/src/main/java/com/hertz/modules/system/mapper/SysRoleMapper.java b/src/main/java/com/hertz/modules/system/mapper/SysRoleMapper.java new file mode 100644 index 0000000..c4b0406 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/mapper/SysRoleMapper.java @@ -0,0 +1,29 @@ +package com.hertz.modules.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.system.entity.SysRole; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface SysRoleMapper extends BaseMapper { + @Select(""" + SELECT r.role_key + FROM sys_role r + INNER JOIN sys_user_role ur ON ur.role_id = r.id + WHERE ur.user_id = #{userId} AND r.status = 1 + """) + List selectRoleKeysByUserId(@Param("userId") long userId); + + @Select(""" + SELECT r.* + FROM sys_role r + INNER JOIN sys_user_role ur ON ur.role_id = r.id + WHERE ur.user_id = #{userId} AND r.status = 1 + ORDER BY r.id ASC + """) + List selectRolesByUserId(@Param("userId") long userId); +} + diff --git a/src/main/java/com/hertz/modules/system/mapper/SysRoleMenuMapper.java b/src/main/java/com/hertz/modules/system/mapper/SysRoleMenuMapper.java new file mode 100644 index 0000000..1b2afdb --- /dev/null +++ b/src/main/java/com/hertz/modules/system/mapper/SysRoleMenuMapper.java @@ -0,0 +1,9 @@ +package com.hertz.modules.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.system.entity.SysRoleMenu; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysRoleMenuMapper extends BaseMapper { +} diff --git a/src/main/java/com/hertz/modules/system/mapper/SysUserMapper.java b/src/main/java/com/hertz/modules/system/mapper/SysUserMapper.java new file mode 100644 index 0000000..9147c82 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/mapper/SysUserMapper.java @@ -0,0 +1,10 @@ +package com.hertz.modules.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.system.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysUserMapper extends BaseMapper { +} + diff --git a/src/main/java/com/hertz/modules/system/mapper/SysUserRoleMapper.java b/src/main/java/com/hertz/modules/system/mapper/SysUserRoleMapper.java new file mode 100644 index 0000000..f0ac2d3 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/mapper/SysUserRoleMapper.java @@ -0,0 +1,10 @@ +package com.hertz.modules.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.hertz.modules.system.entity.SysUserRole; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysUserRoleMapper extends BaseMapper { +} + diff --git a/src/main/java/com/hertz/modules/system/service/AuthzService.java b/src/main/java/com/hertz/modules/system/service/AuthzService.java new file mode 100644 index 0000000..53ca018 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/AuthzService.java @@ -0,0 +1,8 @@ +package com.hertz.modules.system.service; + +import java.util.Set; + +public interface AuthzService { + Set loadAuthorities(long userId); +} + diff --git a/src/main/java/com/hertz/modules/system/service/CaptchaService.java b/src/main/java/com/hertz/modules/system/service/CaptchaService.java new file mode 100644 index 0000000..1179e87 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/CaptchaService.java @@ -0,0 +1,8 @@ +package com.hertz.modules.system.service; + +import com.hertz.modules.system.dto.AuthDtos; + +public interface CaptchaService { + AuthDtos.CaptchaResponse generateCaptcha(); + void validateCaptcha(String uuid, String code); +} diff --git a/src/main/java/com/hertz/modules/system/service/MenuService.java b/src/main/java/com/hertz/modules/system/service/MenuService.java new file mode 100644 index 0000000..fea8e68 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/MenuService.java @@ -0,0 +1,19 @@ +package com.hertz.modules.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.hertz.modules.system.dto.MenuDto; +import com.hertz.modules.system.entity.SysMenu; +import java.util.List; + +public interface MenuService { + List getMenuTreeByUserId(long userId); + + IPage pageMenus(int page, int size, String keyword); + + void saveMenu(SysMenu menu); + + void updateMenu(SysMenu menu); + + void deleteMenu(Long id); +} + diff --git a/src/main/java/com/hertz/modules/system/service/RoleService.java b/src/main/java/com/hertz/modules/system/service/RoleService.java new file mode 100644 index 0000000..c27168e --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/RoleService.java @@ -0,0 +1,22 @@ +package com.hertz.modules.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.hertz.modules.system.entity.SysRole; +import java.util.List; + +public interface RoleService { + List listEnabledRoles(); + + IPage pageRoles(int page, int size, String keyword); + + void saveRole(SysRole role); + + void updateRole(SysRole role); + + void deleteRole(Long id); + + void updateRolePermissions(Long roleId, List menuIds); + + List getRoleMenuIds(Long roleId); +} + diff --git a/src/main/java/com/hertz/modules/system/service/UserService.java b/src/main/java/com/hertz/modules/system/service/UserService.java new file mode 100644 index 0000000..ceb3ead --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/UserService.java @@ -0,0 +1,34 @@ +package com.hertz.modules.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.hertz.modules.system.entity.SysRole; +import com.hertz.modules.system.dto.AuthDtos; +import com.hertz.modules.system.entity.SysUser; +import java.util.List; + +public interface UserService { + SysUser register(String username, String rawPassword, String nickname); + + SysUser findByUsername(String username); + + IPage pageUsers(int page, int size, String keyword); + + void createUser(SysUser user); + + void updateUser(SysUser user); + + void updateProfile(Long userId, AuthDtos.UpdateProfileRequest req); + + void updatePassword(Long userId, AuthDtos.UpdatePasswordRequest req); + + void deleteUser(Long id); + + void deleteUsers(List ids); + + void updateUserRoles(long userId, List roleIds); + + List getUserRoleIds(long userId); + + List getUserRoles(long userId); +} + diff --git a/src/main/java/com/hertz/modules/system/service/impl/AuthzServiceImpl.java b/src/main/java/com/hertz/modules/system/service/impl/AuthzServiceImpl.java new file mode 100644 index 0000000..aac168f --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/impl/AuthzServiceImpl.java @@ -0,0 +1,36 @@ +package com.hertz.modules.system.service.impl; + +import com.hertz.modules.system.mapper.SysMenuMapper; +import com.hertz.modules.system.mapper.SysRoleMapper; +import com.hertz.modules.system.service.AuthzService; +import java.util.HashSet; +import org.springframework.stereotype.Service; + +@Service +public class AuthzServiceImpl implements AuthzService { + private final SysRoleMapper roleMapper; + private final SysMenuMapper menuMapper; + + public AuthzServiceImpl(SysRoleMapper roleMapper, SysMenuMapper menuMapper) { + this.roleMapper = roleMapper; + this.menuMapper = menuMapper; + } + + @Override + public HashSet loadAuthorities(long userId) { + var roleKeys = roleMapper.selectRoleKeysByUserId(userId); + var authorities = new HashSet(); + for (var roleKey : roleKeys) { + if (roleKey != null && !roleKey.isBlank()) { + authorities.add("ROLE_" + roleKey); + } + } + if (roleKeys.stream().anyMatch(r -> "ADMIN".equalsIgnoreCase(r))) { + authorities.addAll(menuMapper.selectAllPerms()); + } else { + authorities.addAll(menuMapper.selectPermsByUserId(userId)); + } + return authorities; + } +} + diff --git a/src/main/java/com/hertz/modules/system/service/impl/CaptchaServiceImpl.java b/src/main/java/com/hertz/modules/system/service/impl/CaptchaServiceImpl.java new file mode 100644 index 0000000..ed75558 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/impl/CaptchaServiceImpl.java @@ -0,0 +1,55 @@ +package com.hertz.modules.system.service.impl; + +import cn.hutool.captcha.CaptchaUtil; +import cn.hutool.captcha.LineCaptcha; +import cn.hutool.core.lang.UUID; +import com.hertz.common.exception.BusinessException; +import com.hertz.modules.system.dto.AuthDtos; +import com.hertz.modules.system.service.CaptchaService; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +public class CaptchaServiceImpl implements CaptchaService { + + private final StringRedisTemplate redisTemplate; + + public CaptchaServiceImpl(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public AuthDtos.CaptchaResponse generateCaptcha() { + // Width: 120, Height: 40, Code Count: 4, Interference Line Count: 10 + LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(120, 40, 4, 10); + String uuid = UUID.fastUUID().toString(true); + String code = lineCaptcha.getCode(); + + // Store in Redis with 5 minutes expiration + redisTemplate.opsForValue().set("captcha:" + uuid, Objects.requireNonNull(code), 5, TimeUnit.MINUTES); + + return new AuthDtos.CaptchaResponse(uuid, lineCaptcha.getImageBase64Data()); + } + + @Override + public void validateCaptcha(String uuid, String code) { + if (uuid == null || uuid.isBlank() || code == null || code.isBlank()) { + throw new BusinessException(40001, "验证码不能为空"); + } + String key = "captcha:" + uuid; + String cachedCode = redisTemplate.opsForValue().get(key); + + if (cachedCode == null) { + throw new BusinessException(40002, "验证码已过期"); + } + + if (!code.equalsIgnoreCase(cachedCode)) { + throw new BusinessException(40003, "验证码错误"); + } + + // Validate once, then delete to prevent replay + redisTemplate.delete(key); + } +} diff --git a/src/main/java/com/hertz/modules/system/service/impl/MenuServiceImpl.java b/src/main/java/com/hertz/modules/system/service/impl/MenuServiceImpl.java new file mode 100644 index 0000000..bc97593 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/impl/MenuServiceImpl.java @@ -0,0 +1,130 @@ +package com.hertz.modules.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.hertz.common.exception.BusinessException; +import com.hertz.modules.system.dto.MenuDto; +import com.hertz.modules.system.entity.SysMenu; +import com.hertz.modules.system.entity.SysRoleMenu; +import com.hertz.modules.system.mapper.SysMenuMapper; +import com.hertz.modules.system.mapper.SysRoleMapper; +import com.hertz.modules.system.mapper.SysRoleMenuMapper; +import com.hertz.modules.system.service.MenuService; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MenuServiceImpl implements MenuService { + private final SysMenuMapper menuMapper; + private final SysRoleMapper roleMapper; + private final SysRoleMenuMapper roleMenuMapper; + + public MenuServiceImpl(SysMenuMapper menuMapper, SysRoleMapper roleMapper, SysRoleMenuMapper roleMenuMapper) { + this.menuMapper = menuMapper; + this.roleMapper = roleMapper; + this.roleMenuMapper = roleMenuMapper; + } + + @Override + public List getMenuTreeByUserId(long userId) { + var roleKeys = roleMapper.selectRoleKeysByUserId(userId); + var menus = roleKeys.stream().anyMatch(r -> "ADMIN".equalsIgnoreCase(r)) + ? menuMapper.selectAllVisibleMenus() + : menuMapper.selectMenusByUserId(userId); + + var map = new HashMap(); + for (var m : menus) { + var dto = new MenuDto(); + dto.setId(m.getId()); + dto.setParentId(m.getParentId()); + dto.setType(m.getType()); + dto.setName(m.getName()); + dto.setPath(m.getPath()); + dto.setComponent(m.getComponent()); + dto.setPerms(m.getPerms()); + dto.setIcon(m.getIcon()); + dto.setSort(m.getSort()); + dto.setStatus(m.getStatus()); + map.put(dto.getId(), dto); + } + + var roots = new ArrayList(); + for (var dto : map.values()) { + var parentId = dto.getParentId() == null ? 0L : dto.getParentId(); + if (parentId == 0L || !map.containsKey(parentId)) { + roots.add(dto); + } else { + map.get(parentId).getChildren().add(dto); + } + } + + Comparator comparator = Comparator + .comparing((MenuDto d) -> d.getSort() == null ? 0 : d.getSort()) + .thenComparing(d -> d.getId() == null ? 0 : d.getId()); + sortRecursively(roots, comparator); + return roots; + } + + @Override + public IPage pageMenus(int page, int size, String keyword) { + var wrapper = new LambdaQueryWrapper().orderByAsc(SysMenu::getSort); + if (keyword != null && !keyword.isBlank()) { + wrapper.like(SysMenu::getName, keyword); + } + return menuMapper.selectPage(Page.of(page, size), wrapper); + } + + @Override + @Transactional + public void saveMenu(SysMenu menu) { + menu.setCreatedAt(java.time.LocalDateTime.now()); + menu.setUpdatedAt(java.time.LocalDateTime.now()); + menuMapper.insert(menu); + } + + @Override + @Transactional + public void updateMenu(SysMenu menu) { + var existing = menuMapper.selectById(menu.getId()); + if (existing == null) { + throw new BusinessException(404, "Menu not found"); + } + existing.setParentId(menu.getParentId()); + existing.setType(menu.getType()); + existing.setName(menu.getName()); + existing.setPath(menu.getPath()); + existing.setComponent(menu.getComponent()); + existing.setPerms(menu.getPerms()); + existing.setIcon(menu.getIcon()); + existing.setSort(menu.getSort()); + existing.setVisible(menu.getVisible()); + existing.setStatus(menu.getStatus()); + existing.setUpdatedAt(java.time.LocalDateTime.now()); + menuMapper.updateById(existing); + } + + @Override + @Transactional + public void deleteMenu(Long id) { + if (menuMapper.selectCount(new LambdaQueryWrapper().eq(SysMenu::getParentId, id)) > 0) { + throw new BusinessException(400, "Has sub-menus, cannot delete"); + } + menuMapper.deleteById(id); + roleMenuMapper.delete(new LambdaQueryWrapper().eq(SysRoleMenu::getMenuId, id)); + } + + private void sortRecursively(List nodes, Comparator comparator) { + nodes.sort(comparator); + for (var n : nodes) { + if (n.getChildren() != null && !n.getChildren().isEmpty()) { + sortRecursively(n.getChildren(), comparator); + } + } + } +} + diff --git a/src/main/java/com/hertz/modules/system/service/impl/RoleServiceImpl.java b/src/main/java/com/hertz/modules/system/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..c631c28 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/impl/RoleServiceImpl.java @@ -0,0 +1,94 @@ +package com.hertz.modules.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.hertz.common.exception.BusinessException; +import com.hertz.modules.system.entity.SysRole; +import com.hertz.modules.system.entity.SysRoleMenu; +import com.hertz.modules.system.mapper.SysRoleMapper; +import com.hertz.modules.system.mapper.SysRoleMenuMapper; +import com.hertz.modules.system.service.RoleService; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class RoleServiceImpl implements RoleService { + private final SysRoleMapper roleMapper; + private final SysRoleMenuMapper roleMenuMapper; + + public RoleServiceImpl(SysRoleMapper roleMapper, SysRoleMenuMapper roleMenuMapper) { + this.roleMapper = roleMapper; + this.roleMenuMapper = roleMenuMapper; + } + + @Override + public List listEnabledRoles() { + return roleMapper.selectList(new LambdaQueryWrapper() + .eq(SysRole::getStatus, 1) + .orderByAsc(SysRole::getId)); + } + + @Override + public IPage pageRoles(int page, int size, String keyword) { + var wrapper = new LambdaQueryWrapper().orderByDesc(SysRole::getId); + if (keyword != null && !keyword.isBlank()) { + wrapper.and(w -> w.like(SysRole::getRoleName, keyword).or().like(SysRole::getRoleKey, keyword)); + } + return roleMapper.selectPage(Page.of(page, size), wrapper); + } + + @Override + @Transactional + public void saveRole(SysRole role) { + if (roleMapper.selectCount(new LambdaQueryWrapper().eq(SysRole::getRoleKey, role.getRoleKey())) > 0) { + throw new BusinessException(400, "Role key already exists"); + } + role.setCreatedAt(java.time.LocalDateTime.now()); + role.setUpdatedAt(java.time.LocalDateTime.now()); + roleMapper.insert(role); + } + + @Override + @Transactional + public void updateRole(SysRole role) { + var existing = roleMapper.selectById(role.getId()); + if (existing == null) { + throw new BusinessException(404, "Role not found"); + } + existing.setRoleName(role.getRoleName()); + existing.setRoleKey(role.getRoleKey()); + existing.setStatus(role.getStatus()); + existing.setUpdatedAt(java.time.LocalDateTime.now()); + roleMapper.updateById(existing); + } + + @Override + @Transactional + public void deleteRole(Long id) { + roleMapper.deleteById(id); + roleMenuMapper.delete(new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, id)); + } + + @Override + @Transactional + public void updateRolePermissions(Long roleId, List menuIds) { + roleMenuMapper.delete(new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)); + if (menuIds != null) { + menuIds.stream().distinct().forEach(menuId -> { + SysRoleMenu rm = new SysRoleMenu(); + rm.setRoleId(roleId); + rm.setMenuId(menuId); + roleMenuMapper.insert(rm); + }); + } + } + + @Override + public List getRoleMenuIds(Long roleId) { + return roleMenuMapper.selectList(new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)) + .stream().map(SysRoleMenu::getMenuId).toList(); + } +} + diff --git a/src/main/java/com/hertz/modules/system/service/impl/UserServiceImpl.java b/src/main/java/com/hertz/modules/system/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..5eb0024 --- /dev/null +++ b/src/main/java/com/hertz/modules/system/service/impl/UserServiceImpl.java @@ -0,0 +1,251 @@ +package com.hertz.modules.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.hertz.common.exception.BusinessException; +import com.hertz.config.AppPathResolver; +import com.hertz.config.AppProperties; +import com.hertz.modules.system.dto.AuthDtos; +import com.hertz.modules.system.entity.SysRole; +import com.hertz.modules.system.entity.SysUser; +import com.hertz.modules.system.entity.SysUserRole; +import com.hertz.modules.system.mapper.SysRoleMapper; +import com.hertz.modules.system.mapper.SysUserMapper; +import com.hertz.modules.system.mapper.SysUserRoleMapper; +import com.hertz.modules.system.service.UserService; +import java.io.File; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserServiceImpl implements UserService { + private final SysUserMapper userMapper; + private final SysUserRoleMapper userRoleMapper; + private final SysRoleMapper roleMapper; + private final PasswordEncoder passwordEncoder; + private final AppProperties appProperties; + private final AppPathResolver pathResolver; + + public UserServiceImpl( + SysUserMapper userMapper, + SysUserRoleMapper userRoleMapper, + SysRoleMapper roleMapper, + PasswordEncoder passwordEncoder, + AppProperties appProperties, + AppPathResolver pathResolver + ) { + this.userMapper = userMapper; + this.userRoleMapper = userRoleMapper; + this.roleMapper = roleMapper; + this.passwordEncoder = passwordEncoder; + this.appProperties = appProperties; + this.pathResolver = pathResolver; + } + + @Override + public SysUser register(String username, String rawPassword, String nickname) { + var exists = userMapper.selectCount(new LambdaQueryWrapper() + .eq(SysUser::getUsername, username)) > 0; + if (exists) { + throw new BusinessException(40002, "用户名已存在"); + } + var user = new SysUser(); + user.setUsername(username); + user.setPassword(passwordEncoder.encode(rawPassword)); + user.setNickname(nickname); + user.setStatus(1); + userMapper.insert(user); + return user; + } + + @Override + public SysUser findByUsername(String username) { + return userMapper.selectOne(new LambdaQueryWrapper() + .eq(SysUser::getUsername, username) + .last("LIMIT 1")); + } + + @Override + public IPage pageUsers(int page, int size, String keyword) { + var wrapper = new LambdaQueryWrapper() + .orderByDesc(SysUser::getId); + if (keyword != null && !keyword.isBlank()) { + wrapper.and(w -> w.like(SysUser::getUsername, keyword) + .or().like(SysUser::getNickname, keyword) + .or().like(SysUser::getPhone, keyword) + .or().like(SysUser::getEmail, keyword)); + } + return userMapper.selectPage(Page.of(page, size), wrapper); + } + + @Override + @Transactional + public void createUser(SysUser user) { + if (userMapper.selectCount(new LambdaQueryWrapper().eq(SysUser::getUsername, user.getUsername())) > 0) { + throw new BusinessException(400, "用户名已存在"); + } + user.setPassword(passwordEncoder.encode(user.getPassword())); + user.setCreatedAt(java.time.LocalDateTime.now()); + user.setUpdatedAt(java.time.LocalDateTime.now()); + userMapper.insert(user); + } + + @Override + @Transactional + public void updateUser(SysUser user) { + var existing = userMapper.selectById(user.getId()); + if (existing == null) { + throw new BusinessException(404, "用户不存在"); + } + existing.setNickname(user.getNickname()); + existing.setPhone(user.getPhone()); + existing.setEmail(user.getEmail()); + existing.setGender(user.getGender()); + existing.setStatus(user.getStatus()); + String oldAvatarPath = existing.getAvatarPath(); + String newAvatarPath = user.getAvatarPath(); + existing.setAvatarPath(newAvatarPath); + existing.setUpdatedAt(java.time.LocalDateTime.now()); + + if (user.getPassword() != null && !user.getPassword().isBlank()) { + existing.setPassword(passwordEncoder.encode(user.getPassword())); + } + + userMapper.updateById(existing); + deleteOldAvatarIfReplaced(oldAvatarPath, newAvatarPath); + } + + @Override + @Transactional + public void updateProfile(Long userId, AuthDtos.UpdateProfileRequest req) { + var existing = userMapper.selectById(userId); + if (existing == null) { + throw new BusinessException(404, "用户不存在"); + } + existing.setNickname(req.nickname()); + String oldAvatarPath = existing.getAvatarPath(); + String newAvatarPath = oldAvatarPath; + if (req.avatarPath() != null) { + newAvatarPath = req.avatarPath(); + existing.setAvatarPath(req.avatarPath()); + } + existing.setPhone(req.phone()); + existing.setEmail(req.email()); + existing.setGender(req.gender()); + existing.setUpdatedAt(java.time.LocalDateTime.now()); + userMapper.updateById(existing); + deleteOldAvatarIfReplaced(oldAvatarPath, newAvatarPath); + } + + @Override + @Transactional + public void updatePassword(Long userId, AuthDtos.UpdatePasswordRequest req) { + var existing = userMapper.selectById(userId); + if (existing == null) { + throw new BusinessException(404, "用户不存在"); + } + if (!passwordEncoder.matches(req.oldPassword(), existing.getPassword())) { + throw new BusinessException(400, "旧密码错误"); + } + existing.setPassword(passwordEncoder.encode(req.newPassword())); + existing.setUpdatedAt(java.time.LocalDateTime.now()); + userMapper.updateById(existing); + } + + @Override + @Transactional + public void deleteUser(Long id) { + userMapper.deleteById(id); + userRoleMapper.delete(new LambdaQueryWrapper().eq(SysUserRole::getUserId, id)); + } + + @Override + @Transactional + public void deleteUsers(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + userMapper.deleteByIds(ids); + userRoleMapper.delete(new LambdaQueryWrapper().in(SysUserRole::getUserId, ids)); + } + + @Override + @Transactional + public void updateUserRoles(long userId, List roleIds) { + userRoleMapper.delete(new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + if (roleIds == null || roleIds.isEmpty()) { + return; + } + for (var roleId : roleIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList())) { + var ur = new SysUserRole(); + ur.setUserId(userId); + ur.setRoleId(roleId); + userRoleMapper.insert(ur); + } + } + + @Override + public List getUserRoleIds(long userId) { + var list = userRoleMapper.selectList(new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + return list.stream().map(SysUserRole::getRoleId).filter(Objects::nonNull).distinct().toList(); + } + + @Override + public List getUserRoles(long userId) { + return roleMapper.selectRolesByUserId(userId); + } + + private void deleteOldAvatarIfReplaced(String oldAvatarPath, String newAvatarPath) { + if (oldAvatarPath == null || oldAvatarPath.isBlank()) { + return; + } + if (newAvatarPath == null || newAvatarPath.isBlank()) { + return; + } + if (Objects.equals(oldAvatarPath, newAvatarPath)) { + return; + } + + String avatarPrefix = "/uploads/" + normalizeRelativePrefix(appProperties.getUpload().getAvatarPath()); + if (!oldAvatarPath.replace("\\", "/").startsWith(avatarPrefix)) { + return; + } + + String relative = oldAvatarPath.replace("\\", "/"); + if (!relative.startsWith("/uploads/")) { + return; + } + relative = relative.substring("/uploads/".length()); + + File root = new File(pathResolver.resolve(appProperties.getUpload().getRootPath())); + File file = new File(root, relative.replace("/", File.separator)); + try { + String rootCanonical = root.getCanonicalPath(); + String fileCanonical = file.getCanonicalPath(); + if (!fileCanonical.startsWith(rootCanonical)) { + return; + } + } catch (Exception ignored) { + return; + } + + if (file.exists()) { + file.delete(); + } + } + + private String normalizeRelativePrefix(String path) { + if (path == null) return ""; + String p = path.replace("\\", "/"); + if (!p.endsWith("/")) { + p = p + "/"; + } + return p; + } +} + diff --git a/src/main/java/com/hertz/security/CustomUserDetailsService.java b/src/main/java/com/hertz/security/CustomUserDetailsService.java new file mode 100644 index 0000000..20b00b7 --- /dev/null +++ b/src/main/java/com/hertz/security/CustomUserDetailsService.java @@ -0,0 +1,38 @@ +/** + * Spring Security 用户信息加载服务。 + * + *

用于根据用户名加载用户信息,供认证与权限体系使用。

+ */ +package com.hertz.security; + +import com.hertz.modules.system.service.UserService; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserService userService; + + public CustomUserDetailsService(UserService userService) { + this.userService = userService; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var user = userService.findByUsername(username); + if (user == null) { + throw new UsernameNotFoundException("User not found: " + username); + } + // We are using JWT for auth, so this is mostly to satisfy Spring Security's default config + // or if we wanted to support Basic Auth/Form Login alongside JWT. + // For now, we return a minimal UserDetails implementation. + return org.springframework.security.core.userdetails.User + .withUsername(user.getUsername()) + .password(user.getPassword()) + .roles("USER") // Default role, actual authorities are loaded in JwtAuthFilter + .build(); + } +} diff --git a/src/main/java/com/hertz/security/JwtAuthFilter.java b/src/main/java/com/hertz/security/JwtAuthFilter.java new file mode 100644 index 0000000..de58917 --- /dev/null +++ b/src/main/java/com/hertz/security/JwtAuthFilter.java @@ -0,0 +1,67 @@ +/** + * JWT 认证过滤器。 + * + *

从请求头 Authorization: Bearer token 中解析 JWT,并将认证信息写入 SecurityContext。

+ */ +package com.hertz.security; + +import com.hertz.modules.system.service.AuthzService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.stream.Collectors; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + private final JwtService jwtService; + private final AuthzService authzService; + + public JwtAuthFilter(JwtService jwtService, AuthzService authzService) { + this.jwtService = jwtService; + this.authzService = authzService; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + var header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + var token = header.substring("Bearer ".length()).trim(); + try { + var claims = jwtService.parse(token); + var userId = claims.get("uid", Number.class).longValue(); + var username = claims.getSubject(); + var authorities = authzService.loadAuthorities(userId).stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + var auth = new UsernamePasswordAuthenticationToken(username, null, authorities); + auth.setDetails(userId); + SecurityContextHolder.getContext().setAuthentication(auth); + // System.out.println("DEBUG: JWT Auth Success for user: " + username); + } catch (Exception e) { + System.err.println("DEBUG: JWT Auth Failed: " + e.getMessage()); + e.printStackTrace(); + logger.error("JWT Authentication failed: " + e.getMessage(), e); + SecurityContextHolder.clearContext(); + } + + filterChain.doFilter(request, response); + } +} + diff --git a/src/main/java/com/hertz/security/JwtService.java b/src/main/java/com/hertz/security/JwtService.java new file mode 100644 index 0000000..4ed0ace --- /dev/null +++ b/src/main/java/com/hertz/security/JwtService.java @@ -0,0 +1,53 @@ +/** + * JWT 工具服务。 + * + *

负责生成与解析 JWT,用于无状态认证。

+ */ +package com.hertz.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtService { + private final SecretKey key; + private final long expireSeconds; + + public JwtService( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expire-seconds}") long expireSeconds + ) { + if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("app.jwt.secret 至少 32 字节"); + } + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expireSeconds = expireSeconds; + } + + public String createToken(long userId, String username) { + var now = Instant.now(); + return Jwts.builder() + .subject(username) + .claim("uid", userId) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusSeconds(expireSeconds))) + .signWith(key) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} + diff --git a/src/main/java/com/hertz/security/SecurityConfig.java b/src/main/java/com/hertz/security/SecurityConfig.java new file mode 100644 index 0000000..c96395b --- /dev/null +++ b/src/main/java/com/hertz/security/SecurityConfig.java @@ -0,0 +1,65 @@ +/** + * Spring Security 配置。 + * + *

配置无状态会话、跨域策略、接口访问规则,并注册 JWT 认证过滤器。

+ */ +package com.hertz.security; + +import jakarta.servlet.DispatcherType; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + var config = new CorsConfiguration(); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowCredentials(true); + + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, + JwtAuthFilter jwtAuthFilter + ) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() + .requestMatchers("/api/auth/**", "/error", "/uploads/**").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} + diff --git a/src/main/java/com/hertz/security/SecurityUtils.java b/src/main/java/com/hertz/security/SecurityUtils.java new file mode 100644 index 0000000..7bb4017 --- /dev/null +++ b/src/main/java/com/hertz/security/SecurityUtils.java @@ -0,0 +1,30 @@ +/** + * 安全上下文工具类。 + * + *

用于从 Spring Security 上下文中获取当前登录用户信息。

+ */ +package com.hertz.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public final class SecurityUtils { + private SecurityUtils() { + } + + public static Long getCurrentUserId() { + return getCurrentUserId(SecurityContextHolder.getContext().getAuthentication()); + } + + public static Long getCurrentUserId(Authentication authentication) { + if (authentication == null) { + return null; + } + var details = authentication.getDetails(); + if (details instanceof Number n) { + return n.longValue(); + } + return null; + } +} + diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..fc7075b --- /dev/null +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,10 @@ +{ + "properties": [ + { + "name": "spring.ai.vectorstore.simple.store.path", + "type": "java.lang.String", + "description": "SimpleVectorStore 持久化存储路径(可为目录或文件路径,项目内按需解析)。" + } + ] +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..909fb28 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,57 @@ +server: + port: 8088 + +spring: + application: + name: Hertz-Springboot + sql: + init: + mode: never # 禁用 SQL 初始化(不自动执行 schema.sql / data.sql)可选:always(默认)、never + servlet: + multipart: + max-file-size: 2MB + max-request-size: 10MB + ai: + ollama: + base-url: http://localhost:11434 + chat: + # 如果需要快速响应,建议切换为标准模型,如: llama3.1, qwen2.5:7b, deepseek-llm:7b + model: deepseek-llm:7b + options: + temperature: 0.7 + embedding: + model: nomic-embed-text + vectorstore: + simple: + store: + path: ./uploads/knowledge/vector_store + datasource: + url: jdbc:mysql://localhost:3306/hertz_springboot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: localhost + port: 6379 + password: + database: 0 + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + +app: + jwt: + secret: change-me-to-a-long-random-string-change-me-to-a-long-random-string + expire-seconds: 86400 + upload: + root-path: ./uploads + avatar-path: avatar/ + knowledge-path: knowledge/ + +management: + endpoints: + web: + exposure: + include: health,info,metrics diff --git a/src/main/resources/schema/ai_schema.sql b/src/main/resources/schema/ai_schema.sql new file mode 100644 index 0000000..bc03fef --- /dev/null +++ b/src/main/resources/schema/ai_schema.sql @@ -0,0 +1,26 @@ + +-- ---------------------------- +-- Table structure for conversations +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `ai_conversations` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT '用户ID', + `title` varchar(255) NOT NULL COMMENT '对话标题', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话记录表'; + +-- ---------------------------- +-- Table structure for messages +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `ai_messages` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `conversation_id` bigint NOT NULL COMMENT '所属对话ID', + `role` enum('user','assistant') NOT NULL COMMENT '消息角色', + `content` text NOT NULL COMMENT '消息内容', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_conversation_id` (`conversation_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话消息表'; diff --git a/src/main/resources/schema/knowledge_schema.sql b/src/main/resources/schema/knowledge_schema.sql new file mode 100644 index 0000000..a249fe6 --- /dev/null +++ b/src/main/resources/schema/knowledge_schema.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS knowledge_base ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + name VARCHAR(255) NOT NULL COMMENT '知识库名称', + description TEXT COMMENT '知识库描述', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + create_by BIGINT COMMENT '创建人ID' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库'; + +CREATE TABLE IF NOT EXISTS knowledge_document ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + kb_id BIGINT NOT NULL COMMENT '知识库ID', + original_name VARCHAR(512) NOT NULL COMMENT '原始文件名', + stored_name VARCHAR(512) NOT NULL COMMENT '存储文件名', + stored_path VARCHAR(1024) NOT NULL COMMENT '存储相对路径', + content_type VARCHAR(255) COMMENT '文件类型', + size_bytes BIGINT NOT NULL COMMENT '文件大小(字节)', + sha256 VARCHAR(64) COMMENT '文件SHA256摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态(PROCESSING/READY/FAILED)', + chunk_count INT DEFAULT 0 COMMENT '分片数量', + error_message VARCHAR(1024) COMMENT '失败原因', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除(软删除标记)', + deleted_time DATETIME COMMENT '删除时间', + KEY idx_kb_id (kb_id), + KEY idx_kb_deleted (kb_id, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文档'; diff --git a/src/main/resources/schema/monitor_schema.sql b/src/main/resources/schema/monitor_schema.sql new file mode 100644 index 0000000..3e1807f --- /dev/null +++ b/src/main/resources/schema/monitor_schema.sql @@ -0,0 +1,14 @@ +-- ---------------------------- +-- Table structure for sys_monitor_log +-- ---------------------------- +DROP TABLE IF EXISTS `sys_monitor_log`; +CREATE TABLE `sys_monitor_log` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `cpu_usage` double NOT NULL COMMENT 'CPU使用率(%)', + `memory_usage` double NOT NULL COMMENT '内存使用率(%)', + `memory_total` bigint NOT NULL COMMENT '总内存(字节)', + `memory_used` bigint NOT NULL COMMENT '已用内存(字节)', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + PRIMARY KEY (`id`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统监控日志表'; diff --git a/src/main/resources/schema/schema.sql b/src/main/resources/schema/schema.sql new file mode 100644 index 0000000..ea0531e --- /dev/null +++ b/src/main/resources/schema/schema.sql @@ -0,0 +1,107 @@ +-- ---------------------------- +-- Table structure for sys_user +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `username` varchar(50) NOT NULL COMMENT '用户名', + `password` varchar(100) NOT NULL COMMENT '加密密码', + `nickname` varchar(50) NOT NULL COMMENT '用户昵称', + `avatar_path` varchar(255) DEFAULT NULL COMMENT '头像路径', + `phone` varchar(20) DEFAULT NULL COMMENT '手机号', + `email` varchar(100) DEFAULT NULL COMMENT '邮箱', + `gender` tinyint(1) DEFAULT '0' COMMENT '0-未知 1-男 2-女', + `status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表'; + +-- ---------------------------- +-- Table structure for sys_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role`; +CREATE TABLE `sys_role` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID', + `role_key` varchar(50) NOT NULL COMMENT '角色标识', + `role_name` varchar(50) NOT NULL COMMENT '角色名称', + `status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_key` (`role_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表'; + +-- ---------------------------- +-- Table structure for sys_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_menu`; +CREATE TABLE `sys_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单ID', + `parent_id` bigint DEFAULT '0' COMMENT '父菜单ID', + `type` varchar(10) NOT NULL COMMENT 'D-目录 M-菜单 B-按钮', + `name` varchar(50) NOT NULL COMMENT '菜单名称', + `path` varchar(200) DEFAULT NULL COMMENT '路由路径', + `component` varchar(200) DEFAULT NULL COMMENT '组件路径', + `perms` varchar(100) DEFAULT NULL COMMENT '权限标识', + `icon` varchar(100) DEFAULT NULL COMMENT '菜单图标', + `sort` int DEFAULT '0' COMMENT '排序', + `visible` tinyint DEFAULT '1' COMMENT '0-隐藏 1-显示', + `status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表'; + +-- ---------------------------- +-- Table structure for sys_user_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_role`; +CREATE TABLE `sys_user_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL COMMENT '用户ID', + `role_id` bigint NOT NULL COMMENT '角色ID', + PRIMARY KEY (`id`), + KEY `idx_user_role` (`user_id`,`role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; + +-- ---------------------------- +-- Table structure for sys_role_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_menu`; +CREATE TABLE `sys_role_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role_id` bigint NOT NULL COMMENT '角色ID', + `menu_id` bigint NOT NULL COMMENT '菜单ID', + PRIMARY KEY (`id`), + KEY `idx_role_menu` (`role_id`,`menu_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表'; + +-- ---------------------------- +-- Init Data +-- ---------------------------- +INSERT INTO `sys_role` (`id`, `role_key`, `role_name`) VALUES + (1, 'ADMIN', '管理员'); + +INSERT INTO `sys_menu` (`id`, `parent_id`, `type`, `name`, `path`, `component`, `perms`, `icon`, `sort`) VALUES + (1, 0, 'M', '仪表盘', '/admin/dashboard', 'admin/Dashboard', NULL, 'DataLine', 0), + (2, 0, 'D', '系统管理', '/admin/system', NULL, NULL, 'Setting', 10), + (3, 2, 'M', '用户管理', '/admin/system/user', 'admin/system/User', 'system:user:view', 'User', 0), + (4, 2, 'M', '角色管理', '/admin/system/role', 'admin/system/Role', 'system:role:view', 'Tickets', 1), + (5, 2, 'M', '菜单管理', '/admin/system/menu', 'admin/system/Menu', 'system:menu:view', 'Menu', 2); + +INSERT INTO `sys_user` (`id`, `username`, `password`, `nickname`, `phone`, `email`, `gender`, `status`) VALUES + (1, 'hertz', '$2a$10$Gker6.ggCxG3wfZ13rE/Eu7aDnB.DX2JmP6h6vct30RTtBr9.q5Pq', '管理员', '18888888888', 'hertz@hertz.com', 1, 1), + (2, 'demo', '$2a$10$PSIz9pWXAwXfB32HWSxTjeGhVi0bixsSKxzeX8YAdKnRRXPxJC3Xe', '普通用户', '13888888888', 'demo@hertz.com', 1, 1); + +INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES + (1, 1); + +INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES + (1, 1), + (1, 2), + (1, 3), + (1, 4), + (1, 5); \ No newline at end of file diff --git a/src/test/java/com/hertz/HertzApplicationTests.java b/src/test/java/com/hertz/HertzApplicationTests.java new file mode 100644 index 0000000..6274ae5 --- /dev/null +++ b/src/test/java/com/hertz/HertzApplicationTests.java @@ -0,0 +1,13 @@ +package com.hertz; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class HertzApplicationTests { + + @Test + void contextLoads() { + } +} + diff --git a/ui/.env.dev b/ui/.env.dev new file mode 100644 index 0000000..1cdc56d --- /dev/null +++ b/ui/.env.dev @@ -0,0 +1 @@ +VITE_API_BASE=http://localhost:8088 \ No newline at end of file diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..c9e0db4 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Hertz Admin + + +
+ + + diff --git a/ui/jsconfig.json b/ui/jsconfig.json new file mode 100644 index 0000000..b8aada2 --- /dev/null +++ b/ui/jsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "strict": false, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["esnext", "dom"], + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.js", "src/**/*.vue", "vite.config.js"], + "exclude": ["node_modules"] +} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..51cd060 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2058 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.2", + "element-plus": "^2.13.1", + "marked": "^17.0.1", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.2.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.1.tgz", + "integrity": "sha512-eG4BDBGdAsUGN6URH1PixzZb0ngdapLivIk1meghS1uEueLvQ3aljSKrCt5x6sYb6mUk8eGtzTQFgsPmLavQcA==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^10.11.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..6e8ea52 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --mode dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.2", + "element-plus": "^2.13.1", + "marked": "^17.0.1", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.2.4" + } +} diff --git a/ui/public/favicon.png b/ui/public/favicon.png new file mode 100644 index 0000000..2b2fed8 Binary files /dev/null and b/ui/public/favicon.png differ diff --git a/ui/public/logo.png b/ui/public/logo.png new file mode 100644 index 0000000..2040f6c Binary files /dev/null and b/ui/public/logo.png differ diff --git a/ui/src/App.vue b/ui/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/ui/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/ui/src/api/auth.js b/ui/src/api/auth.js new file mode 100644 index 0000000..a69ec1c --- /dev/null +++ b/ui/src/api/auth.js @@ -0,0 +1,29 @@ +import { http } from './http' + +export async function getCaptcha() { + const { data } = await http.get('/api/auth/captcha') + return data.data +} + +export async function login(req) { + const { data } = await http.post('/api/auth/login', req) + return data.data +} + +export async function register(req) { + const { data } = await http.post('/api/auth/register', req) + return data.data +} + +export async function me() { + const { data } = await http.get('/api/auth/me') + return data.data +} + +export async function updateProfile(data) { + await http.post('/api/auth/profile', data) +} + +export async function updatePassword(data) { + await http.post('/api/auth/password', data) +} diff --git a/ui/src/api/chat.js b/ui/src/api/chat.js new file mode 100644 index 0000000..7936b89 --- /dev/null +++ b/ui/src/api/chat.js @@ -0,0 +1,39 @@ +import { http as request } from './http' + +export const chatApi = { + // Create Conversation + async createConversation(title) { + const { data } = await request.post('api/ai/conversations', { title }) + return data.data + }, + // Get List + async getConversations() { + const { data } = await request.get('api/ai/conversations') + return data.data + }, + // Delete + async deleteConversation(id) { + const { data } = await request.delete(`api/ai/conversations/${id}`) + return data.data + }, + // Update Title + async updateConversation(id, title) { + const { data } = await request.put(`api/ai/conversations/${id}`, { title }) + return data.data + }, + // Search + async searchConversations(query) { + const { data } = await request.get('api/ai/conversations/search', { params: { query } }) + return data.data + }, + // Save Message + async saveMessage(conversationId, role, content) { + const { data } = await request.post(`api/ai/conversations/${conversationId}/messages`, { role, content }) + return data.data + }, + // Get Messages + async getMessages(conversationId) { + const { data } = await request.get(`api/ai/conversations/${conversationId}/messages`) + return data.data + } +} diff --git a/ui/src/api/common.js b/ui/src/api/common.js new file mode 100644 index 0000000..5ea8fd5 --- /dev/null +++ b/ui/src/api/common.js @@ -0,0 +1,12 @@ +import { http } from './http' + +export async function uploadFile(file) { + const formData = new FormData() + formData.append('file', file) + const { data } = await http.post('/api/common/file/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return data.data +} diff --git a/ui/src/api/http.js b/ui/src/api/http.js new file mode 100644 index 0000000..8107b7e --- /dev/null +++ b/ui/src/api/http.js @@ -0,0 +1,52 @@ +import axios from 'axios' +import { useAuthStore } from '../stores/auth' +import { useMenuStore } from '../stores/menu' + +export const http = axios.create({ + baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080', + timeout: 15000, +}) + +http.interceptors.request.use((config) => { + const auth = useAuthStore() + if (auth.token) { + config.headers = config.headers ?? {} + config.headers.Authorization = `Bearer ${auth.token}` + } + return config +}) + +http.interceptors.response.use( + (resp) => resp, + (err) => { + if (err?.response?.status === 401) { + const auth = useAuthStore() + auth.logout() + const menu = useMenuStore() + menu.reset() + import('../router') + .then(({ default: router }) => { + const current = router.currentRoute.value + if (current.path.startsWith('/admin') && current.path !== '/login') { + router.replace({ path: '/login', query: { redirect: current.fullPath } }) + } + }) + .catch(() => {}) + } + if (err?.response?.status === 403) { + import('../router') + .then(({ default: router }) => { + const current = router.currentRoute.value + const path = current.path + const target = path.startsWith('/admin') ? '/admin/403' : '/portal/403' + if (path !== target) { + router.replace({ path: target, query: { from: current.fullPath } }) + } + }) + .catch(() => { + if (window.location.pathname !== '/portal/403') window.location.replace('/portal/403') + }) + } + return Promise.reject(err) + }, +) diff --git a/ui/src/api/knowledge.js b/ui/src/api/knowledge.js new file mode 100644 index 0000000..9a7c264 --- /dev/null +++ b/ui/src/api/knowledge.js @@ -0,0 +1,47 @@ +import { http as request } from './http' + +export const knowledgeApi = { + // Get List + async getKnowledgeBases(params) { + const { data } = await request.get('api/knowledge-bases', { params }) + return data.data + }, + + // Create + async createKnowledgeBase(data) { + const { data: res } = await request.post('api/knowledge-bases', data) + return res.data + }, + + // Upload Document + async uploadDocument(id, file) { + const formData = new FormData() + formData.append('file', file) + const { data } = await request.post(`api/knowledge-bases/${id}/upload`, formData) + return data + }, + + // List Documents + async listDocuments(id) { + const { data } = await request.get(`api/knowledge-bases/${id}/documents`) + return data.data + }, + + // Delete Document + async deleteDocument(id, docId) { + const { data } = await request.delete(`api/knowledge-bases/${id}/documents/${docId}`) + return data.data + }, + + // Rebuild Vector Store + async rebuildVectorStore(id) { + const { data } = await request.post(`api/knowledge-bases/${id}/rebuild`) + return data.data + }, + + // Delete + async deleteKnowledgeBase(id) { + const { data } = await request.delete(`api/knowledge-bases/${id}`) + return data.data + } +} diff --git a/ui/src/api/monitor.js b/ui/src/api/monitor.js new file mode 100644 index 0000000..bd2b3ba --- /dev/null +++ b/ui/src/api/monitor.js @@ -0,0 +1,6 @@ +import { http } from './http' + +export async function getServerInfo() { + const { data } = await http.get('/api/monitor/server') + return data +} diff --git a/ui/src/api/system.js b/ui/src/api/system.js new file mode 100644 index 0000000..7110516 --- /dev/null +++ b/ui/src/api/system.js @@ -0,0 +1,85 @@ +import { http } from './http' + +export async function fetchMenuTree() { + const { data } = await http.get('/api/system/menus/tree') + return data.data +} + +export async function pageMenus(params) { + const { data } = await http.get('/api/system/menus/page', { params }) + return data.data +} + +export async function createMenu(data) { + await http.post('/api/system/menus', data) +} + +export async function updateMenu(data) { + await http.put('/api/system/menus', data) +} + +export async function deleteMenu(id) { + await http.delete(`/api/system/menus/${id}`) +} + +export async function fetchUsers(params) { + const { data } = await http.get('/api/system/users', { params }) + return data.data +} + +export async function createUser(data) { + await http.post('/api/system/users', data) +} + +export async function updateUser(data) { + await http.put('/api/system/users', data) +} + +export async function deleteUser(id) { + await http.delete(`/api/system/users/${id}`) +} + +export async function deleteUsers(ids) { + await http.delete('/api/system/users', { data: ids }) +} + +export async function fetchRoles() { + const { data } = await http.get('/api/system/roles') + return data.data +} + +export async function pageRoles(params) { + const { data } = await http.get('/api/system/roles/page', { params }) + return data.data +} + +export async function createRole(data) { + await http.post('/api/system/roles', data) +} + +export async function updateRole(data) { + await http.put('/api/system/roles', data) +} + +export async function deleteRole(id) { + await http.delete(`/api/system/roles/${id}`) +} + +export async function fetchRoleMenuIds(roleId) { + const { data } = await http.get(`/api/system/roles/${roleId}/menus`) + return data.data +} + +export async function updateRoleMenus(roleId, menuIds) { + await http.put(`/api/system/roles/${roleId}/menus`, { menuIds }) +} + +export async function fetchUserRoleIds(userId) { + const { data } = await http.get(`/api/system/users/${userId}/roles`) + return data.data +} + +export async function updateUserRoles(userId, roleIds) { + const { data } = await http.put(`/api/system/users/${userId}/roles`, { roleIds }) + return data.data +} diff --git a/ui/src/assets/img/default_avatar.png b/ui/src/assets/img/default_avatar.png new file mode 100644 index 0000000..187c7f1 Binary files /dev/null and b/ui/src/assets/img/default_avatar.png differ diff --git a/ui/src/assets/img/empty.svg b/ui/src/assets/img/empty.svg new file mode 100644 index 0000000..962122f --- /dev/null +++ b/ui/src/assets/img/empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/img/profile_bg.jpg b/ui/src/assets/img/profile_bg.jpg new file mode 100644 index 0000000..2af007b Binary files /dev/null and b/ui/src/assets/img/profile_bg.jpg differ diff --git a/ui/src/assets/login_svg/bg_1.svg b/ui/src/assets/login_svg/bg_1.svg new file mode 100644 index 0000000..44dc03b --- /dev/null +++ b/ui/src/assets/login_svg/bg_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/login_svg/bg_2.png b/ui/src/assets/login_svg/bg_2.png new file mode 100644 index 0000000..f26d53b Binary files /dev/null and b/ui/src/assets/login_svg/bg_2.png differ diff --git a/ui/src/assets/login_svg/bg_2.svg b/ui/src/assets/login_svg/bg_2.svg new file mode 100644 index 0000000..c5677f3 --- /dev/null +++ b/ui/src/assets/login_svg/bg_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/login_svg/bg_3.svg b/ui/src/assets/login_svg/bg_3.svg new file mode 100644 index 0000000..9cbe755 --- /dev/null +++ b/ui/src/assets/login_svg/bg_3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/login_svg/bg_4.svg b/ui/src/assets/login_svg/bg_4.svg new file mode 100644 index 0000000..e34717a --- /dev/null +++ b/ui/src/assets/login_svg/bg_4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/login_svg/bg_5.svg b/ui/src/assets/login_svg/bg_5.svg new file mode 100644 index 0000000..f67f1e4 --- /dev/null +++ b/ui/src/assets/login_svg/bg_5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/style.css b/ui/src/assets/style.css new file mode 100644 index 0000000..bc6fdd7 --- /dev/null +++ b/ui/src/assets/style.css @@ -0,0 +1,363 @@ +:root { + /* iOS Color Palette */ + --ios-primary: #007AFF; + --ios-primary-light: #0A84FF; + --ios-success: #34C759; + --ios-warning: #FF9500; + --ios-danger: #FF3B30; + --ios-gray: #8E8E93; + --ios-bg: #F2F4F8; + --ios-surface: rgba(255, 255, 255, 0.7); + --ios-border: rgba(0, 0, 0, 0.05); + + /* Shapes & Shadows */ + --ios-radius: 16px; + --ios-shadow: 0 4px 24px rgba(0, 0, 0, 0.04); + --ios-shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.08); + + /* Fonts */ + --ios-font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-family: var(--ios-font-family), fangsong; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Element Plus Overrides Variables */ + --el-font-family: var(--ios-font-family); + --el-color-primary: var(--ios-primary); + --el-color-danger: var(--ios-danger); + --el-border-radius-base: 8px; + --el-border-radius-small: 6px; + --el-border-radius-round: 20px; + --el-bg-color-page: var(--ios-bg); +} + +body { + margin: 0; + padding: 0; + background-color: var(--ios-bg); + color: #1d1d1f; + font-family: var(--ios-font-family), fangsong; + font-size: 14px; + line-height: 1.5; +} + +/* Glassmorphism Utilities */ +.glass { + background: var(--ios-surface); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +/* Card Styling */ +.el-card, .ios-card { + border: none !important; + border-radius: var(--ios-radius) !important; + background: white; + transition: all 0.3s ease; + /*box-shadow: none !important;*/ +} + + +.el-card__header { + border-bottom: 1px solid var(--ios-border) !important; + padding: 10px 24px !important; + font-weight: 600; + font-size: 16px; +} + +/* Button Styling */ +.el-button { + border-radius: 10px !important; /* Pill shape */ + font-weight: 500 !important; + transition: all 0.2s ease !important; +} + +/* Remove focus outline from buttons globally to prevent residual border after dialog close */ +.el-button:focus, .el-button:focus-visible { + outline: none !important; + box-shadow: none !important; /* Also remove box-shadow which sometimes looks like a border */ +} + +.el-button.is-link:not(.el-button--danger):hover { + color: var(--ios-primary-light); +} + +.el-button.is-link.el-button--danger:hover { + color: var(--ios-danger); +} + + +.el-button--primary { + --el-button-hover-bg-color: var(--ios-primary-light); + --el-button-hover-border-color: var(--ios-primary-light); + --el-button-active-bg-color: var(--ios-primary-light); + --el-button-active-border-color: var(--ios-primary-light); +} + +.el-button:not(.is-circle):not(.is-text):not(.is-link) { + height: 36px !important; + padding: 0 20px !important; +} + + +/* Input Styling */ +.el-input__wrapper { + height: 36px !important; + border-radius: 10px !important; + padding: 0 12px !important; + background-color: rgba(255,255,255,0.8) !important; + transition: all 0.2s ease; +} + +.el-input__wrapper.is-focus { + background-color: white !important; +} + +/* Table Styling */ +.el-table { + --el-table-border-color: var(--ios-border); + --el-table-header-bg-color: transparent; + background-color: transparent !important; + border-radius: var(--ios-radius); + overflow: hidden; +} + +.el-table th.el-table__cell { + background-color: none !important; + color: #1d1d1f; + font-weight: 600; + font-size: 15px; + border-bottom: 1px solid var(--ios-border) !important; + padding: 12px 0 !important; +} + +.el-table td.el-table__cell { + border-bottom: 1px solid var(--ios-border) !important; +} + +.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell { + background-color: rgba(0, 122, 255, 0.03) !important; +} + +.el-tag { + border-radius: 10px !important; + padding: 0 10px !important; + height: 24px !important; + line-height: 22px !important; + font-weight: 600 !important; + border: 1px solid transparent !important; +} + +.el-tag--primary { + background-color: rgba(0, 122, 255, 0.12) !important; + border-color: rgba(0, 122, 255, 0.18) !important; + color: var(--ios-primary) !important; +} + +.el-tag--success { + background-color: rgba(52, 199, 89, 0.14) !important; + border-color: rgba(52, 199, 89, 0.22) !important; + color: var(--ios-success) !important; +} + +.el-tag--warning { + background-color: rgba(255, 149, 0, 0.14) !important; + border-color: rgba(255, 149, 0, 0.22) !important; + color: var(--ios-warning) !important; +} + +.el-tag--danger { + background-color: rgba(255, 59, 48, 0.14) !important; + border-color: rgba(255, 59, 48, 0.22) !important; + color: var(--ios-danger) !important; +} + +.el-tag--info { + background-color: rgba(142, 142, 147, 0.14) !important; + border-color: rgba(142, 142, 147, 0.22) !important; + color: var(--ios-gray) !important; +} + +.el-checkbox__inner { + width: 15px; + height: 15px; + border-radius: 4px; +} + + + +/* Dialog Styling */ +.el-dialog { + border-radius: 20px !important; + overflow: hidden; +} + +.el-dialog__header { + margin-right: 0 !important; + padding: 20px 24px !important; +} + +/* Dropdown (User menu) */ +.ios-dropdown { + min-width: 220px; + padding: 10px !important; +} + +.ios-dropdown-popper { + margin-top: 8px !important; +} + +.ios-dropdown-popper.el-popper { + border-radius: 18px !important; + border: 1px solid var(--ios-border) !important; + box-shadow: var(--ios-shadow) !important; + overflow: hidden !important; + padding: 0 !important; + background: transparent !important; +} + +.ios-dropdown-popper .el-popper__content { + border-radius: 18px !important; + overflow: hidden !important; + padding: 0 !important; + background: rgba(255, 255, 255, 0.98) !important; +} + +.ios-dropdown-popper .el-popper__arrow { + display: none !important; +} + +.ios-dropdown .el-dropdown-menu__item { + height: 44px; + line-height: 44px; + border-radius: 14px; + margin: 2px 0; + padding: 0 12px; + display: flex; + align-items: center; + gap: 10px; + color: #1d1d1f; +} + +.ios-dropdown .el-dropdown-menu__item:not(.is-disabled):hover { + background-color: rgba(0, 0, 0, 0.04) !important; +} + +.ios-dropdown .el-dropdown-menu__item.is-disabled { + cursor: default; + color: inherit; + opacity: 1; +} + +.ios-dropdown .el-dropdown-menu__item.is-divided { + margin-top: 10px; +} + +.ios-dropdown .el-dropdown-menu__item.is-divided::before { + height: 1px; + background-color: var(--ios-border); + left: 10px; + right: 10px; + top: -6px; +} + +.ios-dropdown .ios-user-card { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 4px 2px; +} + +.ios-dropdown .ios-user-meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.ios-dropdown .ios-user-name { + font-weight: 700; + font-size: 15px; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ios-dropdown .ios-user-email { + font-size: 12px; + color: #8E8E93; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ios-dropdown .ios-logout-item { + justify-content: center; + border: 1px solid rgba(0, 0, 0, 0.08); + background: white; +} + +.ios-dropdown .ios-logout-item:hover { + background-color: rgba(0, 0, 0, 0.02) !important; +} + +.ios-dropdown .el-icon { + font-size: 16px; +} + +/* Scrollbar Hiding (Global) */ +::-webkit-scrollbar { + display: none; +} +html, body { + scrollbar-width: none; + -ms-overflow-style: none; +} + +/* MessageBox Styling */ +.el-message-box { + border-radius: var(--ios-radius) !important; + border: none !important; + box-shadow: var(--ios-shadow-hover) !important; + background-color: rgba(255, 255, 255, 0.95) !important; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + padding: 0 !important; + width: 400px !important; + max-width: 90vw; +} + +.el-message-box__header { + padding: 20px 24px 10px !important; +} + +.el-message-box__title { + font-weight: 600; + font-size: 18px !important; +} + +.el-message-box__headerbtn { + top: 20px !important; + right: 20px !important; +} + +.el-message-box__content { + padding: 10px 24px 20px !important; + font-size: 15px !important; + color: #1d1d1f; +} + +.el-message-box__btns { + padding: 0 24px 24px !important; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.el-message-box__btns .el-button { + margin-left: 0 !important; + min-width: 80px; +} diff --git a/ui/src/components/ContentPage.vue b/ui/src/components/ContentPage.vue new file mode 100644 index 0000000..d649248 --- /dev/null +++ b/ui/src/components/ContentPage.vue @@ -0,0 +1,66 @@ + + + diff --git a/ui/src/components/Error.vue b/ui/src/components/Error.vue new file mode 100644 index 0000000..135678d --- /dev/null +++ b/ui/src/components/Error.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/ui/src/layouts/AdminLayout.vue b/ui/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..46e9d78 --- /dev/null +++ b/ui/src/layouts/AdminLayout.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/ui/src/layouts/PortalLayout.vue b/ui/src/layouts/PortalLayout.vue new file mode 100644 index 0000000..ee30ba5 --- /dev/null +++ b/ui/src/layouts/PortalLayout.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/ui/src/main.js b/ui/src/main.js new file mode 100644 index 0000000..bdf847e --- /dev/null +++ b/ui/src/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import App from './App.vue' +import { createPinia } from 'pinia' +import router from './router' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import './assets/style.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { + locale: zhCn, +}) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.mount('#app') diff --git a/ui/src/router/index.js b/ui/src/router/index.js new file mode 100644 index 0000000..1ba7438 --- /dev/null +++ b/ui/src/router/index.js @@ -0,0 +1,88 @@ +import { createRouter, createWebHistory } from 'vue-router' +import PortalLayout from '../layouts/PortalLayout.vue' +import AdminLayout from '../layouts/AdminLayout.vue' +import Login from '../views/Login.vue' +import Register from '../views/Register.vue' +import { setupGuards } from './setupGuards' + +export const ROUTE_NAMES = { + AdminRoot: 'AdminRoot', + AdminLayout: 'AdminLayout', +} + +const routes = [ + { path: '/', redirect: '/portal/home' }, + { path: '/login', component: Login }, + { path: '/register', component: Register }, + { + path: '/portal', + component: PortalLayout, + children: [ + { path: '', redirect: '/portal/home' }, + { path: 'home', component: () => import('../views/portal/Home.vue') }, + { path: 'monitor', component: () => import('../views/portal/Monitor.vue') }, + { path: 'chat', component: () => import('../views/portal/Chat.vue') }, + { path: 'about', component: () => import('../views/portal/About.vue') }, + { path: 'profile', component: () => import('../views/Profile.vue') }, + { + path: '403', + component: () => import('../components/Error.vue'), + props: { + code: 403, + title: '抱歉,您没有权限访问该页面', + subTitle: '请登录账号或联系管理员开通权限,或切换账号后重试。', + }, + }, + { + path: ':pathMatch(.*)*', + component: () => import('../components/Error.vue'), + props: { code: 404 }, + }, + ], + }, + { + path: '/admin', + name: ROUTE_NAMES.AdminLayout, + component: AdminLayout, + children: [ + { path: '', name: ROUTE_NAMES.AdminRoot, redirect: '/admin/dashboard' }, + { path: 'dashboard', component: () => import('../views/admin/Dashboard.vue') }, + { path: 'ai/knowledge-base', component: () => import('../views/admin/ai/KnowledgeBase.vue') }, + { path: 'profile', component: () => import('../views/Profile.vue') }, + { + path: '403', + component: () => import('../components/Error.vue'), + props: { + code: 403, + title: '抱歉,您没有权限访问该页面', + subTitle: '请联系管理员开通权限,或切换账号后重试。', + }, + }, + { + path: ':pathMatch(.*)*', + component: () => import('../components/Error.vue'), + props: { code: 404 }, + }, + ], + }, + { + path: '/:pathMatch(.*)*', + component: PortalLayout, + children: [ + { + path: '', + component: () => import('../components/Error.vue'), + props: { code: 404 }, + }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +setupGuards(router) + +export default router diff --git a/ui/src/router/setupGuards.js b/ui/src/router/setupGuards.js new file mode 100644 index 0000000..a634bcd --- /dev/null +++ b/ui/src/router/setupGuards.js @@ -0,0 +1,63 @@ +import { useAuthStore } from '../stores/auth' +import { useMenuStore } from '../stores/menu' +import { buildAdminRoutesFromMenus } from './utils' +import { ROUTE_NAMES } from './index' + +export function setupGuards(router) { + router.beforeEach(async (to) => { + const auth = useAuthStore() + const menu = useMenuStore() + + const isAdminPath = to.path.startsWith('/admin') + const isAuthPage = to.path === '/login' || to.path === '/register' + + if (!auth.token) { + if (isAdminPath) return { path: '/login', query: { redirect: to.fullPath } } + return true + } + + if (!auth.meLoaded) { + try { + await auth.fetchMe() + } catch (e) { + const status = e?.response?.status + if (status === 401 || status === 403) { + auth.logout() + menu.reset() + if (isAuthPage) return true + if (isAdminPath) return { path: '/login', query: { redirect: to.fullPath } } + return true + } + throw e + } + } + + if (auth.roles.length === 0) { + if (isAdminPath) return '/portal/home' + return true + } + + if (isAuthPage) return '/admin' + + if (isAdminPath && !menu.routesLoaded) { + try { + await menu.fetchMenus() + } catch (e) { + const status = e?.response?.status + if (status === 401 || status === 403) { + auth.logout() + menu.reset() + return { path: '/login', query: { redirect: to.fullPath } } + } + throw e + } + const adminRoutes = buildAdminRoutesFromMenus(menu.menuTree) + for (const r of adminRoutes) router.addRoute(ROUTE_NAMES.AdminLayout, r) + menu.routesLoaded = true + return { ...to, replace: true } + } + + return true + }) +} + diff --git a/ui/src/router/utils.js b/ui/src/router/utils.js new file mode 100644 index 0000000..b1f0c3e --- /dev/null +++ b/ui/src/router/utils.js @@ -0,0 +1,41 @@ + +const viewModules = import.meta.glob('../views/**/*.vue') + +function resolveView(component) { + if (!component) return undefined + const key = `../views/${component}.vue` + const loader = viewModules[key] + if (!loader) return undefined + return loader +} + +export function buildAdminRoutesFromMenus(menus) { + const routes = [] + + const normalizeAdminChildPath = (rawPath) => { + if (!rawPath) return '' + if (rawPath.startsWith('/admin/')) return rawPath.slice('/admin/'.length) + if (rawPath === '/admin') return '' + return rawPath.startsWith('/') ? rawPath.slice(1) : rawPath + } + + const walk = (nodes) => { + for (const n of nodes) { + if (n.type === 'M') { + const component = resolveView(n.component) + if (!component) continue + const childPath = normalizeAdminChildPath(n.path || '') + if (!childPath) continue + routes.push({ + path: childPath, + component, + meta: { title: n.name, icon: n.icon }, + }) + } + if (n.children?.length) walk(n.children) + } + } + + walk(menus) + return routes +} diff --git a/ui/src/stores/auth.js b/ui/src/stores/auth.js new file mode 100644 index 0000000..c48237f --- /dev/null +++ b/ui/src/stores/auth.js @@ -0,0 +1,57 @@ +import { defineStore } from 'pinia' +import { login, me, register } from '../api/auth' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: localStorage.getItem('token') || '', + meLoaded: false, + userId: 0, + username: '', + nickname: '', + avatarPath: '', + phone: '', + email: '', + gender: 0, + roles: [], + }), + actions: { + async login(req) { + const res = await login(req) + this.token = res.token + localStorage.setItem('token', this.token) + // Login response only has limited info, fetch full profile + await this.fetchMe() + return res + }, + async register(req) { + return register(req) + }, + async fetchMe() { + const res = await me() + this.userId = res.userId + this.username = res.username + this.nickname = res.nickname || '' + this.avatarPath = res.avatarPath || '' + this.phone = res.phone || '' + this.email = res.email || '' + this.gender = res.gender || 0 + this.roles = res.roles || [] + this.meLoaded = true + return res + }, + logout() { + this.token = '' + localStorage.clear() + this.meLoaded = false + this.userId = 0 + this.username = '' + this.nickname = '' + this.avatarPath = '' + this.phone = '' + this.email = '' + this.gender = 0 + this.roles = [] + }, + }, +}) + diff --git a/ui/src/stores/menu.js b/ui/src/stores/menu.js new file mode 100644 index 0000000..82c9bb5 --- /dev/null +++ b/ui/src/stores/menu.js @@ -0,0 +1,20 @@ +import { defineStore } from 'pinia' +import { fetchMenuTree } from '../api/system' + +export const useMenuStore = defineStore('menu', { + state: () => ({ + menuTree: [], + routesLoaded: false, + }), + actions: { + async fetchMenus() { + this.menuTree = await fetchMenuTree() + return this.menuTree + }, + reset() { + this.menuTree = [] + this.routesLoaded = false + }, + }, +}) + diff --git a/ui/src/views/Login.vue b/ui/src/views/Login.vue new file mode 100644 index 0000000..8a83e76 --- /dev/null +++ b/ui/src/views/Login.vue @@ -0,0 +1,269 @@ + + + + + + diff --git a/ui/src/views/Profile.vue b/ui/src/views/Profile.vue new file mode 100644 index 0000000..169aa25 --- /dev/null +++ b/ui/src/views/Profile.vue @@ -0,0 +1,384 @@ + + + + + + + diff --git a/ui/src/views/Register.vue b/ui/src/views/Register.vue new file mode 100644 index 0000000..6950c79 --- /dev/null +++ b/ui/src/views/Register.vue @@ -0,0 +1,271 @@ + + + + + + diff --git a/ui/src/views/admin/Dashboard.vue b/ui/src/views/admin/Dashboard.vue new file mode 100644 index 0000000..e85cfe3 --- /dev/null +++ b/ui/src/views/admin/Dashboard.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/ui/src/views/admin/ai/KnowledgeBase.vue b/ui/src/views/admin/ai/KnowledgeBase.vue new file mode 100644 index 0000000..a972288 --- /dev/null +++ b/ui/src/views/admin/ai/KnowledgeBase.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/ui/src/views/admin/system/Menu.vue b/ui/src/views/admin/system/Menu.vue new file mode 100644 index 0000000..d7b660b --- /dev/null +++ b/ui/src/views/admin/system/Menu.vue @@ -0,0 +1,370 @@ + + + + + + diff --git a/ui/src/views/admin/system/Role.vue b/ui/src/views/admin/system/Role.vue new file mode 100644 index 0000000..81dbe2b --- /dev/null +++ b/ui/src/views/admin/system/Role.vue @@ -0,0 +1,297 @@ + + + + diff --git a/ui/src/views/admin/system/User.vue b/ui/src/views/admin/system/User.vue new file mode 100644 index 0000000..dc1f9b9 --- /dev/null +++ b/ui/src/views/admin/system/User.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/ui/src/views/portal/About.vue b/ui/src/views/portal/About.vue new file mode 100644 index 0000000..7d0393f --- /dev/null +++ b/ui/src/views/portal/About.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/ui/src/views/portal/Chat.vue b/ui/src/views/portal/Chat.vue new file mode 100644 index 0000000..6202f60 --- /dev/null +++ b/ui/src/views/portal/Chat.vue @@ -0,0 +1,872 @@ + + + + + diff --git a/ui/src/views/portal/Home.vue b/ui/src/views/portal/Home.vue new file mode 100644 index 0000000..03035cb --- /dev/null +++ b/ui/src/views/portal/Home.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/ui/src/views/portal/Monitor.vue b/ui/src/views/portal/Monitor.vue new file mode 100644 index 0000000..c16c872 --- /dev/null +++ b/ui/src/views/portal/Monitor.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/ui/vite.config.js b/ui/vite.config.js new file mode 100644 index 0000000..59e16fd --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,13 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + } +}) diff --git a/数据库说明文档.md b/数据库说明文档.md new file mode 100644 index 0000000..22bcc76 --- /dev/null +++ b/数据库说明文档.md @@ -0,0 +1,207 @@ +# Hertz 权限管理系统数据库说明文档 + +## 1. 数据库概述 + +- **数据库名称**: `hertz_springboot` +- **数据库类型**: MySQL +- **字符集**: utf8mb4 +- **排序规则**: utf8mb4_general_ci (推荐) + +> 说明:知识库(RAG)功能除了数据库表外,还依赖 Ollama 的向量化模型(Embedding)。首次使用前需在运行后端的机器执行 `ollama pull nomic-embed-text`,并保持 `application.yml` 中 `spring.ai.ollama.embedding.model` 与实际模型一致。 + +## 2. ER 图设计概要 + +系统采用 RBAC(用户-角色-权限)为核心,并扩展了监控、AI 对话、知识库等模块。主要表如下: + +- **RBAC 权限模型** + - `sys_user`: 用户表 + - `sys_role`: 角色表 + - `sys_menu`: 菜单/权限表 + - `sys_user_role`: 用户-角色关联表 + - `sys_role_menu`: 角色-菜单关联表 +- **监控模块** + - `sys_monitor_log`: 系统监控日志表 +- **AI 助手模块** + - `ai_conversations`: 对话记录表 + - `ai_messages`: 对话消息表 +- **知识库模块(RAG)** + - `knowledge_base`: 知识库表 + - `knowledge_document`: 知识库文档表(上传记录/存储路径/向量化状态) + +## 3. 表结构详解 + +### 3.1 系统用户表 (sys_user) + +存储系统的登录用户信息。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| username | varchar | 50 | NO | | 用户名 (唯一) | +| password | varchar | 100 | NO | | 加密密码 (BCrypt) | +| nickname | varchar | 50 | NO | | 用户昵称 | +| avatar_path | varchar | 255 | YES | NULL | 头像路径 | +| phone | varchar | 20 | YES | NULL | 手机号 | +| email | varchar | 100 | YES | NULL | 邮箱 | +| gender | tinyint | 1 | YES | 0 | 0-未知 1-男 2-女 | +| status | tinyint | 4 | YES | 1 | 0-禁用 1-启用 | +| created_at | datetime | | YES | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | datetime | | YES | CURRENT_TIMESTAMP | 更新时间 | + +### 3.2 系统角色表 (sys_role) + +存储系统的角色信息。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| role_key | varchar | 50 | NO | | 角色标识 (唯一,如 ADMIN) | +| role_name | varchar | 50 | NO | | 角色名称 (如 管理员) | +| status | tinyint | 4 | YES | 1 | 0-禁用 1-启用 | +| created_at | datetime | | YES | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | datetime | | YES | CURRENT_TIMESTAMP | 更新时间 | + +### 3.3 系统菜单表 (sys_menu) + +存储菜单和按钮权限信息,支持树形结构。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| parent_id | bigint | 20 | YES | 0 | 父菜单 ID (0为顶级) | +| type | varchar | 10 | NO | | 菜单类型: D-目录 M-菜单 B-按钮 | +| name | varchar | 50 | NO | | 菜单名称/按钮名称 | +| path | varchar | 200 | YES | NULL | 路由路径 (前端路由) | +| component | varchar | 200 | YES | NULL | 组件路径 (Vue组件) | +| perms | varchar | 100 | YES | NULL | 权限标识 (如 system:user:view) | +| icon | varchar | 100 | YES | NULL | 菜单图标 | +| sort | int | 11 | YES | 0 | 排序 (数值越小越靠前) | +| visible | tinyint | 4 | YES | 1 | 0-隐藏 1-显示 | +| status | tinyint | 4 | YES | 1 | 0-禁用 1-启用 | +| created_at | datetime | | YES | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | datetime | | YES | CURRENT_TIMESTAMP | 更新时间 | + +### 3.4 用户角色关联表 (sys_user_role) + +用户与角色的多对多关系表。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| user_id | bigint | 20 | NO | | 用户 ID | +| role_id | bigint | 20 | NO | | 角色 ID | + +### 3.5 角色菜单关联表 (sys_role_menu) + +角色与菜单(权限)的多对多关系表。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| role_id | bigint | 20 | NO | | 角色 ID | +| menu_id | bigint | 20 | NO | | 菜单 ID | + +### 3.6 系统监控日志表 (sys_monitor_log) + +存储系统监控的日志信息。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| cpu_usage | double | | NO | | CPU使用率(%) | +| memory_usage | double | | NO | | 内存使用率(%) | +| memory_total | bigint | | NO | | 总内存(字节) | +| memory_used | bigint | | NO | | 已用内存(字节) | +| created_at | datetime | | NO | CURRENT_TIMESTAMP | 记录时间 | + +### 3.7 对话记录表 (ai_conversations) + +存储 AI 助手的对话会话记录。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| user_id | bigint | 20 | NO | | 用户 ID | +| title | varchar | 255 | NO | | 对话标题 | +| created_at | datetime | | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | datetime | | NO | CURRENT_TIMESTAMP | 更新时间 | + +### 3.8 对话消息表 (ai_messages) + +存储对话的具体消息内容。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| conversation_id | bigint | 20 | NO | | 所属对话 ID | +| role | enum | | NO | | 消息角色 (user/assistant) | +| content | text | | NO | | 消息内容 | +| created_at | datetime | | NO | CURRENT_TIMESTAMP | 创建时间 | + +### 3.9 知识库表 (knowledge_base) + +存储知识库的基本信息(名称、描述、创建时间等)。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| name | varchar | 255 | NO | | 知识库名称 | +| description | text | | YES | NULL | 知识库描述 | +| create_time | datetime | | YES | CURRENT_TIMESTAMP | 创建时间 | +| create_by | bigint | 20 | YES | NULL | 创建人用户 ID | + +### 3.10 知识库文档表 (knowledge_document) + +存储知识库文档上传记录与落盘信息,用于“可管理、可回溯、可重建向量”。 + +| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID | +| kb_id | bigint | 20 | NO | | 所属知识库 ID | +| original_name | varchar | 512 | NO | | 原始文件名 | +| stored_name | varchar | 512 | NO | | 存储文件名(UUID) | +| stored_path | varchar | 1024 | NO | | 存储相对路径(位于 uploads 根目录下) | +| content_type | varchar | 255 | YES | NULL | MIME 类型 | +| size_bytes | bigint | 20 | NO | | 文件大小(字节) | +| sha256 | varchar | 64 | YES | NULL | 文件哈希(用于去重/审计) | +| status | varchar | 32 | NO | | 处理状态(PROCESSING/READY/FAILED) | +| chunk_count | int | 11 | YES | 0 | 分片数量 | +| error_message | varchar | 1024 | YES | NULL | 失败原因 | +| create_time | datetime | | YES | CURRENT_TIMESTAMP | 上传时间 | + +## 4. 初始数据说明 + +系统初始化脚本 `db/init.sql` 会预置 RBAC 与 AI 对话相关表结构和初始数据。监控与知识库相关表请额外导入: + +- `src/main/resources/schema/monitor_schema.sql` +- `src/main/resources/schema/knowledge_schema.sql` + +1. **初始角色**: + - `ADMIN`: 超级管理员,拥有所有权限。 + +2. **初始菜单结构**: + - 仪表盘 + - 系统管理 + - 用户管理 + - 角色管理 + - 菜单管理 + +3. **初始用户**: + - `hertz`: 管理员账号,已绑定 ADMIN 角色。 + - `demo`: 演示账号。 + +## 5. 数据字典与枚举 + +- **用户/角色/菜单状态 (status)**: + - `1`: 启用 (Normal) + - `0`: 禁用 (Disabled) + +- **菜单类型 (type)**: + - `D`: 目录 (Directory) - 不对应具体页面,仅用于分组 + - `M`: 菜单 (Menu) - 对应具体的前端页面 + - `B`: 按钮 (Button) - 页面内的功能按钮,用于权限控制 + +- **性别 (gender)**: + - `0`: 未知 + - `1`: 男 + - `2`: 女 diff --git a/项目说明文档.md b/项目说明文档.md new file mode 100644 index 0000000..f14ce70 --- /dev/null +++ b/项目说明文档.md @@ -0,0 +1,137 @@ +# Hertz 权限管理系统项目说明文档 + +## 1. 项目简介 + +Hertz 权限管理系统是一个基于前后端分离架构的轻量级权限管理平台。系统集成了用户管理、角色管理、菜单管理等核心功能,采用 RBAC(Role-Based Access Control)模型实现细粒度的权限控制。 + +### 1.1 项目结构 + +项目采用典型的多模块(或目录分离)结构: + +- **src/**: 后端工程源码,基于 Spring Boot 3 + MyBatis-Plus。 +- **ui/**: 前端工程源码,基于 Vue 3 + Vite + Element Plus。 + +## 2. 技术栈 + +### 2.1 后端技术栈 (hertz_springboot) + +- **核心框架**: Spring Boot 3.4.1 +- **持久层框架**: MyBatis-Plus 3.5.8 +- **安全框架**: Spring Security + JJWT 0.12.6 (实现无状态 JWT 认证) +- **数据库连接池**: HikariCP (Spring Boot 默认) +- **数据库驱动**: MySQL Connector/J +- **工具库**: Lombok +- **运行环境**: Java 21 + +### 2.2 前端技术栈 (hertz_springboot_ui) + +- **核心框架**: Vue 3.5.24 +- **构建工具**: Vite 7.2.4 +- **UI 组件库**: Element Plus 2.13.1 +- **状态管理**: Pinia 3.0.4 +- **路由管理**: Vue Router 4.6.4 +- **HTTP 客户端**: Axios 1.13.2 + +## 3. 功能模块 + +1. **认证模块**: 支持用户登录、注册(可选)、JWT Token 颁发与验证。 +2. **系统管理**: + - **用户管理**: 用户的增删改查、分配角色、状态控制。 + - **角色管理**: 角色的增删改查、分配菜单权限。 + - **菜单管理**: 动态菜单配置,支持目录、菜单、按钮三种类型,支持权限标识配置。 +3. **个人中心**: 用户资料修改、密码修改、头像上传。 +4. **监控模块**: + - **系统监控**: 实时展示 CPU、内存、JVM、服务器信息、磁盘状态。 + - **监控日志**: 记录系统运行时的性能指标。 +5. **AI 助手模块**: + - **智能对话**: 集成 Spring AI (Ollama),支持与 AI 助手进行智能对话。 + - **会话管理**: 支持历史会话的持久化存储与查询。 + - **知识库 (RAG)**: 支持创建知识库、上传/管理文档、向量化检索增强回答。 + +## 4. 快速开始 + +### 4.1 环境准备 + +- JDK 21+ +- Node.js 18+ +- MySQL 8.0+ + +### 4.2 后端启动 + +1. 配置数据库: + - 创建数据库 `hertz_springboot`。 + - 导入 `db/init.sql` 初始化基础表结构和数据(RBAC、AI 对话等)。 + - 额外导入以下脚本以启用监控与知识库功能: + - `src/main/resources/schema/monitor_schema.sql` + - `src/main/resources/schema/knowledge_schema.sql` + - 修改 `src/main/resources/application.yml` 中的数据库连接信息(url, username, password)。 +2. 运行项目: + ```bash + mvn spring-boot:run + ``` + 或者在 IDE 中运行 `HertzApplication.java`。 + 后端服务将启动在 `http://localhost:8088`。 + +### 4.4 AI(Ollama)准备 + +AI 助手与知识库(RAG)依赖 Ollama。首次启动前请确保已安装并运行 Ollama,并拉取所需模型: + +1. 向量化模型(Embedding,用于知识库): + ```bash + ollama pull nomic-embed-text + ``` +2. 聊天模型(用于对话,按配置决定,例如): + ```bash + ollama pull deepseek-llm:7b + ``` + +对应配置项位于 `src/main/resources/application.yml`: + +- `spring.ai.ollama.chat.model` +- `spring.ai.ollama.embedding.model` + +### 4.3 前端启动 + +1. 进入前端目录: + ```bash + cd ui + ``` +2. 安装依赖: + ```bash + npm install + ``` +3. 启动开发服务器: + ```bash + npm run dev + ``` + 前端服务通常启动在 `http://localhost:5173`。 + +## 5. 默认账号 + +| 角色 | 用户名 | 密码 | 权限 | +| :--- | :--- | :--- | :--- | +| **管理员** | `hertz` | `hertz` | 拥有所有系统权限 | +| **普通用户** | `demo` | `123456` | 仅拥有基本查看权限 | + +## 6. 配置说明 + +### 6.1 后端配置 (application.yml) + +- **Server Port**: 8088 +- **File Upload**: + - 最大文件大小: 2MB + - 最大请求大小: 10MB + - 上传根路径: `./uploads`(项目根目录下的 uploads 目录) + - 头像文件目录: `./uploads/avatar/yyyy/MM/` + - 知识库文件目录: `./uploads/knowledge/kb-{kbId}/yyyy/MM/` +- **Vector Store**: + - 向量库目录: `./uploads/vector_store/` + - 按知识库分别存储: `vector-store-kb-{kbId}.json` +- **JWT**: + - 密钥: `app.jwt.secret` (建议在生产环境中修改为强随机字符串) + - 过期时间: 86400秒 (24小时) + +## 7. 注意事项 + +- **文件上传**: 默认配置了本地文件存储路径,请确保该路径存在或有写入权限,或者在 `application.yml` 中修改为合适的路径。 +- **跨域**: 前端开发环境通常通过 Vite 代理解决跨域问题,生产环境需配置 Nginx 或后端 CORS。