This commit is contained in:
2026-01-22 17:33:28 +08:00
parent 1bbf177b2c
commit b46759dc73
105 changed files with 2929 additions and 433 deletions

77
.gitignore vendored
View File

@@ -1,56 +1,45 @@
# --- Common --- # IDEs
.DS_Store
Thumbs.db
Desktop.ini
*.log
*.tmp
*.bak
*.swp
# --- IDEs & Editors ---
.idea/ .idea/
.vscode/ .vscode/
*.iml *.iml
*.ipr
*.iws *.iws
*.ipr
.classpath .classpath
.project .project
.settings/ .settings/
.factorypath .factorypath
# --- Java / Maven (Backend) --- # OS
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
# Maven
target/ target/
*.class .mvn/
*.jar mvnw
*.war mvnw.cmd
*.ear *.log
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# --- Node / Vue (Frontend) --- # Frontend (Vue/Vite)
node_modules/ ui/node_modules/
dist/ ui/dist/
dist-ssr/ ui/npm-debug.log*
coverage/ ui/yarn-debug.log*
*.local ui/yarn-error.log*
.npm/ ui/pnpm-debug.log*
# Logs ui/.env.local
npm-debug.log* ui/.env.*.local
yarn-debug.log* ui/.DS_Store
yarn-error.log* ui/coverage/
pnpm-debug.log* ui/.nyc_output/
# --- Application Specific --- # Application Logs
# Uploaded files logs/
uploads/ *.log
# Ignore local environment override files if any
.env.local # Temp files
.env.development.local *.tmp
.env.test.local *.bak
.env.production.local *.swp

View File

@@ -11,12 +11,48 @@
Target Server Version : 80040 (8.0.40) Target Server Version : 80040 (8.0.40)
File Encoding : 65001 File Encoding : 65001
Date: 20/01/2026 15:33:14 Date: 22/01/2026 14:58:04
*/ */
SET NAMES utf8mb4; SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0; 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 = 38 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 = 159 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '对话消息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of ai_messages
-- ----------------------------
-- ---------------------------- -- ----------------------------
-- Table structure for sys_menu -- Table structure for sys_menu
-- ---------------------------- -- ----------------------------
@@ -107,13 +143,13 @@ CREATE TABLE `sys_user` (
PRIMARY KEY (`id`) USING BTREE, PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_username`(`username` ASC) USING BTREE, UNIQUE INDEX `uk_username`(`username` ASC) USING BTREE,
INDEX `idx_status`(`status` ASC) USING BTREE INDEX `idx_status`(`status` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统用户表' ROW_FORMAT = Dynamic; ) ENGINE = InnoDB AUTO_INCREMENT = 24 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统用户表' ROW_FORMAT = Dynamic;
-- ---------------------------- -- ----------------------------
-- Records of sys_user -- 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-20 15:33:01'); INSERT INTO `sys_user` VALUES (1, 'hertz', '$2a$10$Gker6.ggCxG3wfZ13rE/Eu7aDnB.DX2JmP6h6vct30RTtBr9.q5Pq', '赫兹', '/uploads/avatar/2026/01/20/56b8d363d1c743d6afb749faa0b4f5cb.png', '18888888888', 'hertz@hertz.com', 1, 1, '2026-01-19 17:30:21', '2026-01-22 14:29:46');
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-20 15:33:04'); INSERT INTO `sys_user` VALUES (2, 'demo', '$2a$10$PSIz9pWXAwXfB32HWSxTjeGhVi0bixsSKxzeX8YAdKnRRXPxJC3Xe', 'demo', '/uploads/avatar/2026/01/20/e19931fe65664b1ba31bf3db85cc6519.jpg', '13888888888', 'demo@hertz.com', 1, 1, '2026-01-19 17:30:21', '2026-01-20 17:57:47');
-- ---------------------------- -- ----------------------------
-- Table structure for sys_user_role -- Table structure for sys_user_role

View File

@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,269 +0,0 @@
<template>
<div>
<el-card>
<template #header>菜单管理</template>
<div style="margin-bottom: 12px">
<el-button type="primary" @click="load">刷新</el-button>
<el-button @click="openCreate(0)" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增菜单</el-button>
</div>
<el-table
:data="menuTree"
v-loading="loading"
style="width: 100%"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
>
<el-table-column prop="name" label="菜单名称" width="200" />
<el-table-column prop="icon" label="图标" width="60">
<template #default="{ row }">
<component :is="row.icon" v-if="row.icon" style="width: 16px; height: 16px" />
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }">
<el-tag v-if="row.type === 'D'">目录</el-tag>
<el-tag v-else-if="row.type === 'M'" type="success">菜单</el-tag>
<el-tag v-else type="info">按钮</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" />
<el-table-column prop="component" label="组件路径" />
<el-table-column prop="perms" label="权限标识" />
<el-table-column prop="sort" label="排序" width="60" />
<!-- <el-table-column prop="visible" label="可见" width="70">
<template #default="{ row }">
<el-tag :type="row.visible === 1 ? 'success' : 'info'">{{ row.visible === 1 ? '显示' : '隐藏' }}</el-tag>
</template>
</el-table-column> -->
<el-table-column label="操作" width="160">
<template #default="{ row }">
<div style="display: flex; align-items: center">
<el-tooltip content="编辑" placement="top">
<el-button type="primary" text bg :icon="EditPen" style="font-size: 15px; padding: 6px 8px" @click="openEdit(row)" />
</el-tooltip>
<el-tooltip content="新增子项" placement="top">
<el-button type="primary" text bg :icon="Plus" style="font-size: 15px; padding: 6px 8px" @click="openCreate(row.id)" />
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button type="danger" text bg :icon="Delete" style="font-size: 15px; padding: 6px 8px" @click="handleDelete(row)" />
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogOpen" :title="formData.id ? '编辑菜单' : '新增菜单'" width="600px">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" v-loading="formLoading">
<el-form-item label="上级菜单" prop="parentId">
<el-tree-select
v-model="formData.parentId"
:data="menuOptions"
:props="{ label: 'name', children: 'children' }"
node-key="id"
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单类型" prop="type">
<el-radio-group v-model="formData.type">
<el-radio value="D">目录</el-radio>
<el-radio value="M">菜单</el-radio>
<el-radio value="B">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="菜单名称" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item label="路由路径" prop="path" v-if="formData.type !== 'B'">
<el-input v-model="formData.path" placeholder="例如:/system/user" />
</el-form-item>
<el-form-item label="组件路径" prop="component" v-if="formData.type === 'M'">
<el-input v-model="formData.component" placeholder="例如admin/system/User" />
</el-form-item>
<el-form-item label="权限标识" prop="perms" v-if="formData.type !== 'D'">
<el-input v-model="formData.perms" placeholder="例如system:user:view" />
</el-form-item>
<el-form-item label="图标" prop="icon" v-if="formData.type !== 'B'">
<el-input v-model="formData.icon" placeholder="Element Plus Icon Name">
<template #prefix>
<component :is="formData.icon" v-if="formData.icon" style="width: 16px; height: 16px" />
</template>
</el-input>
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" />
</el-form-item>
</el-col>
<!-- <el-col :span="12">
<el-form-item label="显示状态" prop="visible" v-if="formData.type !== 'B'">
<el-radio-group v-model="formData.visible">
<el-radio :value="1">显示</el-radio>
<el-radio :value="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-col> -->
</el-row>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogOpen = false">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="saveMenu">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, EditPen, Plus } from '@element-plus/icons-vue'
import {
createMenu,
deleteMenu,
fetchMenuTree,
updateMenu,
} from '../../../api/system'
import { useMenuStore } from '../../../stores/menu'
const menuTree = ref([])
const loading = ref(false)
const menuStore = useMenuStore()
async function load() {
loading.value = true
try {
// The backend's /tree endpoint returns hierarchical data, which is perfect for el-table tree-props.
// If we wanted pagination, we would use pageMenus(), but for menus, a full tree is usually better
// to visualize structure. Since the requirement mentioned "page query" for menu management,
// we can either switch to flat table with pagination or keep the tree view (which is standard for menus).
// Given the UI is "Menu Management", a Tree Table is the best UX.
// However, the backend `MenuController.page()` returns a flat list (or tree? checking logic).
// Wait, the backend implementation of `pageMenus` returns `IPage<SysMenu>`, which is a flat list of menus sorted by sort.
// But for a hierarchical display, we need a tree.
// Let's use `fetchMenuTree` (which is already implemented) for the main view to support parent-child relationships clearly.
// If the user *strictly* wants pagination, we can switch, but tree view is superior for menus.
// Let's stick to Tree Table using `fetchMenuTree` as it's more functional for this use case.
menuTree.value = await fetchMenuTree()
} finally {
loading.value = false
}
}
// Form
const dialogOpen = ref(false)
const formLoading = ref(false)
const formRef = ref(null)
const formData = reactive({
id: undefined,
parentId: 0,
type: 'M',
name: '',
path: '',
component: '',
perms: '',
icon: '',
sort: 0,
visible: 1,
status: 1,
})
const rules = reactive({
name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
})
// We need a flat list or tree for the "Parent Menu" selector.
// We can reuse `menuTree` for the TreeSelect.
// We should insert a "Root" node option.
const menuOptions = ref([])
function updateMenuOptions() {
const root = { id: 0, name: '主类目', children: [] }
// Deep copy menuTree to avoid modifying the view
// And maybe disable self and children to prevent cycles (if editing)
menuOptions.value = [root, ...menuTree.value]
}
function openCreate(parentId = 0) {
formData.id = undefined
formData.parentId = parentId
formData.type = 'M'
formData.name = ''
formData.path = ''
formData.component = ''
formData.perms = ''
formData.icon = ''
formData.sort = 0
formData.visible = 1
formData.status = 1
updateMenuOptions()
dialogOpen.value = true
}
function openEdit(row) {
Object.assign(formData, row)
if (formData.parentId === null || formData.parentId === undefined) {
formData.parentId = 0
}
updateMenuOptions()
dialogOpen.value = true
}
async function saveMenu() {
if (formRef.value) {
await formRef.value.validate()
} else {
return
}
formLoading.value = true
try {
if (formData.id) {
await updateMenu(formData)
ElMessage.success('更新成功')
} else {
await createMenu(formData)
ElMessage.success('创建成功')
}
dialogOpen.value = false
load()
// Refresh the sidebar menu store as well
menuStore.routesLoaded = false
await menuStore.fetchMenus()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
formLoading.value = false
}
}
function handleDelete(row) {
ElMessageBox.confirm(`确认删除菜单 "${row.name}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await deleteMenu(row.id)
ElMessage.success('删除成功')
load()
// Refresh the sidebar menu store as well
menuStore.routesLoaded = false
await menuStore.fetchMenus()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '删除失败')
}
})
}
onMounted(load)
</script>

