v2
77
.gitignore
vendored
@@ -1,56 +1,45 @@
|
||||
# --- Common ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
# --- IDEs & Editors ---
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
*.ipr
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
.factorypath
|
||||
|
||||
# --- Java / Maven (Backend) ---
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Maven
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
.mvn/
|
||||
mvnw
|
||||
mvnw.cmd
|
||||
*.log
|
||||
|
||||
# --- Node / Vue (Frontend) ---
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
coverage/
|
||||
*.local
|
||||
.npm/
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.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 Specific ---
|
||||
# Uploaded files
|
||||
uploads/
|
||||
# Ignore local environment override files if any
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
# Application Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
44
db/init.sql
@@ -11,12 +11,48 @@
|
||||
Target Server Version : 80040 (8.0.40)
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 20/01/2026 15:33:14
|
||||
Date: 22/01/2026 14:58:04
|
||||
*/
|
||||
|
||||
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 = 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
|
||||
-- ----------------------------
|
||||
@@ -107,13 +143,13 @@ CREATE TABLE `sys_user` (
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE INDEX `uk_username`(`username` 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
|
||||
-- ----------------------------
|
||||
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 (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 (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', '/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
|
||||
|
||||
@@ -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).
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -12,9 +12,9 @@
|
||||
</parent>
|
||||
|
||||
<groupId>com.hertz</groupId>
|
||||
<artifactId>hertz-springboot-backend</artifactId>
|
||||
<artifactId>hertz-springboot</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>hertz-springboot-backend</name>
|
||||
<name>hertz-springboot</name>
|
||||
<description>Hertz 权限管理系统后端</description>
|
||||
|
||||
<properties>
|
||||
@@ -22,8 +22,21 @@
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<mybatis-plus.version>3.5.8</mybatis-plus.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
<spring-ai.version>1.0.0-M5</spring-ai.version>
|
||||
</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>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -38,6 +51,15 @@
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</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>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
@@ -74,6 +96,23 @@
|
||||
<scope>runtime</scope>
|
||||
</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>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -86,6 +125,17 @@
|
||||
</dependency>
|
||||
</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>
|
||||
<plugins>
|
||||
<plugin>
|
||||
84
src/main/java/com/hertz/ai/controller/AiController.java
Normal 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));
|
||||
}
|
||||
}
|
||||
10
src/main/java/com/hertz/ai/dto/ChatRequest.java
Normal 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;
|
||||
}
|
||||
18
src/main/java/com/hertz/ai/entity/Conversation.java
Normal 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;
|
||||
}
|
||||
21
src/main/java/com/hertz/ai/entity/Message.java
Normal 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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
9
src/main/java/com/hertz/ai/mapper/MessageMapper.java
Normal 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> {
|
||||
}
|
||||
8
src/main/java/com/hertz/ai/service/AiService.java
Normal 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);
|
||||
}
|
||||
16
src/main/java/com/hertz/ai/service/ConversationService.java
Normal 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);
|
||||
}
|
||||
71
src/main/java/com/hertz/ai/service/impl/AiServiceImpl.java
Normal 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() + "]"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ApiResponse<Void> handleException(Exception e) {
|
||||
e.printStackTrace(); // 打印堆栈信息到控制台
|
||||
return ApiResponse.fail(50000, "系统异常");
|
||||
}
|
||||
}
|
||||
35
src/main/java/com/hertz/common/filter/RequestLogFilter.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
89
src/main/java/com/hertz/monitor/dto/MonitorDto.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.hertz.monitor.service;
|
||||
|
||||
import com.hertz.monitor.dto.MonitorDto;
|
||||
|
||||
public interface MonitorService {
|
||||
MonitorDto getServerInfo();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
var auth = new UsernamePasswordAuthenticationToken(username, null, authorities);
|
||||
auth.setDetails(userId);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -47,6 +48,7 @@ public class SecurityConfig {
|
||||
.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()
|
||||
@@ -6,6 +6,7 @@ import com.hertz.security.JwtService;
|
||||
import com.hertz.security.SecurityUtils;
|
||||
import com.hertz.system.dto.AuthDtos;
|
||||
import com.hertz.system.mapper.SysRoleMapper;
|
||||
import com.hertz.system.service.CaptchaService;
|
||||
import com.hertz.system.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
@@ -24,21 +25,25 @@ public class AuthController {
|
||||
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
|
||||
SysRoleMapper roleMapper,
|
||||
CaptchaService captchaService
|
||||
) {
|
||||
this.userService = userService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.roleMapper = roleMapper;
|
||||
this.captchaService = captchaService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
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());
|
||||
return ApiResponse.ok(new AuthDtos.MeResponse(
|
||||
user.getId(),
|
||||
@@ -54,6 +59,7 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<AuthDtos.LoginResponse> 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, "用户名或密码错误");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,13 @@ public class UserController {
|
||||
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")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')")
|
||||
public ApiResponse<List<Long>> roleIds(@PathVariable("id") long userId) {
|
||||
@@ -6,14 +6,18 @@ import java.util.List;
|
||||
public class AuthDtos {
|
||||
public record LoginRequest(
|
||||
@NotBlank String username,
|
||||
@NotBlank String password
|
||||
@NotBlank String password,
|
||||
String uuid,
|
||||
String code
|
||||
) {
|
||||
}
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank String username,
|
||||
@NotBlank String password,
|
||||
String nickname
|
||||
String nickname,
|
||||
String uuid,
|
||||
String code
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -51,5 +55,11 @@ public class AuthDtos {
|
||||
@NotBlank(message = "新密码不能为空") String newPassword
|
||||
) {
|
||||
}
|
||||
|
||||
public record CaptchaResponse(
|
||||
@com.fasterxml.jackson.annotation.JsonProperty("uuid") String uuid,
|
||||
@com.fasterxml.jackson.annotation.JsonProperty("img") String img
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ public interface UserService {
|
||||
|
||||
void deleteUser(Long id);
|
||||
|
||||
void deleteUsers(List<Long> ids);
|
||||
|
||||
void updateUserRoles(long userId, List<Long> roleIds);
|
||||
|
||||
List<Long> getUserRoleIds(long userId);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,16 @@ public class UserServiceImpl implements UserService {
|
||||
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
|
||||
@Transactional
|
||||
public void updateUserRoles(long userId, List<Long> roleIds) {
|
||||
@@ -11,11 +11,25 @@ spring:
|
||||
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
|
||||
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:
|
||||
@@ -28,3 +42,9 @@ app:
|
||||
upload:
|
||||
root-path: d:\LocalFile\hertz_springboot\uploads
|
||||
avatar-path: avatar/
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
26
src/main/resources/schema/ai_schema.sql
Normal 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='对话消息表';
|
||||
14
src/main/resources/schema/monitor_schema.sql
Normal 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='系统监控日志表';
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Hertz Admin</title>
|
||||
</head>
|
||||
@@ -11,6 +11,7 @@
|
||||
"@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"
|
||||
@@ -1656,6 +1657,18 @@
|
||||
"@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",
|
||||
@@ -12,6 +12,7 @@
|
||||
"@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"
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,5 +1,10 @@
|
||||
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
|
||||
39
ui/src/api/chat.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,14 @@ http.interceptors.response.use(
|
||||
import('../router')
|
||||
.then(({ default: router }) => {
|
||||
const current = router.currentRoute.value
|
||||
if (current.path !== '/403') {
|
||||
router.replace({ path: '/403', query: { from: current.fullPath } })
|
||||
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 !== '/403') window.location.replace('/403')
|
||||
if (window.location.pathname !== '/portal/403') window.location.replace('/portal/403')
|
||||
})
|
||||
}
|
||||
return Promise.reject(err)
|
||||
6
ui/src/api/monitor.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { http } from './http'
|
||||
|
||||
export async function getServerInfo() {
|
||||
const { data } = await http.get('/api/monitor/server')
|
||||
return data
|
||||
}
|
||||
@@ -39,6 +39,10 @@ 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
|
||||
BIN
ui/src/assets/img/default_avatar.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 841 KiB After Width: | Height: | Size: 841 KiB |
@@ -1,10 +1,10 @@
|
||||
:root {
|
||||
/* iOS Color Palette */
|
||||
--ios-primary: #007AFF;
|
||||
--ios-primary-light: #47a0ff;
|
||||
--ios-primary: #3542ec;
|
||||
--ios-primary-light: #0011FF;
|
||||
--ios-success: #34C759;
|
||||
--ios-warning: #FF9500;
|
||||
--ios-danger: #FF3B30;
|
||||
--ios-danger: #FF4040;
|
||||
--ios-gray: #8E8E93;
|
||||
--ios-bg: #F2F4F8;
|
||||
--ios-surface: rgba(255, 255, 255, 0.7);
|
||||
@@ -56,7 +56,7 @@ body {
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--ios-border) !important;
|
||||
padding: 16px 24px !important;
|
||||
padding: 10px 24px !important;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -68,7 +68,19 @@ body {
|
||||
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;
|
||||
padding: 0 20px !important;
|
||||
}
|
||||
@@ -111,6 +123,14 @@ body {
|
||||
background-color: rgba(0, 122, 255, 0.03) !important;
|
||||
}
|
||||
|
||||
.el-checkbox__inner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Dialog Styling */
|
||||
.el-dialog {
|
||||
border-radius: 20px !important;
|
||||
@@ -240,3 +260,48 @@ 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;
|
||||
}
|
||||
66
ui/src/components/ContentPage.vue
Normal 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>
|
||||
@@ -3,18 +3,18 @@
|
||||
<div class="error-card glass">
|
||||
<div class="error-visual" :class="{ forbidden: codeText === '403' }">
|
||||
<div class="error-code">{{ codeText }}</div>
|
||||
<div class="error-badge">
|
||||
<!-- <div class="error-badge">
|
||||
<el-icon :size="18">
|
||||
<component :is="iconComponent" />
|
||||
</el-icon>
|
||||
<span class="error-badge-text">{{ badgeText }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div class="error-content">
|
||||
<div class="error-title">{{ displayTitle }}</div>
|
||||
<div class="error-subtitle">{{ displaySubTitle }}</div>
|
||||
<div class="error-actions">
|
||||
<!-- <div class="error-actions">
|
||||
<el-button type="primary" @click="goHome">
|
||||
<el-icon class="btn-icon"><House /></el-icon>
|
||||
返回首页
|
||||
@@ -25,9 +25,9 @@
|
||||
</el-button>
|
||||
<el-button v-if="codeText === '403'" type="danger" plain @click="switchAccount">
|
||||
<el-icon class="btn-icon"><SwitchButton /></el-icon>
|
||||
切换账号
|
||||
登录
|
||||
</el-button>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,11 +97,9 @@ function switchAccount() {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
background: radial-gradient(circle at top left, #e3f2fd, transparent 45%),
|
||||
radial-gradient(circle at bottom right, #f3e5f5, transparent 45%),
|
||||
#f2f4f8;
|
||||
min-height: 100%;
|
||||
padding: 32px 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<el-container style="min-height: 100vh; background-color: var(--ios-bg);">
|
||||
<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;" />
|
||||
<span class="logo-text">Hertz Admin</span>
|
||||
</div>
|
||||
@@ -172,7 +172,6 @@ function logout() {
|
||||
|
||||
.admin-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
padding: 16px 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -221,6 +220,7 @@ function logout() {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.default-avatar {
|
||||
@@ -2,12 +2,14 @@
|
||||
<div class="portal-layout">
|
||||
<header class="portal-header glass">
|
||||
<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;" />
|
||||
<span>Hertz Admin</span>
|
||||
</div>
|
||||
<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/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>
|
||||
<div class="auth-actions">
|
||||
@@ -87,6 +89,8 @@ function logout() {
|
||||
.portal-layout {
|
||||
min-height: 100vh;
|
||||
background-color: var(--ios-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.portal-header {
|
||||
position: sticky;
|
||||
@@ -94,6 +98,7 @@ function logout() {
|
||||
z-index: 100;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--ios-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
@@ -145,10 +150,7 @@ function logout() {
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.user-link:hover {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.default-avatar {
|
||||
@@ -163,5 +165,9 @@ function logout() {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -14,23 +14,30 @@ const routes = [
|
||||
{ path: '/', redirect: '/portal/home' },
|
||||
{ path: '/login', component: Login },
|
||||
{ path: '/register', component: Register },
|
||||
{
|
||||
path: '/403',
|
||||
component: () => import('../components/Error.vue'),
|
||||
props: {
|
||||
code: 403,
|
||||
title: '抱歉,您没有权限访问该页面',
|
||||
subTitle: '请联系管理员开通权限,或切换账号后重试。',
|
||||
},
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -40,12 +47,32 @@ const routes = [
|
||||
children: [
|
||||
{ path: '', name: ROUTE_NAMES.AdminRoot, redirect: '/admin/dashboard' },
|
||||
{ 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: () => import('../components/Error.vue'),
|
||||
props: { code: 404 },
|
||||
component: PortalLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('../components/Error.vue'),
|
||||
props: { code: 404 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.password" type="password" autocomplete="current-password" show-password placeholder="请输入密码" :prefix-icon="Lock" />
|
||||
</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-button type="primary" :loading="loading" @click="submit" style="width: 100%">登录</el-button>
|
||||
</el-form-item>
|
||||
@@ -27,21 +33,42 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const captchaImg = ref('')
|
||||
const form = reactive({
|
||||
username: '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() {
|
||||
@@ -56,6 +83,7 @@ async function submit() {
|
||||
await router.replace(redirect)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '登录失败')
|
||||
await fetchCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -75,8 +103,8 @@ function goRegister() {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: radial-gradient(circle at top left, #E3F2FD, transparent 40%),
|
||||
radial-gradient(circle at bottom right, #F3E5F5, transparent 40%),
|
||||
#F2F4F8;
|
||||
radial-gradient(circle at bottom right, #F3E5F5, transparent 40%),
|
||||
#F2F4F8;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.nickname" placeholder="昵称" :prefix-icon="Postcard" />
|
||||
</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-button type="primary" :loading="loading" @click="submit" style="width: 100%">立即注册</el-button>
|
||||
</el-form-item>
|
||||
@@ -31,22 +37,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const captchaImg = ref('')
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
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() {
|
||||
@@ -62,6 +89,7 @@ async function submit() {
|
||||
await router.replace('/login')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '注册失败')
|
||||
await fetchCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
<!-- <div class="welcome-right">
|
||||
<div class="stat-item">
|
||||
<span class="label">我的角色</span>
|
||||
<span class="value">{{ auth.roles.length }}</span>
|
||||
<span class="value">{{ auth.roles }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
@@ -278,10 +278,6 @@ onMounted(() => {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -341,15 +337,11 @@ onMounted(() => {
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background-color: #f5f7fa;
|
||||
background-color: #ecf5ff;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
258
ui/src/views/admin/system/Menu.vue
Normal 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>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<template #header>角色管理</template>
|
||||
<ContentPage>
|
||||
<!-- <template #header>角色管理</template> -->
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px">
|
||||
<el-input v-model="query.keyword" placeholder="角色名称/标识" style="max-width: 260px" clearable />
|
||||
<el-button type="primary" @click="() => ((query.page = 1), load())">查询</el-button>
|
||||
<el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增角色</el-button>
|
||||
</div>
|
||||
<template #search>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<el-input v-model="query.keyword" placeholder="角色名称/标识" style="max-width: 260px" clearable />
|
||||
<el-button type="primary" @click="() => ((query.page = 1), load())">查询</el-button>
|
||||
<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="roleKey" label="角色标识" />
|
||||
<el-table-column prop="roleName" label="角色名称" />
|
||||
@@ -35,17 +37,19 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 12px">
|
||||
<template #pagination>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="query.size"
|
||||
:current-page="query.page"
|
||||
@current-change="onPageChange"
|
||||
v-model:page-size="query.size"
|
||||
v-model:current-page="query.page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Role Dialog -->
|
||||
<el-dialog v-model="roleDialogOpen" :title="roleData.id ? '编辑角色' : '新增角色'" width="500px">
|
||||
@@ -91,6 +95,7 @@
|
||||
|
||||
|
||||
<script setup>
|
||||
import ContentPage from '@/components/ContentPage.vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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
|
||||
const roleDialogOpen = ref(false)
|
||||
@@ -1,17 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<template #header>用户管理</template>
|
||||
<ContentPage>
|
||||
<!-- <template #header>用户管理</template> -->
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px">
|
||||
<el-input v-model="query.keyword" placeholder="用户名/昵称/手机号/邮箱" style="max-width: 260px" clearable />
|
||||
<el-button type="primary" @click="() => ((query.page = 1), loadUsers())">查询</el-button>
|
||||
<el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增用户</el-button>
|
||||
</div>
|
||||
<template #search>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<el-input v-model="query.keyword" placeholder="用户名/昵称/手机号/邮箱" style="max-width: 260px" clearable />
|
||||
<el-button type="primary" @click="() => ((query.page = 1), loadUsers())">查询</el-button>
|
||||
<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-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="用户" min-width="100">
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%; height: 100%" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column type="index" label="序号" width="60" :index="indexMethod" />
|
||||
<el-table-column label="用户" min-width="80">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<el-avatar shape="square" :size="40" :src="apiBase + row.avatarPath" v-if="row.avatarPath" />
|
||||
@@ -23,7 +26,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="80" />
|
||||
<el-table-column label="角色">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 12px">
|
||||
<template #pagination>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="query.size"
|
||||
:current-page="query.page"
|
||||
@current-change="onPageChange"
|
||||
v-model:page-size="query.size"
|
||||
v-model:current-page="query.page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="loadUsers"
|
||||
@current-change="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</ContentPage>
|
||||
|
||||
<el-dialog v-model="roleDialogOpen" title="分配角色" width="520px">
|
||||
<el-form v-loading="roleLoading" label-width="90px">
|
||||
@@ -135,16 +140,17 @@
|
||||
<el-button type="primary" :loading="formLoading" @click="saveUser">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ContentPage from '@/components/ContentPage.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, EditPen, Plus, UserFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
deleteUsers,
|
||||
fetchRoles,
|
||||
fetchUserRoleIds,
|
||||
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 formLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
@@ -309,10 +340,6 @@ async function saveRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p) {
|
||||
query.page = p
|
||||
loadUsers()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||