View File

@@ -1,8 +0,0 @@
<template>
<el-card>
<template #header>关于</template>
<div>Spring Boot 3 + Vue 3 + Element Plus + MyBatis-Plus + RBAC 动态菜单</div>
</el-card>
</template>
<script setup></script>

View File

@@ -12,9 +12,9 @@
</parent> </parent>
<groupId>com.hertz</groupId> <groupId>com.hertz</groupId>
<artifactId>hertz-springboot-backend</artifactId> <artifactId>hertz-springboot</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>hertz-springboot-backend</name> <name>hertz-springboot</name>
<description>Hertz 权限管理系统后端</description> <description>Hertz 权限管理系统后端</description>
<properties> <properties>
@@ -22,8 +22,21 @@
<maven.compiler.release>21</maven.compiler.release> <maven.compiler.release>21</maven.compiler.release>
<mybatis-plus.version>3.5.8</mybatis-plus.version> <mybatis-plus.version>3.5.8</mybatis-plus.version>
<jjwt.version>0.12.6</jjwt.version> <jjwt.version>0.12.6</jjwt.version>
<spring-ai.version>1.0.0-M5</spring-ai.version>
</properties> </properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -38,6 +51,15 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
@@ -74,6 +96,23 @@
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<!-- System Monitor -->
<dependency>
<groupId>com.github.oshi</groupId>
<artifactId>oshi-core</artifactId>
<version>6.6.5</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@@ -86,6 +125,17 @@
</dependency> </dependency>
</dependencies> </dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>

View File

@@ -0,0 +1,84 @@
package com.hertz.ai.controller;
import com.hertz.ai.dto.ChatRequest;
import com.hertz.ai.entity.Conversation;
import com.hertz.ai.entity.Message;
import com.hertz.ai.service.AiService;
import com.hertz.ai.service.ConversationService;
import com.hertz.common.api.ApiResponse;
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<String> chat(@RequestBody ChatRequest request) {
if (request.getConversationId() != null) {
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage());
}
String response = aiService.chat(request.getMessage(), request.getTemperature());
if (request.getConversationId() != null) {
conversationService.saveMessage(request.getConversationId(), "assistant", response);
}
return ApiResponse.ok(response);
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody ChatRequest request) {
// Frontend handles message persistence
return aiService.streamChat(request.getMessage(), request.getTemperature());
}
@PostMapping("/conversations")
public ApiResponse<Conversation> createConversation(@RequestBody Conversation conversation) {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResponse.ok(conversationService.createConversation(conversation.getTitle(), userId));
}
@GetMapping("/conversations")
public ApiResponse<List<Conversation>> listConversations() {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResponse.ok(conversationService.getConversations(userId));
}
@DeleteMapping("/conversations/{id}")
public ApiResponse<Void> deleteConversation(@PathVariable Long id) {
Long userId = SecurityUtils.getCurrentUserId();
conversationService.deleteConversation(id, userId);
return ApiResponse.ok();
}
@PutMapping("/conversations/{id}")
public ApiResponse<Void> 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<List<Conversation>> searchConversations(@RequestParam String query) {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResponse.ok(conversationService.searchConversations(query, userId));
}
@PostMapping("/conversations/{id}/messages")
public ApiResponse<Message> saveMessage(@PathVariable Long id, @RequestBody Message message) {
return ApiResponse.ok(conversationService.saveMessage(id, message.getRole(), message.getContent()));
}
@GetMapping("/conversations/{id}/messages")
public ApiResponse<List<Message>> getMessages(@PathVariable Long id) {
return ApiResponse.ok(conversationService.getMessages(id));
}
}

View File

@@ -0,0 +1,10 @@
package com.hertz.ai.dto;
import lombok.Data;
@Data
public class ChatRequest {
private String message;
private Double temperature;
private Long conversationId;
}

View File

@@ -0,0 +1,18 @@
package com.hertz.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;
}

View File

@@ -0,0 +1,21 @@
package com.hertz.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;
}

View File

@@ -0,0 +1,9 @@
package com.hertz.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.ai.entity.Conversation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ConversationMapper extends BaseMapper<Conversation> {
}

View File

@@ -0,0 +1,9 @@
package com.hertz.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hertz.ai.entity.Message;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MessageMapper extends BaseMapper<Message> {
}

View File

@@ -0,0 +1,8 @@
package com.hertz.ai.service;
import reactor.core.publisher.Flux;
public interface AiService {
String chat(String message, Double temperature);
Flux<String> streamChat(String message, Double temperature);
}

View File

@@ -0,0 +1,16 @@
package com.hertz.ai.service;
import com.hertz.ai.entity.Conversation;
import com.hertz.ai.entity.Message;
import java.util.List;
public interface ConversationService {
Conversation createConversation(String title, Long userId);
List<Conversation> getConversations(Long userId);
void deleteConversation(Long id, Long userId);
List<Conversation> searchConversations(String query, Long userId);
Message saveMessage(Long conversationId, String role, String content);
List<Message> getMessages(Long conversationId);
Conversation getConversation(Long id);
void updateConversationTitle(Long id, String title, Long userId);
}

View File

@@ -0,0 +1,71 @@
package com.hertz.ai.service.impl;
import com.hertz.ai.service.AiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class AiServiceImpl implements AiService {
private final OllamaChatModel chatModel;
private static final String SYSTEM_PROMPT = "你是赫兹官网的AI助手。赫兹是一个基于Spring Boot的权限控制管理系统框架用于构建高性能、可扩展的应用程序。" +
"你可以回答关于赫兹的问题,如:赫兹的架构、性能优化、微服务设计模式等。" +
"如果用户有其他问题,也请尽力回答。";
@Override
public String chat(String message, Double temperature) {
long startTime = System.currentTimeMillis();
try {
log.info("Starting AI chat request. Message length: {}", message.length());
OllamaOptions options = OllamaOptions.builder()
.temperature(temperature != null ? temperature : 0.7)
.build();
List<Message> messages = List.of(
new SystemMessage(SYSTEM_PROMPT),
new UserMessage(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<String> streamChat(String message, Double temperature) {
OllamaOptions options = OllamaOptions.builder()
.temperature(temperature != null ? temperature : 0.7)
.build();
List<Message> messages = List.of(
new SystemMessage(SYSTEM_PROMPT),
new UserMessage(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() + "]"));
}
}

View File

@@ -0,0 +1,106 @@
package com.hertz.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hertz.ai.entity.Conversation;
import com.hertz.ai.entity.Message;
import com.hertz.ai.mapper.ConversationMapper;
import com.hertz.ai.mapper.MessageMapper;
import com.hertz.ai.service.ConversationService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
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<Conversation> getConversations(Long userId) {
return conversationMapper.selectList(new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getUserId, userId)
.orderByDesc(Conversation::getUpdatedAt));
}
@Override
@Transactional
public void deleteConversation(Long id, Long userId) {
Conversation conversation = conversationMapper.selectById(id);
if (conversation != null && conversation.getUserId().equals(userId)) {
messageMapper.delete(new LambdaQueryWrapper<Message>()
.eq(Message::getConversationId, id));
conversationMapper.deleteById(id);
} else {
throw new RuntimeException("Conversation not found or access denied");
}
}
@Override
public List<Conversation> searchConversations(String query, Long userId) {
return conversationMapper.selectList(new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getUserId, userId)
.like(Conversation::getTitle, query)
.orderByDesc(Conversation::getUpdatedAt));
}
@Override
@Transactional
public Message saveMessage(Long conversationId, String role, String content) {
Message message = new Message();
message.setConversationId(conversationId);
message.setRole(role);
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
messageMapper.insert(message);
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation != null) {
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.updateById(conversation);
}
return message;
}
@Override
public List<Message> getMessages(Long conversationId) {
return messageMapper.selectList(new LambdaQueryWrapper<Message>()
.eq(Message::getConversationId, conversationId)
.orderByAsc(Message::getCreatedAt));
}
@Override
public Conversation getConversation(Long id) {
return conversationMapper.selectById(id);
}
@Override
@Transactional
public void updateConversationTitle(Long id, String title, Long userId) {
Conversation conversation = conversationMapper.selectById(id);
if (conversation != null && conversation.getUserId().equals(userId)) {
conversation.setTitle(title);
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.updateById(conversation);
} else {
throw new RuntimeException("Conversation not found or access denied");
}
}
}

View File

@@ -54,6 +54,7 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleException(Exception e) { public ApiResponse<Void> handleException(Exception e) {
e.printStackTrace(); // 打印堆栈信息到控制台
return ApiResponse.fail(50000, "系统异常"); return ApiResponse.fail(50000, "系统异常");
} }
} }

View File

@@ -0,0 +1,35 @@
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.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Slf4j
public class RequestLogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 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);
}
}
}

View File

@@ -0,0 +1,22 @@
package com.hertz.monitor.controller;
import com.hertz.common.api.ApiResponse;
import com.hertz.monitor.dto.MonitorDto;
import com.hertz.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<MonitorDto> getServerInfo() {
return ApiResponse.ok(monitorService.getServerInfo());
}
}

View File

@@ -0,0 +1,89 @@
package com.hertz.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<DiskInfo> 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;
}
}

View File

@@ -0,0 +1,7 @@
package com.hertz.monitor.service;
import com.hertz.monitor.dto.MonitorDto;
public interface MonitorService {
MonitorDto getServerInfo();
}

View File

@@ -0,0 +1,165 @@
package com.hertz.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.monitor.dto.MonitorDto;
import com.hertz.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<GarbageCollectorMXBean> 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<MonitorDto.DiskInfo> getDiskInfo(OperatingSystem os) {
List<MonitorDto.DiskInfo> list = new ArrayList<>();
FileSystem fileSystem = os.getFileSystem();
List<OSFileStore> 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;
}
}

View File

@@ -0,0 +1,33 @@
package com.hertz.security;
import com.hertz.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();
}
}

View File

@@ -47,7 +47,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
var auth = new UsernamePasswordAuthenticationToken(username, null, authorities); var auth = new UsernamePasswordAuthenticationToken(username, null, authorities);
auth.setDetails(userId); auth.setDetails(userId);
SecurityContextHolder.getContext().setAuthentication(auth); SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception ignored) { // 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(); SecurityContextHolder.clearContext();
} }

View File

@@ -1,5 +1,6 @@
package com.hertz.security; package com.hertz.security;
import jakarta.servlet.DispatcherType;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@@ -47,6 +48,7 @@ public class SecurityConfig {
.cors(Customizer.withDefaults()) .cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
.requestMatchers("/api/auth/**", "/error", "/uploads/**").permitAll() .requestMatchers("/api/auth/**", "/error", "/uploads/**").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()

View File

@@ -6,6 +6,7 @@ import com.hertz.security.JwtService;
import com.hertz.security.SecurityUtils; import com.hertz.security.SecurityUtils;
import com.hertz.system.dto.AuthDtos; import com.hertz.system.dto.AuthDtos;
import com.hertz.system.mapper.SysRoleMapper; import com.hertz.system.mapper.SysRoleMapper;
import com.hertz.system.service.CaptchaService;
import com.hertz.system.service.UserService; import com.hertz.system.service.UserService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
@@ -24,21 +25,25 @@ public class AuthController {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtService jwtService; private final JwtService jwtService;
private final SysRoleMapper roleMapper; private final SysRoleMapper roleMapper;
private final CaptchaService captchaService;
public AuthController( public AuthController(
UserService userService, UserService userService,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
JwtService jwtService, JwtService jwtService,
SysRoleMapper roleMapper SysRoleMapper roleMapper,
CaptchaService captchaService
) { ) {
this.userService = userService; this.userService = userService;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService; this.jwtService = jwtService;
this.roleMapper = roleMapper; this.roleMapper = roleMapper;
this.captchaService = captchaService;
} }
@PostMapping("/register") @PostMapping("/register")
public ApiResponse<AuthDtos.MeResponse> register(@Valid @RequestBody AuthDtos.RegisterRequest req) { public ApiResponse<AuthDtos.MeResponse> register(@Valid @RequestBody AuthDtos.RegisterRequest req) {
captchaService.validateCaptcha(req.uuid(), req.code());
var user = userService.register(req.username(), req.password(), req.nickname()); var user = userService.register(req.username(), req.password(), req.nickname());
return ApiResponse.ok(new AuthDtos.MeResponse( return ApiResponse.ok(new AuthDtos.MeResponse(
user.getId(), user.getId(),
@@ -54,6 +59,7 @@ public class AuthController {
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<AuthDtos.LoginResponse> login(@Valid @RequestBody AuthDtos.LoginRequest req) { public ApiResponse<AuthDtos.LoginResponse> login(@Valid @RequestBody AuthDtos.LoginRequest req) {
captchaService.validateCaptcha(req.uuid(), req.code());
var user = userService.findByUsername(req.username()); var user = userService.findByUsername(req.username());
if (user == null || user.getStatus() == null || user.getStatus() != 1) { if (user == null || user.getStatus() == null || user.getStatus() != 1) {
throw new BusinessException(40003, "用户名或密码错误"); throw new BusinessException(40003, "用户名或密码错误");

View File

@@ -0,0 +1,25 @@
package com.hertz.system.controller;
import com.hertz.common.api.ApiResponse;
import com.hertz.system.dto.AuthDtos;
import com.hertz.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<AuthDtos.CaptchaResponse> getCaptcha() {
var res = captchaService.generateCaptcha();
return ApiResponse.ok(res);
}
}

View File

@@ -135,6 +135,13 @@ public class UserController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
@DeleteMapping
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:remove')")
public ApiResponse<Void> deleteBatch(@RequestBody List<Long> ids) {
userService.deleteUsers(ids);
return ApiResponse.ok();
}
@GetMapping("/{id}/roles") @GetMapping("/{id}/roles")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')") @PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')")
public ApiResponse<List<Long>> roleIds(@PathVariable("id") long userId) { public ApiResponse<List<Long>> roleIds(@PathVariable("id") long userId) {

View File

@@ -6,14 +6,18 @@ import java.util.List;
public class AuthDtos { public class AuthDtos {
public record LoginRequest( public record LoginRequest(
@NotBlank String username, @NotBlank String username,
@NotBlank String password @NotBlank String password,
String uuid,
String code
) { ) {
} }
public record RegisterRequest( public record RegisterRequest(
@NotBlank String username, @NotBlank String username,
@NotBlank String password, @NotBlank String password,
String nickname String nickname,
String uuid,
String code
) { ) {
} }
@@ -51,5 +55,11 @@ public class AuthDtos {
@NotBlank(message = "新密码不能为空") String newPassword @NotBlank(message = "新密码不能为空") String newPassword
) { ) {
} }
public record CaptchaResponse(
@com.fasterxml.jackson.annotation.JsonProperty("uuid") String uuid,
@com.fasterxml.jackson.annotation.JsonProperty("img") String img
) {
}
} }

View File

@@ -0,0 +1,8 @@
package com.hertz.system.service;
import com.hertz.system.dto.AuthDtos;
public interface CaptchaService {
AuthDtos.CaptchaResponse generateCaptcha();
void validateCaptcha(String uuid, String code);
}

View File

@@ -22,6 +22,8 @@ public interface UserService {
void deleteUser(Long id); void deleteUser(Long id);
void deleteUsers(List<Long> ids);
void updateUserRoles(long userId, List<Long> roleIds); void updateUserRoles(long userId, List<Long> roleIds);
List<Long> getUserRoleIds(long userId); List<Long> getUserRoleIds(long userId);

View File

@@ -0,0 +1,54 @@
package com.hertz.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.system.dto.AuthDtos;
import com.hertz.system.service.CaptchaService;
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, 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);
}
}

View File

@@ -145,6 +145,16 @@ public class UserServiceImpl implements UserService {
userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, id)); userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, id));
} }
@Override
@Transactional
public void deleteUsers(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
userMapper.deleteByIds(ids);
userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().in(SysUserRole::getUserId, ids));
}
@Override @Override
@Transactional @Transactional
public void updateUserRoles(long userId, List<Long> roleIds) { public void updateUserRoles(long userId, List<Long> roleIds) {

View File

@@ -11,11 +11,25 @@ spring:
multipart: multipart:
max-file-size: 2MB max-file-size: 2MB
max-request-size: 10MB 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
datasource: datasource:
url: jdbc:mysql://localhost:3306/hertz_springboot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai url: jdbc:mysql://localhost:3306/hertz_springboot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root username: root
password: 123456 password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: localhost
port: 6379
password:
database: 0
mybatis-plus: mybatis-plus:
configuration: configuration:
@@ -28,3 +42,9 @@ app:
upload: upload:
root-path: d:\LocalFile\hertz_springboot\uploads root-path: d:\LocalFile\hertz_springboot\uploads
avatar-path: avatar/ avatar-path: avatar/
management:
endpoints:
web:
exposure:
include: health,info,metrics

View File

@@ -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='对话消息表';

View File

@@ -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='系统监控日志表';

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/index1.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hertz Admin</title> <title>Hertz Admin</title>
</head> </head>

View File

@@ -11,6 +11,7 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"element-plus": "^2.13.1", "element-plus": "^2.13.1",
"marked": "^17.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
@@ -1656,6 +1657,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -12,6 +12,7 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"element-plus": "^2.13.1", "element-plus": "^2.13.1",
"marked": "^17.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,5 +1,10 @@
import { http } from './http' import { http } from './http'
export async function getCaptcha() {
const { data } = await http.get('/api/auth/captcha')
return data.data
}
export async function login(req) { export async function login(req) {
const { data } = await http.post('/api/auth/login', req) const { data } = await http.post('/api/auth/login', req)
return data.data return data.data

39
ui/src/api/chat.js Normal file
View File

@@ -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
}
}

View File

@@ -26,12 +26,14 @@ http.interceptors.response.use(
import('../router') import('../router')
.then(({ default: router }) => { .then(({ default: router }) => {
const current = router.currentRoute.value const current = router.currentRoute.value
if (current.path !== '/403') { const path = current.path
router.replace({ path: '/403', query: { from: current.fullPath } }) const target = path.startsWith('/admin') ? '/admin/403' : '/portal/403'
if (path !== target) {
router.replace({ path: target, query: { from: current.fullPath } })
} }
}) })
.catch(() => { .catch(() => {
if (window.location.pathname !== '/403') window.location.replace('/403') if (window.location.pathname !== '/portal/403') window.location.replace('/portal/403')
}) })
} }
return Promise.reject(err) return Promise.reject(err)

6
ui/src/api/monitor.js Normal file
View File

@@ -0,0 +1,6 @@
import { http } from './http'
export async function getServerInfo() {
const { data } = await http.get('/api/monitor/server')
return data
}

View File

@@ -39,6 +39,10 @@ export async function deleteUser(id) {
await http.delete(`/api/system/users/${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() { export async function fetchRoles() {
const { data } = await http.get('/api/system/roles') const { data } = await http.get('/api/system/roles')
return data.data return data.data

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 841 KiB

After

Width:  |  Height:  |  Size: 841 KiB

View File

@@ -1,10 +1,10 @@
:root { :root {
/* iOS Color Palette */ /* iOS Color Palette */
--ios-primary: #007AFF; --ios-primary: #3542ec;
--ios-primary-light: #47a0ff; --ios-primary-light: #0011FF;
--ios-success: #34C759; --ios-success: #34C759;
--ios-warning: #FF9500; --ios-warning: #FF9500;
--ios-danger: #FF3B30; --ios-danger: #FF4040;
--ios-gray: #8E8E93; --ios-gray: #8E8E93;
--ios-bg: #F2F4F8; --ios-bg: #F2F4F8;
--ios-surface: rgba(255, 255, 255, 0.7); --ios-surface: rgba(255, 255, 255, 0.7);
@@ -56,7 +56,7 @@ body {
.el-card__header { .el-card__header {
border-bottom: 1px solid var(--ios-border) !important; border-bottom: 1px solid var(--ios-border) !important;
padding: 16px 24px !important; padding: 10px 24px !important;
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: 16px;
} }
@@ -68,7 +68,19 @@ body {
transition: all 0.2s ease !important; transition: all 0.2s ease !important;
} }
.el-button:not(.is-circle):not(.is-text) { .el-button.is-link:hover {
color: var(--ios-primary-light);
}
.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; height: 36px !important;
padding: 0 20px !important; padding: 0 20px !important;
} }
@@ -111,6 +123,14 @@ body {
background-color: rgba(0, 122, 255, 0.03) !important; background-color: rgba(0, 122, 255, 0.03) !important;
} }
.el-checkbox__inner {
width: 15px;
height: 15px;
border-radius: 4px;
}
/* Dialog Styling */ /* Dialog Styling */
.el-dialog { .el-dialog {
border-radius: 20px !important; border-radius: 20px !important;
@@ -240,3 +260,48 @@ html, body {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: 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;
}

View File

@@ -0,0 +1,66 @@
<template>
<div class="common-page-container">
<el-card class="common-box-card">
<template #header v-if="$slots.header">
<slot name="header" />
</template>
<!-- 搜索/工具栏区域 -->
<div class="common-search-area" v-if="$slots.search">
<slot name="search" />
</div>
<!-- 表格内容区域 -->
<div class="common-table-area">
<slot />
</div>
<!-- 分页区域 -->
<div class="common-pagination-area" v-if="$slots.pagination">
<slot name="pagination" />
</div>
</el-card>
</div>
</template>
<style scoped>
.common-page-container {
height: calc(100vh - 112px);
display: flex;
flex-direction: column;
}
.common-box-card {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 穿透修改 el-card__body 样式,使其变为 flex 布局 */
:deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.common-search-area {
margin-bottom: 12px;
flex-shrink: 0;
}
.common-table-area {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.common-pagination-area {
margin-top: 12px;
display: flex;
justify-content: center;
flex-shrink: 0;
}
</style>

View File

@@ -3,18 +3,18 @@
<div class="error-card glass"> <div class="error-card glass">
<div class="error-visual" :class="{ forbidden: codeText === '403' }"> <div class="error-visual" :class="{ forbidden: codeText === '403' }">
<div class="error-code">{{ codeText }}</div> <div class="error-code">{{ codeText }}</div>
<div class="error-badge"> <!-- <div class="error-badge">
<el-icon :size="18"> <el-icon :size="18">
<component :is="iconComponent" /> <component :is="iconComponent" />
</el-icon> </el-icon>
<span class="error-badge-text">{{ badgeText }}</span> <span class="error-badge-text">{{ badgeText }}</span>
</div> </div> -->
</div> </div>
<div class="error-content"> <div class="error-content">
<div class="error-title">{{ displayTitle }}</div> <div class="error-title">{{ displayTitle }}</div>
<div class="error-subtitle">{{ displaySubTitle }}</div> <div class="error-subtitle">{{ displaySubTitle }}</div>
<div class="error-actions"> <!-- <div class="error-actions">
<el-button type="primary" @click="goHome"> <el-button type="primary" @click="goHome">
<el-icon class="btn-icon"><House /></el-icon> <el-icon class="btn-icon"><House /></el-icon>
返回首页 返回首页
@@ -25,9 +25,9 @@
</el-button> </el-button>
<el-button v-if="codeText === '403'" type="danger" plain @click="switchAccount"> <el-button v-if="codeText === '403'" type="danger" plain @click="switchAccount">
<el-icon class="btn-icon"><SwitchButton /></el-icon> <el-icon class="btn-icon"><SwitchButton /></el-icon>
切换账号 登录
</el-button> </el-button>
</div> </div> -->
</div> </div>
</div> </div>
</div> </div>
@@ -97,11 +97,9 @@ function switchAccount() {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100vh; min-height: 100%;
padding: 16px; padding: 32px 16px;
background: radial-gradient(circle at top left, #e3f2fd, transparent 45%), flex: 1;
radial-gradient(circle at bottom right, #f3e5f5, transparent 45%),
#f2f4f8;
} }
.error-card { .error-card {

View File

@@ -1,7 +1,7 @@
<template> <template>
<el-container style="min-height: 100vh; background-color: var(--ios-bg);"> <el-container style="min-height: 100vh; background-color: var(--ios-bg);">
<el-aside width="240px" class="admin-aside glass"> <el-aside width="240px" class="admin-aside glass">
<div class="logo-area" style="display: flex; align-items: center; justify-content: center; gap: 12px;"> <div class="logo-area" @click="router.push('/admin/dashboard')" style="display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer;">
<img src="/logo.png" alt="Logo" style="width: 48px; height: 26px;" /> <img src="/logo.png" alt="Logo" style="width: 48px; height: 26px;" />
<span class="logo-text">Hertz Admin</span> <span class="logo-text">Hertz Admin</span>
</div> </div>
@@ -172,7 +172,6 @@ function logout() {
.admin-menu { .admin-menu {
border-right: none; border-right: none;
background: transparent;
padding: 16px 12px; padding: 16px 12px;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -221,6 +220,7 @@ function logout() {
padding: 6px 12px; padding: 6px 12px;
border-radius: 20px; border-radius: 20px;
transition: background-color 0.2s; transition: background-color 0.2s;
outline: none;
} }
.default-avatar { .default-avatar {

View File

@@ -2,12 +2,14 @@
<div class="portal-layout"> <div class="portal-layout">
<header class="portal-header glass"> <header class="portal-header glass">
<div class="header-content"> <div class="header-content">
<div class="logo" style="display: flex; align-items: center; gap: 8px;"> <div class="logo" @click="go('/portal/home')" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<img src="/logo.png" alt="Logo" style="width: 48px; height: 26px;" /> <img src="/logo.png" alt="Logo" style="width: 48px; height: 26px;" />
<span>Hertz Admin</span> <span>Hertz Admin</span>
</div> </div>
<el-menu mode="horizontal" :default-active="active()" class="portal-menu" :ellipsis="false"> <el-menu mode="horizontal" :default-active="active()" class="portal-menu" :ellipsis="false">
<el-menu-item index="/portal/home" @click="go('/portal/home')">首页</el-menu-item> <el-menu-item index="/portal/home" @click="go('/portal/home')">首页</el-menu-item>
<el-menu-item index="/portal/monitor" @click="go('/portal/monitor')">监控</el-menu-item>
<el-menu-item index="/portal/chat" @click="go('/portal/chat')">聊天</el-menu-item>
<el-menu-item index="/portal/about" @click="go('/portal/about')">关于</el-menu-item> <el-menu-item index="/portal/about" @click="go('/portal/about')">关于</el-menu-item>
</el-menu> </el-menu>
<div class="auth-actions"> <div class="auth-actions">
@@ -87,6 +89,8 @@ function logout() {
.portal-layout { .portal-layout {
min-height: 100vh; min-height: 100vh;
background-color: var(--ios-bg); background-color: var(--ios-bg);
display: flex;
flex-direction: column;
} }
.portal-header { .portal-header {
position: sticky; position: sticky;
@@ -94,6 +98,7 @@ function logout() {
z-index: 100; z-index: 100;
height: 64px; height: 64px;
border-bottom: 1px solid var(--ios-border); border-bottom: 1px solid var(--ios-border);
flex-shrink: 0;
} }
.header-content { .header-content {
max-width: 1200px; max-width: 1200px;
@@ -145,10 +150,7 @@ function logout() {
padding: 4px 8px; padding: 4px 8px;
border-radius: 20px; border-radius: 20px;
transition: background-color 0.2s; transition: background-color 0.2s;
} outline: none;
.user-link:hover {
border: none;
} }
.default-avatar { .default-avatar {
@@ -163,5 +165,9 @@ function logout() {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 32px 24px; padding: 32px 24px;
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
} }
</style> </style>

View File

@@ -14,23 +14,30 @@ const routes = [
{ path: '/', redirect: '/portal/home' }, { path: '/', redirect: '/portal/home' },
{ path: '/login', component: Login }, { path: '/login', component: Login },
{ path: '/register', component: Register }, { path: '/register', component: Register },
{
path: '/403',
component: () => import('../components/Error.vue'),
props: {
code: 403,
title: '抱歉,您没有权限访问该页面',
subTitle: '请联系管理员开通权限,或切换账号后重试。',
},
},
{ {
path: '/portal', path: '/portal',
component: PortalLayout, component: PortalLayout,
children: [ children: [
{ path: '', redirect: '/portal/home' }, { path: '', redirect: '/portal/home' },
{ path: 'home', component: () => import('../views/portal/Home.vue') }, { 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: 'about', component: () => import('../views/portal/About.vue') },
{ path: 'profile', component: () => import('../views/Profile.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 },
},
], ],
}, },
{ {
@@ -40,12 +47,32 @@ const routes = [
children: [ children: [
{ path: '', name: ROUTE_NAMES.AdminRoot, redirect: '/admin/dashboard' }, { path: '', name: ROUTE_NAMES.AdminRoot, redirect: '/admin/dashboard' },
{ path: 'profile', component: () => import('../views/Profile.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(.*)*', path: '/:pathMatch(.*)*',
component: () => import('../components/Error.vue'), component: PortalLayout,
props: { code: 404 }, children: [
{
path: '',
component: () => import('../components/Error.vue'),
props: { code: 404 },
},
],
}, },
] ]

View File

@@ -13,6 +13,12 @@
<el-form-item label=""> <el-form-item label="">
<el-input v-model="form.password" type="password" autocomplete="current-password" show-password placeholder="请输入密码" :prefix-icon="Lock" /> <el-input v-model="form.password" type="password" autocomplete="current-password" show-password placeholder="请输入密码" :prefix-icon="Lock" />
</el-form-item> </el-form-item>
<el-form-item label="">
<div style="display: flex; gap: 12px; width: 100%">
<el-input v-model="form.code" placeholder="验证码" :prefix-icon="Key" style="flex: 1" @keyup.enter="submit" />
<img v-if="captchaImg" :src="captchaImg" @click="fetchCaptcha" style="cursor: pointer; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6;" alt="captcha" />
</div>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" @click="submit" style="width: 100%">登录</el-button> <el-button type="primary" :loading="loading" @click="submit" style="width: 100%">登录</el-button>
</el-form-item> </el-form-item>
@@ -27,21 +33,42 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { getCaptcha } from '../api/auth'
import { User, Lock } from '@element-plus/icons-vue' import { User, Lock, Key } from '@element-plus/icons-vue'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const loading = ref(false) const loading = ref(false)
const captchaImg = ref('')
const form = reactive({ const form = reactive({
username: 'hertz', username: 'hertz',
password: 'hertz', password: 'hertz',
uuid: '',
code: '',
})
async function fetchCaptcha() {
try {
const res = await getCaptcha()
if (res && res.img) {
form.uuid = res.uuid
const base64 = res.img
captchaImg.value = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64
}
} catch (e) {
console.error(e)
}
}
onMounted(() => {
fetchCaptcha()
}) })
async function submit() { async function submit() {
@@ -56,6 +83,7 @@ async function submit() {
await router.replace(redirect) await router.replace(redirect)
} catch (e) { } catch (e) {
ElMessage.error(e?.response?.data?.message || '登录失败') ElMessage.error(e?.response?.data?.message || '登录失败')
await fetchCaptcha()
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -75,8 +103,8 @@ function goRegister() {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: radial-gradient(circle at top left, #E3F2FD, transparent 40%), background: radial-gradient(circle at top left, #E3F2FD, transparent 40%),
radial-gradient(circle at bottom right, #F3E5F5, transparent 40%), radial-gradient(circle at bottom right, #F3E5F5, transparent 40%),
#F2F4F8; #F2F4F8;
padding: 16px; padding: 16px;
} }

View File

@@ -18,6 +18,12 @@
<el-form-item label=""> <el-form-item label="">
<el-input v-model="form.nickname" placeholder="昵称" :prefix-icon="Postcard" /> <el-input v-model="form.nickname" placeholder="昵称" :prefix-icon="Postcard" />
</el-form-item> </el-form-item>
<el-form-item label="">
<div style="display: flex; gap: 12px; width: 100%">
<el-input v-model="form.code" placeholder="验证码" :prefix-icon="Key" style="flex: 1" @keyup.enter="submit" />
<img v-if="captchaImg" :src="captchaImg" @click="fetchCaptcha" style="cursor: pointer; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6;" alt="captcha" />
</div>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" @click="submit" style="width: 100%">立即注册</el-button> <el-button type="primary" :loading="loading" @click="submit" style="width: 100%">立即注册</el-button>
</el-form-item> </el-form-item>
@@ -31,22 +37,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { getCaptcha } from '../api/auth'
import { User, Lock, Postcard } from '@element-plus/icons-vue' import { User, Lock, Postcard, Key } from '@element-plus/icons-vue'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const captchaImg = ref('')
const form = reactive({ const form = reactive({
username: '', username: '',
password: '', password: '',
confirmPassword: '', confirmPassword: '',
nickname: '', nickname: '',
uuid: '',
code: '',
})
async function fetchCaptcha() {
try {
const res = await getCaptcha()
if (res && res.img) {
form.uuid = res.uuid
const base64 = res.img
captchaImg.value = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64
}
} catch (e) {
console.error(e)
}
}
onMounted(() => {
fetchCaptcha()
}) })
async function submit() { async function submit() {
@@ -62,6 +89,7 @@ async function submit() {
await router.replace('/login') await router.replace('/login')
} catch (e) { } catch (e) {
ElMessage.error(e?.response?.data?.message || '注册失败') ElMessage.error(e?.response?.data?.message || '注册失败')
await fetchCaptcha()
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -17,7 +17,7 @@
<!-- <div class="welcome-right"> <!-- <div class="welcome-right">
<div class="stat-item"> <div class="stat-item">
<span class="label">我的角色</span> <span class="label">我的角色</span>
<span class="value">{{ auth.roles.length }}</span> <span class="value">{{ auth.roles }}</span>
</div> </div>
</div> --> </div> -->
</div> </div>
@@ -278,10 +278,6 @@ onMounted(() => {
transition: transform 0.3s; transition: transform 0.3s;
} }
.stat-card:hover {
transform: translateY(-4px);
}
.stat-card :deep(.el-card__body) { .stat-card :deep(.el-card__body) {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -341,15 +337,11 @@ onMounted(() => {
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
border-radius: 12px; border-radius: 12px;
background-color: #f5f7fa; background-color: #ecf5ff;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
} }
.action-item:hover {
background-color: #ecf5ff;
}
.action-icon { .action-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;

View File

@@ -0,0 +1,258 @@
<template>
<div>
<el-card style="height: calc(100vh - 112px);">
<!-- <template #header>菜单管理</template> -->
<div style="margin-bottom: 12px">
<el-button type="primary" @click="load">刷新</el-button>
<el-button @click="openCreate(0)" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增菜单</el-button>
</div>
<el-table
:data="menuTree"
v-loading="loading"
style="width: 100%"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
>
<el-table-column prop="name" label="菜单名称" width="200" />
<el-table-column prop="icon" label="图标" width="60">
<template #default="{ row }">
<component :is="row.icon" v-if="row.icon" style="width: 16px; height: 16px" />
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }">
<el-tag v-if="row.type === 'D'">目录</el-tag>
<el-tag v-else-if="row.type === 'M'" type="success">菜单</el-tag>
<el-tag v-else type="info">按钮</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" />
<el-table-column prop="component" label="组件路径" />
<el-table-column prop="perms" label="权限标识" />
<el-table-column prop="sort" label="排序" width="60" />
<!-- <el-table-column prop="visible" label="可见" width="70">
<template #default="{ row }">
<el-tag :type="row.visible === 1 ? 'success' : 'info'">{{ row.visible === 1 ? '显示' : '隐藏' }}</el-tag>
</template>
</el-table-column> -->
<el-table-column label="操作" width="160">
<template #default="{ row }">
<div style="display: flex; align-items: center">
<el-tooltip content="编辑" placement="top">
<el-button type="primary" text bg :icon="EditPen" style="font-size: 15px; padding: 6px 8px" @click="openEdit(row)" />
</el-tooltip>
<el-tooltip content="新增子项" placement="top">
<el-button type="primary" text bg :icon="Plus" style="font-size: 15px; padding: 6px 8px" @click="openCreate(row.id)" />
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button type="danger" text bg :icon="Delete" style="font-size: 15px; padding: 6px 8px" @click="handleDelete(row)" />
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogOpen" :title="formData.id ? '编辑菜单' : '新增菜单'" width="600px">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" v-loading="formLoading">
<el-form-item label="上级菜单" prop="parentId">
<el-tree-select
v-model="formData.parentId"
:data="menuOptions"
:props="{ label: 'name', children: 'children' }"
node-key="id"
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单类型" prop="type">
<el-radio-group v-model="formData.type">
<el-radio value="D">目录</el-radio>
<el-radio value="M">菜单</el-radio>
<el-radio value="B">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="菜单名称" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item label="路由路径" prop="path" v-if="formData.type !== 'B'">
<el-input v-model="formData.path" placeholder="例如:/system/user" />
</el-form-item>
<el-form-item label="组件路径" prop="component" v-if="formData.type === 'M'">
<el-input v-model="formData.component" placeholder="例如admin/system/User" />
</el-form-item>
<el-form-item label="权限标识" prop="perms" v-if="formData.type !== 'D'">
<el-input v-model="formData.perms" placeholder="例如system:user:view" />
</el-form-item>
<el-form-item label="图标" prop="icon" v-if="formData.type !== 'B'">
<el-input v-model="formData.icon" placeholder="Element Plus Icon Name">
<template #prefix>
<component :is="formData.icon" v-if="formData.icon" style="width: 16px; height: 16px" />
</template>
</el-input>
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" />
</el-form-item>
</el-col>
<!-- <el-col :span="12">
<el-form-item label="显示状态" prop="visible" v-if="formData.type !== 'B'">
<el-radio-group v-model="formData.visible">
<el-radio :value="1">显示</el-radio>
<el-radio :value="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-col> -->
</el-row>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogOpen = false">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="saveMenu">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, EditPen, Plus } from '@element-plus/icons-vue'
import {
createMenu,
deleteMenu,
fetchMenuTree,
updateMenu,
} from '../../../api/system'
import { useMenuStore } from '../../../stores/menu'
const menuTree = ref([])
const loading = ref(false)
const menuStore = useMenuStore()
async function load() {
loading.value = true
try {
menuTree.value = await fetchMenuTree()
} finally {
loading.value = false
}
}
// Form
const dialogOpen = ref(false)
const formLoading = ref(false)
const formRef = ref(null)
const formData = reactive({
id: undefined,
parentId: 0,
type: 'M',
name: '',
path: '',
component: '',
perms: '',
icon: '',
sort: 0,
visible: 1,
status: 1,
})
const rules = reactive({
name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
})
// We need a flat list or tree for the "Parent Menu" selector.
// We can reuse `menuTree` for the TreeSelect.
// We should insert a "Root" node option.
const menuOptions = ref([])
function updateMenuOptions() {
const root = { id: 0, name: '主类目', children: [] }
// Deep copy menuTree to avoid modifying the view
// And maybe disable self and children to prevent cycles (if editing)
menuOptions.value = [root, ...menuTree.value]
}
function openCreate(parentId = 0) {
formData.id = undefined
formData.parentId = parentId
formData.type = 'M'
formData.name = ''
formData.path = ''
formData.component = ''
formData.perms = ''
formData.icon = ''
formData.sort = 0
formData.visible = 1
formData.status = 1
updateMenuOptions()
dialogOpen.value = true
}
function openEdit(row) {
Object.assign(formData, row)
if (formData.parentId === null || formData.parentId === undefined) {
formData.parentId = 0
}
updateMenuOptions()
dialogOpen.value = true
}
async function saveMenu() {
if (formRef.value) {
await formRef.value.validate()
} else {
return
}
formLoading.value = true
try {
if (formData.id) {
await updateMenu(formData)
ElMessage.success('更新成功')
} else {
await createMenu(formData)
ElMessage.success('创建成功')
}
dialogOpen.value = false
load()
// Refresh the sidebar menu store as well
menuStore.routesLoaded = false
await menuStore.fetchMenus()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
formLoading.value = false
}
}
function handleDelete(row) {
ElMessageBox.confirm(`确认删除菜单 "${row.name}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await deleteMenu(row.id)
ElMessage.success('删除成功')
load()
// Refresh the sidebar menu store as well
menuStore.routesLoaded = false
await menuStore.fetchMenus()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '删除失败')
}
})
}
onMounted(load)
</script>

View File

@@ -1,15 +1,17 @@
<template> <template>
<div> <div>
<el-card> <ContentPage>
<template #header>角色管理</template> <!-- <template #header>角色管理</template> -->
<div style="display: flex; gap: 8px; margin-bottom: 12px"> <template #search>
<el-input v-model="query.keyword" placeholder="角色名称/标识" style="max-width: 260px" clearable /> <div style="display: flex; gap: 8px">
<el-button type="primary" @click="() => ((query.page = 1), load())">查询</el-button> <el-input v-model="query.keyword" placeholder="角色名称/标识" style="max-width: 260px" clearable />
<el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增角色</el-button> <el-button type="primary" @click="() => ((query.page = 1), load())">查询</el-button>
</div> <el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增角色</el-button>
</div>
</template>
<el-table :data="roles" v-loading="loading" style="width: 100%"> <el-table :data="roles" v-loading="loading" style="width: 100%; height: 100%">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="roleKey" label="角色标识" /> <el-table-column prop="roleKey" label="角色标识" />
<el-table-column prop="roleName" label="角色名称" /> <el-table-column prop="roleName" label="角色名称" />
@@ -35,17 +37,19 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div style="display: flex; justify-content: flex-end; margin-top: 12px"> <template #pagination>
<el-pagination <el-pagination
background background
layout="prev, pager, next, total" layout="total, sizes, prev, pager, next, jumper"
:total="total" :total="total"
:page-size="query.size" v-model:page-size="query.size"
:current-page="query.page" v-model:current-page="query.page"
@current-change="onPageChange" :page-sizes="[10, 20, 50, 100]"
@size-change="load"
@current-change="load"
/> />
</div> </template>
</el-card> </ContentPage>
<!-- Role Dialog --> <!-- Role Dialog -->
<el-dialog v-model="roleDialogOpen" :title="roleData.id ? '编辑角色' : '新增角色'" width="500px"> <el-dialog v-model="roleDialogOpen" :title="roleData.id ? '编辑角色' : '新增角色'" width="500px">
@@ -91,6 +95,7 @@
<script setup> <script setup>
import ContentPage from '@/components/ContentPage.vue'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, EditPen, Setting } from '@element-plus/icons-vue' import { Delete, EditPen, Setting } from '@element-plus/icons-vue'
@@ -125,10 +130,6 @@ async function load() {
} }
} }
function onPageChange(p) {
query.page = p
load()
}
// Role Form // Role Form
const roleDialogOpen = ref(false) const roleDialogOpen = ref(false)

View File

@@ -1,17 +1,20 @@
<template> <template>
<div> <ContentPage>
<el-card> <!-- <template #header>用户管理</template> -->
<template #header>用户管理</template>
<div style="display: flex; gap: 8px; margin-bottom: 12px"> <template #search>
<el-input v-model="query.keyword" placeholder="用户名/昵称/手机号/邮箱" style="max-width: 260px" clearable /> <div style="display: flex; gap: 8px">
<el-button type="primary" @click="() => ((query.page = 1), loadUsers())">查询</el-button> <el-input v-model="query.keyword" placeholder="用户名/昵称/手机号/邮箱" style="max-width: 260px" clearable />
<el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增用户</el-button> <el-button type="primary" @click="() => ((query.page = 1), loadUsers())">查询</el-button>
</div> <el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增用户</el-button>
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">批量删除</el-button>
</div>
</template>
<el-table :data="users" v-loading="loading" style="width: 100%"> <el-table :data="users" v-loading="loading" style="width: 100%; height: 100%" @selection-change="handleSelectionChange">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column type="selection" width="55" />
<el-table-column label="用户" min-width="100"> <el-table-column type="index" label="序号" width="60" :index="indexMethod" />
<el-table-column label="用户" min-width="80">
<template #default="{ row }"> <template #default="{ row }">
<div style="display: flex; align-items: center; gap: 10px"> <div style="display: flex; align-items: center; gap: 10px">
<el-avatar shape="square" :size="40" :src="apiBase + row.avatarPath" v-if="row.avatarPath" /> <el-avatar shape="square" :size="40" :src="apiBase + row.avatarPath" v-if="row.avatarPath" />
@@ -23,7 +26,7 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="nickname" label="昵称" /> <el-table-column prop="nickname" label="昵称" min-width="80" />
<el-table-column label="角色"> <el-table-column label="角色">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-for="r in row.roles" :key="r" size="small" style="margin-right: 4px">{{ r }}</el-tag> <el-tag v-for="r in row.roles" :key="r" size="small" style="margin-right: 4px">{{ r }}</el-tag>
@@ -59,17 +62,19 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div style="display: flex; justify-content: flex-end; margin-top: 12px"> <template #pagination>
<el-pagination <el-pagination
background background
layout="prev, pager, next, total" layout="total, sizes, prev, pager, next, jumper"
:total="total" :total="total"
:page-size="query.size" v-model:page-size="query.size"
:current-page="query.page" v-model:current-page="query.page"
@current-change="onPageChange" :page-sizes="[10, 20, 50, 100]"
@size-change="loadUsers"
@current-change="loadUsers"
/> />
</div> </template>
</el-card> </ContentPage>
<el-dialog v-model="roleDialogOpen" title="分配角色" width="520px"> <el-dialog v-model="roleDialogOpen" title="分配角色" width="520px">
<el-form v-loading="roleLoading" label-width="90px"> <el-form v-loading="roleLoading" label-width="90px">
@@ -135,16 +140,17 @@
<el-button type="primary" :loading="formLoading" @click="saveUser">保存</el-button> <el-button type="primary" :loading="formLoading" @click="saveUser">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div>
</template> </template>
<script setup> <script setup>
import ContentPage from '@/components/ContentPage.vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, EditPen, Plus, UserFilled } from '@element-plus/icons-vue' import { Delete, EditPen, Plus, UserFilled } from '@element-plus/icons-vue'
import { import {
createUser, createUser,
deleteUser, deleteUser,
deleteUsers,
fetchRoles, fetchRoles,
fetchUserRoleIds, fetchUserRoleIds,
fetchUsers, fetchUsers,
@@ -176,6 +182,31 @@ async function loadUsers() {
} }
} }
function indexMethod(index) {
return (query.page - 1) * query.size + index + 1
}
const selectedIds = ref([])
function handleSelectionChange(selection) {
selectedIds.value = selection.map((item) => item.id)
}
function handleBatchDelete() {
if (selectedIds.value.length === 0) return
ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 个用户吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await deleteUsers(selectedIds.value)
ElMessage.success('删除成功')
loadUsers()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '删除失败')
}
})
}
const formDialogOpen = ref(false) const formDialogOpen = ref(false)
const formLoading = ref(false) const formLoading = ref(false)
const formRef = ref(null) const formRef = ref(null)
@@ -309,10 +340,6 @@ async function saveRoles() {
} }
} }
function onPageChange(p) {
query.page = p
loadUsers()
}
</script> </script>
<style scoped> <style scoped>

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