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

3
ui/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

29
ui/src/api/auth.js Normal file
View File

@@ -0,0 +1,29 @@
import { http } from './http'
export async function getCaptcha() {
const { data } = await http.get('/api/auth/captcha')
return data.data
}
export async function login(req) {
const { data } = await http.post('/api/auth/login', req)
return data.data
}
export async function register(req) {
const { data } = await http.post('/api/auth/register', req)
return data.data
}
export async function me() {
const { data } = await http.get('/api/auth/me')
return data.data
}
export async function updateProfile(data) {
await http.post('/api/auth/profile', data)
}
export async function updatePassword(data) {
await http.post('/api/auth/password', data)
}

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

@@ -0,0 +1,39 @@
import { http as request } from './http'
export const chatApi = {
// Create Conversation
async createConversation(title) {
const { data } = await request.post('api/ai/conversations', { title })
return data.data
},
// Get List
async getConversations() {
const { data } = await request.get('api/ai/conversations')
return data.data
},
// Delete
async deleteConversation(id) {
const { data } = await request.delete(`api/ai/conversations/${id}`)
return data.data
},
// Update Title
async updateConversation(id, title) {
const { data } = await request.put(`api/ai/conversations/${id}`, { title })
return data.data
},
// Search
async searchConversations(query) {
const { data } = await request.get('api/ai/conversations/search', { params: { query } })
return data.data
},
// Save Message
async saveMessage(conversationId, role, content) {
const { data } = await request.post(`api/ai/conversations/${conversationId}/messages`, { role, content })
return data.data
},
// Get Messages
async getMessages(conversationId) {
const { data } = await request.get(`api/ai/conversations/${conversationId}/messages`)
return data.data
}
}

12
ui/src/api/common.js Normal file
View File

@@ -0,0 +1,12 @@
import { http } from './http'
export async function uploadFile(file) {
const formData = new FormData()
formData.append('file', file)
const { data } = await http.post('/api/common/file/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return data.data
}

41
ui/src/api/http.js Normal file
View File

@@ -0,0 +1,41 @@
import axios from 'axios'
import { useAuthStore } from '../stores/auth'
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
timeout: 15000,
})
http.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) {
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
})
http.interceptors.response.use(
(resp) => resp,
(err) => {
if (err?.response?.status === 401) {
const auth = useAuthStore()
auth.logout()
}
if (err?.response?.status === 403) {
import('../router')
.then(({ default: router }) => {
const current = router.currentRoute.value
const path = current.path
const target = path.startsWith('/admin') ? '/admin/403' : '/portal/403'
if (path !== target) {
router.replace({ path: target, query: { from: current.fullPath } })
}
})
.catch(() => {
if (window.location.pathname !== '/portal/403') window.location.replace('/portal/403')
})
}
return Promise.reject(err)
},
)

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

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

85
ui/src/api/system.js Normal file
View File

@@ -0,0 +1,85 @@
import { http } from './http'
export async function fetchMenuTree() {
const { data } = await http.get('/api/system/menus/tree')
return data.data
}
export async function pageMenus(params) {
const { data } = await http.get('/api/system/menus/page', { params })
return data.data
}
export async function createMenu(data) {
await http.post('/api/system/menus', data)
}
export async function updateMenu(data) {
await http.put('/api/system/menus', data)
}
export async function deleteMenu(id) {
await http.delete(`/api/system/menus/${id}`)
}
export async function fetchUsers(params) {
const { data } = await http.get('/api/system/users', { params })
return data.data
}
export async function createUser(data) {
await http.post('/api/system/users', data)
}
export async function updateUser(data) {
await http.put('/api/system/users', data)
}
export async function deleteUser(id) {
await http.delete(`/api/system/users/${id}`)
}
export async function deleteUsers(ids) {
await http.delete('/api/system/users', { data: ids })
}
export async function fetchRoles() {
const { data } = await http.get('/api/system/roles')
return data.data
}
export async function pageRoles(params) {
const { data } = await http.get('/api/system/roles/page', { params })
return data.data
}
export async function createRole(data) {
await http.post('/api/system/roles', data)
}
export async function updateRole(data) {
await http.put('/api/system/roles', data)
}
export async function deleteRole(id) {
await http.delete(`/api/system/roles/${id}`)
}
export async function fetchRoleMenuIds(roleId) {
const { data } = await http.get(`/api/system/roles/${roleId}/menus`)
return data.data
}
export async function updateRoleMenus(roleId, menuIds) {
await http.put(`/api/system/roles/${roleId}/menus`, { menuIds })
}
export async function fetchUserRoleIds(userId) {
const { data } = await http.get(`/api/system/users/${userId}/roles`)
return data.data
}
export async function updateUserRoles(userId, roleIds) {
const { data } = await http.put(`/api/system/users/${userId}/roles`, { roleIds })
return data.data
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

307
ui/src/assets/style.css Normal file
View File

@@ -0,0 +1,307 @@
:root {
/* iOS Color Palette */
--ios-primary: #3542ec;
--ios-primary-light: #0011FF;
--ios-success: #34C759;
--ios-warning: #FF9500;
--ios-danger: #FF4040;
--ios-gray: #8E8E93;
--ios-bg: #F2F4F8;
--ios-surface: rgba(255, 255, 255, 0.7);
--ios-border: rgba(0, 0, 0, 0.05);
/* Shapes & Shadows */
--ios-radius: 16px;
--ios-shadow: 0 4px 24px rgba(0, 0, 0, 0.04);
--ios-shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.08);
/* Fonts */
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Element Plus Overrides Variables */
--el-color-primary: var(--ios-primary);
--el-border-radius-base: 8px;
--el-border-radius-small: 6px;
--el-border-radius-round: 20px;
--el-bg-color-page: var(--ios-bg);
}
body {
margin: 0;
padding: 0;
background-color: var(--ios-bg);
color: #1d1d1f;
font-size: 14px;
line-height: 1.5;
}
/* Glassmorphism Utilities */
.glass {
background: var(--ios-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
/* Card Styling */
.el-card, .ios-card {
border: none !important;
border-radius: var(--ios-radius) !important;
background: white;
transition: all 0.3s ease;
}
.el-card__header {
border-bottom: 1px solid var(--ios-border) !important;
padding: 10px 24px !important;
font-weight: 600;
font-size: 16px;
}
/* Button Styling */
.el-button {
border-radius: 10px !important; /* Pill shape */
font-weight: 500 !important;
transition: all 0.2s ease !important;
}
.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;
}
/* Input Styling */
.el-input__wrapper {
height: 36px !important;
border-radius: 10px !important;
padding: 0 12px !important;
background-color: rgba(255,255,255,0.8) !important;
transition: all 0.2s ease;
}
.el-input__wrapper.is-focus {
background-color: white !important;
}
/* Table Styling */
.el-table {
--el-table-border-color: var(--ios-border);
--el-table-header-bg-color: transparent;
background-color: transparent !important;
border-radius: var(--ios-radius);
overflow: hidden;
}
.el-table th.el-table__cell {
background-color: #fafafa !important;
font-weight: 600;
color: #86868b;
border-bottom: 1px solid var(--ios-border) !important;
}
.el-table td.el-table__cell {
border-bottom: 1px solid var(--ios-border) !important;
}
.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell {
background-color: rgba(0, 122, 255, 0.03) !important;
}
.el-checkbox__inner {
width: 15px;
height: 15px;
border-radius: 4px;
}
/* Dialog Styling */
.el-dialog {
border-radius: 20px !important;
overflow: hidden;
}
.el-dialog__header {
margin-right: 0 !important;
padding: 20px 24px !important;
}
/* Dropdown (User menu) */
.ios-dropdown {
min-width: 220px;
padding: 10px !important;
}
.ios-dropdown-popper {
margin-top: 8px !important;
}
.ios-dropdown-popper.el-popper {
border-radius: 18px !important;
border: 1px solid var(--ios-border) !important;
box-shadow: var(--ios-shadow) !important;
overflow: hidden !important;
padding: 0 !important;
background: transparent !important;
}
.ios-dropdown-popper .el-popper__content {
border-radius: 18px !important;
overflow: hidden !important;
padding: 0 !important;
background: rgba(255, 255, 255, 0.98) !important;
}
.ios-dropdown-popper .el-popper__arrow {
display: none !important;
}
.ios-dropdown .el-dropdown-menu__item {
height: 44px;
line-height: 44px;
border-radius: 14px;
margin: 2px 0;
padding: 0 12px;
display: flex;
align-items: center;
gap: 10px;
color: #1d1d1f;
}
.ios-dropdown .el-dropdown-menu__item:not(.is-disabled):hover {
background-color: rgba(0, 0, 0, 0.04) !important;
}
.ios-dropdown .el-dropdown-menu__item.is-disabled {
cursor: default;
color: inherit;
opacity: 1;
}
.ios-dropdown .el-dropdown-menu__item.is-divided {
margin-top: 10px;
}
.ios-dropdown .el-dropdown-menu__item.is-divided::before {
height: 1px;
background-color: var(--ios-border);
left: 10px;
right: 10px;
top: -6px;
}
.ios-dropdown .ios-user-card {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 4px 2px;
}
.ios-dropdown .ios-user-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.ios-dropdown .ios-user-name {
font-weight: 700;
font-size: 15px;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ios-dropdown .ios-user-email {
font-size: 12px;
color: #8E8E93;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ios-dropdown .ios-logout-item {
justify-content: center;
border: 1px solid rgba(0, 0, 0, 0.08);
background: white;
}
.ios-dropdown .ios-logout-item:hover {
background-color: rgba(0, 0, 0, 0.02) !important;
}
.ios-dropdown .el-icon {
font-size: 16px;
}
/* Scrollbar Hiding (Global) */
::-webkit-scrollbar {
display: none;
}
html, body {
scrollbar-width: none;
-ms-overflow-style: none;
}
/* MessageBox Styling */
.el-message-box {
border-radius: var(--ios-radius) !important;
border: none !important;
box-shadow: var(--ios-shadow-hover) !important;
background-color: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: 0 !important;
width: 400px !important;
max-width: 90vw;
}
.el-message-box__header {
padding: 20px 24px 10px !important;
}
.el-message-box__title {
font-weight: 600;
font-size: 18px !important;
}
.el-message-box__headerbtn {
top: 20px !important;
right: 20px !important;
}
.el-message-box__content {
padding: 10px 24px 20px !important;
font-size: 15px !important;
color: #1d1d1f;
}
.el-message-box__btns {
padding: 0 24px 24px !important;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.el-message-box__btns .el-button {
margin-left: 0 !important;
min-width: 80px;
}

View File

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

205
ui/src/components/Error.vue Normal file
View File

@@ -0,0 +1,205 @@
<template>
<div class="error-page">
<div class="error-card glass">
<div class="error-visual" :class="{ forbidden: codeText === '403' }">
<div class="error-code">{{ codeText }}</div>
<!-- <div class="error-badge">
<el-icon :size="18">
<component :is="iconComponent" />
</el-icon>
<span class="error-badge-text">{{ badgeText }}</span>
</div> -->
</div>
<div class="error-content">
<div class="error-title">{{ displayTitle }}</div>
<div class="error-subtitle">{{ displaySubTitle }}</div>
<!-- <div class="error-actions">
<el-button type="primary" @click="goHome">
<el-icon class="btn-icon"><House /></el-icon>
返回首页
</el-button>
<el-button @click="goBack">
<el-icon class="btn-icon"><ArrowLeft /></el-icon>
返回上一页
</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>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { ArrowLeft, CircleCloseFilled, House, SwitchButton, WarningFilled } from '@element-plus/icons-vue'
const props = defineProps({
code: { type: [String, Number], default: '404' },
title: { type: String, default: '' },
subTitle: { type: String, default: '' },
})
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const codeText = computed(() => String(props.code || '404'))
const badgeText = computed(() => {
if (codeText.value === '403') return '无权限'
return '未找到'
})
const iconComponent = computed(() => {
if (codeText.value === '403') return CircleCloseFilled
return WarningFilled
})
const displayTitle = computed(() => {
if (props.title) return props.title
if (codeText.value === '403') return '抱歉,您没有权限访问该页面'
return '抱歉,您访问的页面不存在'
})
const displaySubTitle = computed(() => {
if (props.subTitle) return props.subTitle
if (codeText.value === '403') return '请联系管理员开通权限,或切换账号后重试。'
return '链接可能已失效,或页面已被删除。'
})
function goHome() {
if (auth.token && auth.roles.length > 0) {
router.push('/admin')
return
}
router.push('/portal/home')
}
function goBack() {
router.back()
}
function switchAccount() {
const redirect = route.query.from ? String(route.query.from) : '/admin'
auth.logout()
router.replace({ path: '/login', query: { redirect } })
}
</script>
<style scoped>
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100%;
padding: 32px 16px;
flex: 1;
}
.error-card {
width: 100%;
max-width: 760px;
border-radius: 24px;
box-shadow: var(--ios-shadow);
overflow: hidden;
display: grid;
grid-template-columns: 260px 1fr;
gap: 0;
}
.error-visual {
padding: 28px 24px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(135deg, rgba(0, 122, 255, 0.18) 0%, rgba(0, 198, 251, 0.12) 100%);
border-right: 1px solid var(--ios-border);
}
.error-visual.forbidden {
background: linear-gradient(135deg, rgba(255, 59, 48, 0.16) 0%, rgba(255, 149, 0, 0.10) 100%);
}
.error-code {
font-size: 68px;
font-weight: 900;
line-height: 1;
letter-spacing: -2px;
background: linear-gradient(135deg, var(--ios-primary) 0%, #00c6fb 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.error-visual.forbidden .error-code {
background: linear-gradient(135deg, var(--ios-danger) 0%, var(--ios-warning) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.error-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
width: fit-content;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.55);
}
.error-badge-text {
font-weight: 600;
color: #1d1d1f;
}
.error-content {
padding: 28px 28px 24px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.error-title {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.2px;
}
.error-subtitle {
color: #8e8e93;
font-size: 14px;
}
.error-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.btn-icon {
margin-right: 6px;
}
@media (max-width: 720px) {
.error-card {
grid-template-columns: 1fr;
}
.error-visual {
border-right: none;
border-bottom: 1px solid var(--ios-border);
}
.error-code {
font-size: 60px;
}
}
</style>

View File

@@ -0,0 +1,236 @@
<template>
<el-container style="min-height: 100vh; background-color: var(--ios-bg);">
<el-aside width="240px" class="admin-aside glass">
<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>
<el-menu :default-active="active" class="admin-menu" @select="onSelect">
<template v-for="m in menus" :key="m.id">
<el-sub-menu v-if="m.children && m.children.length" :index="m.path || String(m.id)">
<template #title>
<el-icon v-if="iconComp(m.icon)">
<component :is="iconComp(m.icon)" />
</el-icon>
<span>{{ m.name }}</span>
</template>
<el-menu-item
v-for="c in m.children"
:key="c.id"
:index="c.path || ''"
@click="toMenuPath(c)"
>
<el-icon v-if="iconComp(c.icon)">
<component :is="iconComp(c.icon)" />
</el-icon>
<span>{{ c.name }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="m.path || ''" @click="toMenuPath(m)">
<el-icon v-if="iconComp(m.icon)">
<component :is="iconComp(m.icon)" />
</el-icon>
<span>{{ m.name }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container>
<el-header height="64px" class="admin-header glass">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/admin' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.id">{{ item.name }}</el-breadcrumb-item>
</el-breadcrumb>
<el-dropdown
trigger="hover"
placement="bottom-end"
popper-class="ios-dropdown-popper"
:popper-options="{ modifiers: [{ name: 'offset', options: { offset: [8] } }] }"
>
<span class="user-dropdown-link">
<el-avatar :size="36" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
<el-avatar :size="36" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
<span class="username">{{ auth.nickname || auth.username }}</span>
</span>
<template #dropdown>
<el-dropdown-menu class="ios-dropdown">
<el-dropdown-item disabled class="ios-dropdown-user">
<div class="ios-user-card">
<el-avatar :size="44" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
<el-avatar :size="44" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
<div class="ios-user-meta">
<div class="ios-user-name">{{ auth.nickname || auth.username }}</div>
<div class="ios-user-email">{{ auth.email || '—' }}</div>
</div>
</div>
</el-dropdown-item>
<el-dropdown-item divided @click="router.push('/admin/profile')">
<el-icon><component :is="Icons.User" /></el-icon>
<span>个人资料</span>
</el-dropdown-item>
<el-dropdown-item @click="router.push('/portal')">
<el-icon><component :is="Icons.House" /></el-icon>
<span>访问前台</span>
</el-dropdown-item>
<el-dropdown-item divided class="ios-logout-item" @click="logout">
<el-icon><component :is="Icons.SwitchButton" /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main style="padding: 24px; overflow-x: hidden;">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useMenuStore } from '../stores/menu'
import * as Icons from '@element-plus/icons-vue'
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const menuStore = useMenuStore()
const active = computed(() => route.path)
const menus = computed(() => {
const root = menuStore.menuTree.length === 1 ? menuStore.menuTree[0] : undefined
if (root?.children?.length) return root.children
return menuStore.menuTree
})
const breadcrumbs = computed(() => {
const findPath = (nodes, targetPath, acc = []) => {
for (const node of nodes) {
if (node.path === targetPath) {
return [...acc, node]
}
if (node.children && node.children.length) {
const found = findPath(node.children, targetPath, [...acc, node])
if (found) return found
}
}
return null
}
return findPath(menuStore.menuTree, route.path) || []
})
function iconComp(name) {
if (!name) return undefined
return Icons[name]
}
function toMenuPath(node) {
if (node.type === 'M' && node.path) router.push(node.path)
}
function onSelect(index) {
router.push(index)
}
function logout() {
auth.logout()
menuStore.reset()
router.replace('/login')
}
</script>
<style scoped>
.admin-aside {
border-right: none;
display: flex;
flex-direction: column;
z-index: 20;
}
.logo-area {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 24px;
}
.logo-text {
font-weight: 700;
font-size: 20px;
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.admin-menu {
border-right: none;
padding: 16px 12px;
flex: 1;
overflow-y: auto;
}
:deep(.el-menu-item), :deep(.el-sub-menu__title) {
border-radius: 12px;
margin-bottom: 6px;
height: 48px;
line-height: 48px;
color: #555;
}
:deep(.el-menu-item:hover), :deep(.el-sub-menu__title:hover) {
background-color: rgba(0,0,0,0.03);
color: #000;
}
:deep(.el-menu-item.is-active) {
background-color: var(--ios-primary);
color: white;
font-weight: 600;
}
:deep(.el-sub-menu .el-menu-item) {
height: 44px;
line-height: 44px;
margin-left: 4px;
margin-right: 4px;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
z-index: 10;
border-bottom: 1px solid var(--ios-border);
}
.user-dropdown-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 20px;
transition: background-color 0.2s;
outline: none;
}
.default-avatar {
background-color: var(--ios-primary);
color: white;
font-weight: 600;
}
.username {
font-weight: 500;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<div class="portal-layout">
<header class="portal-header glass">
<div class="header-content">
<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">
<template v-if="!auth.token">
<el-button type="primary" @click="goLogin">登录</el-button>
<el-button @click="goRegister" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">注册</el-button>
</template>
<el-dropdown v-else trigger="hover" placement="bottom-end" popper-class="ios-dropdown-popper">
<span class="user-link">
<el-avatar :size="32" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
<el-avatar :size="32" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
<span class="username">{{ auth.nickname || auth.username }}</span>
</span>
<template #dropdown>
<el-dropdown-menu class="ios-dropdown">
<el-dropdown-item disabled class="ios-dropdown-user">
<div class="ios-user-card">
<el-avatar :size="44" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
<el-avatar :size="44" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
<div class="ios-user-meta">
<div class="ios-user-name">{{ auth.nickname || auth.username }}</div>
<div class="ios-user-email">{{ auth.email || '—' }}</div>
</div>
</div>
</el-dropdown-item>
<el-dropdown-item divided @click="go('/portal/profile')">
<el-icon><component :is="Icons.User" /></el-icon>
<span>个人中心</span>
</el-dropdown-item>
<el-dropdown-item divided class="ios-logout-item" @click="logout">
<el-icon><component :is="Icons.SwitchButton" /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</header>
<main class="portal-main">
<router-view />
</main>
</div>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import * as Icons from '@element-plus/icons-vue'
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const active = () => route.path
function go(path) {
router.push(path)
}
function goLogin() {
router.push('/login')
}
function goRegister() {
router.push('/register')
}
function logout() {
auth.logout()
router.push('/portal/home')
}
</script>
<style scoped>
.portal-layout {
min-height: 100vh;
background-color: var(--ios-bg);
display: flex;
flex-direction: column;
}
.portal-header {
position: sticky;
top: 0;
z-index: 100;
height: 64px;
border-bottom: 1px solid var(--ios-border);
flex-shrink: 0;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
height: 100%;
display: flex;
align-items: center;
padding: 0 24px;
}
.logo {
font-weight: 700;
font-size: 22px;
margin-right: 40px;
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.portal-menu {
flex: 1;
border-bottom: none !important;
background: transparent !important;
}
:deep(.el-menu-item) {
background: transparent !important;
font-weight: 500;
font-size: 15px;
border-bottom: 2px solid transparent !important;
color: #555 !important;
}
:deep(.el-menu-item.is-active) {
color: var(--ios-primary) !important;
border-bottom-color: var(--ios-primary) !important;
}
:deep(.el-menu-item:hover) {
color: var(--ios-primary) !important;
}
.auth-actions {
margin-left: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.user-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 20px;
transition: background-color 0.2s;
outline: none;
}
.default-avatar {
background-color: var(--ios-primary);
color: white;
font-weight: 600;
}
.username {
font-weight: 500;
}
.portal-main {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
</style>

22
ui/src/main.js Normal file
View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import './assets/style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

86
ui/src/router/index.js Normal file
View File

@@ -0,0 +1,86 @@
import { createRouter, createWebHistory } from 'vue-router'
import PortalLayout from '../layouts/PortalLayout.vue'
import AdminLayout from '../layouts/AdminLayout.vue'
import Login from '../views/Login.vue'
import Register from '../views/Register.vue'
import { setupGuards } from './setupGuards'
export const ROUTE_NAMES = {
AdminRoot: 'AdminRoot',
AdminLayout: 'AdminLayout',
}
const routes = [
{ path: '/', redirect: '/portal/home' },
{ path: '/login', component: Login },
{ path: '/register', component: Register },
{
path: '/portal',
component: PortalLayout,
children: [
{ path: '', redirect: '/portal/home' },
{ path: 'home', component: () => import('../views/portal/Home.vue') },
{ path: 'monitor', component: () => import('../views/portal/Monitor.vue') },
{ path: 'chat', component: () => import('../views/portal/Chat.vue') },
{ path: 'about', component: () => import('../views/portal/About.vue') },
{ path: 'profile', component: () => import('../views/Profile.vue') },
{
path: '403',
component: () => import('../components/Error.vue'),
props: {
code: 403,
title: '抱歉,您没有权限访问该页面',
subTitle: '请联系管理员开通权限,或切换账号后重试。',
},
},
{
path: ':pathMatch(.*)*',
component: () => import('../components/Error.vue'),
props: { code: 404 },
},
],
},
{
path: '/admin',
name: ROUTE_NAMES.AdminLayout,
component: AdminLayout,
children: [
{ path: '', name: ROUTE_NAMES.AdminRoot, redirect: '/admin/dashboard' },
{ path: 'profile', component: () => import('../views/Profile.vue') },
{
path: '403',
component: () => import('../components/Error.vue'),
props: {
code: 403,
title: '抱歉,您没有权限访问该页面',
subTitle: '请联系管理员开通权限,或切换账号后重试。',
},
},
{
path: ':pathMatch(.*)*',
component: () => import('../components/Error.vue'),
props: { code: 404 },
},
],
},
{
path: '/:pathMatch(.*)*',
component: PortalLayout,
children: [
{
path: '',
component: () => import('../components/Error.vue'),
props: { code: 404 },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
setupGuards(router)
export default router

View File

@@ -0,0 +1,40 @@
import { useAuthStore } from '../stores/auth'
import { useMenuStore } from '../stores/menu'
import { buildAdminRoutesFromMenus } from './utils'
import { ROUTE_NAMES } from './index'
export function setupGuards(router) {
router.beforeEach(async (to) => {
const auth = useAuthStore()
const menu = useMenuStore()
const isAdminPath = to.path.startsWith('/admin')
if (!auth.token) {
if (isAdminPath) return { path: '/login', query: { redirect: to.fullPath } }
return true
}
if (!auth.meLoaded) {
await auth.fetchMe()
}
if (auth.roles.length === 0) {
if (isAdminPath) return '/portal/home'
return true
}
if (to.path === '/login' || to.path === '/register') return '/admin'
if (isAdminPath && !menu.routesLoaded) {
await menu.fetchMenus()
const adminRoutes = buildAdminRoutesFromMenus(menu.menuTree)
for (const r of adminRoutes) router.addRoute(ROUTE_NAMES.AdminLayout, r)
menu.routesLoaded = true
return { ...to, replace: true }
}
return true
})
}

41
ui/src/router/utils.js Normal file
View File

@@ -0,0 +1,41 @@
const viewModules = import.meta.glob('../views/**/*.vue')
function resolveView(component) {
if (!component) return undefined
const key = `../views/${component}.vue`
const loader = viewModules[key]
if (!loader) return undefined
return loader
}
export function buildAdminRoutesFromMenus(menus) {
const routes = []
const normalizeAdminChildPath = (rawPath) => {
if (!rawPath) return ''
if (rawPath.startsWith('/admin/')) return rawPath.slice('/admin/'.length)
if (rawPath === '/admin') return ''
return rawPath.startsWith('/') ? rawPath.slice(1) : rawPath
}
const walk = (nodes) => {
for (const n of nodes) {
if (n.type === 'M') {
const component = resolveView(n.component)
if (!component) continue
const childPath = normalizeAdminChildPath(n.path || '')
if (!childPath) continue
routes.push({
path: childPath,
component,
meta: { title: n.name, icon: n.icon },
})
}
if (n.children?.length) walk(n.children)
}
}
walk(menus)
return routes
}

57
ui/src/stores/auth.js Normal file
View File

@@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { login, me, register } from '../api/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || '',
meLoaded: false,
userId: 0,
username: '',
nickname: '',
avatarPath: '',
phone: '',
email: '',
gender: 0,
roles: [],
}),
actions: {
async login(req) {
const res = await login(req)
this.token = res.token
localStorage.setItem('token', this.token)
// Login response only has limited info, fetch full profile
await this.fetchMe()
return res
},
async register(req) {
return register(req)
},
async fetchMe() {
const res = await me()
this.userId = res.userId
this.username = res.username
this.nickname = res.nickname || ''
this.avatarPath = res.avatarPath || ''
this.phone = res.phone || ''
this.email = res.email || ''
this.gender = res.gender || 0
this.roles = res.roles || []
this.meLoaded = true
return res
},
logout() {
this.token = ''
localStorage.clear()
this.meLoaded = false
this.userId = 0
this.username = ''
this.nickname = ''
this.avatarPath = ''
this.phone = ''
this.email = ''
this.gender = 0
this.roles = []
},
},
})

20
ui/src/stores/menu.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineStore } from 'pinia'
import { fetchMenuTree } from '../api/system'
export const useMenuStore = defineStore('menu', {
state: () => ({
menuTree: [],
routesLoaded: false,
}),
actions: {
async fetchMenus() {
this.menuTree = await fetchMenuTree()
return this.menuTree
},
reset() {
this.menuTree = []
this.routesLoaded = false
},
},
})

150
ui/src/views/Login.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<div class="login-container">
<div class="login-content">
<el-card class="login-card glass">
<div class="login-header">
<img src="/logo.png" alt="Logo" style="width: 96px; height: 52px; margin-bottom: 1px;" />
<h1>Hertz Admin</h1>
</div>
<el-form label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="">
<el-input v-model="form.username" autocomplete="username" placeholder="请输入用户名" :prefix-icon="User" />
</el-form-item>
<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>
<div class="form-footer">
<el-button type="info" link @click="router.push('/portal/home')">游客访问</el-button>
<el-button type="primary" link @click="goRegister">注册新账户</el-button>
</div>
</el-form>
</el-card>
</div>
</div>
</template>
<script setup>
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, 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() {
loading.value = true
try {
await auth.login(form)
if (auth.roles.length === 0) {
await router.replace('/portal/home')
return
}
const redirect = route.query.redirect || '/admin'
await router.replace(redirect)
} catch (e) {
ElMessage.error(e?.response?.data?.message || '登录失败')
await fetchCaptcha()
} finally {
loading.value = false
}
}
function goRegister() {
router.push('/register')
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
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;
padding: 16px;
}
.login-content {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
font-size: 32px;
font-weight: 800;
margin: 0 0 8px;
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.login-header p {
color: #8E8E93;
margin: 0;
font-size: 16px;
}
.login-card {
border: 1px solid rgba(255,255,255,0.6);
}
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 0 4px;
}
</style>

376
ui/src/views/Profile.vue Normal file
View File

@@ -0,0 +1,376 @@
<template>
<el-row :gutter="20">
<!-- Left Column: Profile Card -->
<el-col :span="8" :xs="24">
<el-card class="profile-card" :body-style="{ padding: '0px' }">
<div class="profile-header">
<img src="@/assets/img/profile_bg.jpg" alt="Cover" class="cover-image" />
<div class="avatar-wrapper">
<el-upload
class="avatar-uploader-card"
:show-file-list="false"
:http-request="handleAvatarUpload"
:before-upload="beforeAvatarUpload"
>
<img v-if="form.avatarPath" :src="apiBase + form.avatarPath" class="profile-avatar" />
<div v-else class="profile-avatar-placeholder">
<el-icon><Plus /></el-icon>
</div>
<div class="avatar-mask">
<el-icon><Plus /></el-icon>
</div>
</el-upload>
</div>
</div>
<div class="profile-info">
<h2 class="profile-name">{{ form.nickname || form.username || 'User' }}</h2>
<div class="profile-details">
<div class="detail-item">
<el-icon><Message /></el-icon>
<span>{{ form.email || '未设置邮箱' }}</span>
</div>
<div class="detail-item">
<el-icon><Iphone /></el-icon>
<span>{{ form.phone || '未设置手机' }}</span>
</div>
<div class="detail-item">
<el-icon><User /></el-icon>
<span>{{ form.gender === 1 ? '男' : (form.gender === 2 ? '女' : '未知') }}</span>
</div>
</div>
<!-- <el-divider content-position="center">标签</el-divider>
<div class="profile-tags">
<el-tag effect="plain">交互专家</el-tag>
<el-tag effect="plain" type="success">设计爱好者</el-tag>
<el-tag effect="plain" type="warning">Vue.js</el-tag>
<el-tag effect="plain" type="info">Spring Boot</el-tag>
</div> -->
</div>
</el-card>
</el-col>
<!-- Right Column: Settings Tabs -->
<el-col :span="16" :xs="24">
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane label="基本设置" name="info">
<div class="tab-content">
<el-form :model="form" label-position="top" class="settings-form">
<el-row :gutter="24">
<el-col :span="12" :xs="24">
<el-form-item label="昵称">
<el-input v-model="form.nickname" placeholder="请输入昵称" />
</el-form-item>
</el-col>
<el-col :span="12" :xs="24">
<el-form-item label="性别">
<el-select v-model="form.gender" style="width: 100%">
<el-option label="未知" :value="0" />
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" :xs="24">
<el-form-item label="手机">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
</el-col>
<el-col :span="12" :xs="24">
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="个人介绍">
<el-input
v-model="form.bio"
type="textarea"
:rows="4"
placeholder="个人介绍..."
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item>
<el-button type="primary" :loading="loading" @click="onSubmit">更新基本信息</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</el-tab-pane>
<el-tab-pane label="安全设置" name="password">
<div class="tab-content">
<el-form :model="pwdForm" label-position="top" style="max-width: 500px">
<el-form-item label="旧密码">
<el-input v-model="pwdForm.oldPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认新密码">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pwdLoading" @click="onChangePassword">修改密码</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { updateProfile, updatePassword } from '../api/auth'
import { uploadFile } from '../api/common'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, User, Message, Iphone, Location } from '@element-plus/icons-vue'
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
const auth = useAuthStore()
const router = useRouter()
const form = ref({
nickname: '',
phone: '',
email: '',
gender: 0,
avatarPath: ''
})
const pwdForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const pwdLoading = ref(false)
const loading = ref(false)
const activeTab = ref('info')
onMounted(() => {
form.value = {
nickname: auth.nickname,
phone: auth.phone,
email: auth.email,
gender: auth.gender,
avatarPath: auth.avatarPath
}
})
async function handleAvatarUpload(options) {
try {
const res = await uploadFile(options.file)
form.value.avatarPath = res.url
ElMessage.success('头像上传成功')
} catch (e) {
ElMessage.error('上传失败')
}
}
function beforeAvatarUpload(rawFile) {
if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') {
ElMessage.error('Avatar picture must be JPG format!')
return false
} else if (rawFile.size / 1024 / 1024 > 2) {
ElMessage.error('Avatar picture size can not exceed 2MB!')
return false
}
return true
}
async function onSubmit() {
loading.value = true
try {
await updateProfile(form.value)
ElMessage.success('更新成功')
await auth.fetchMe() // refresh store
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
async function onChangePassword() {
if (!pwdForm.oldPassword || !pwdForm.newPassword || !pwdForm.confirmPassword) {
ElMessage.warning('请填写完整密码信息')
return
}
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
ElMessage.warning('两次输入的新密码不一致')
return
}
pwdLoading.value = true
try {
await updatePassword({
oldPassword: pwdForm.oldPassword,
newPassword: pwdForm.newPassword
})
ElMessage.success('密码修改成功,请重新登录')
pwdForm.oldPassword = ''
pwdForm.newPassword = ''
pwdForm.confirmPassword = ''
// Logout and redirect to login
auth.logout()
router.replace('/login')
} catch (e) {
ElMessage.error(e?.response?.data?.message || '密码修改失败')
} finally {
pwdLoading.value = false
}
}
</script>
<style scoped>
.profile-card {
text-align: center;
padding-bottom: 20px;
min-height: 438px;
}
.profile-header {
position: relative;
margin-bottom: 50px;
}
.cover-image {
width: 100%;
height: 160px;
object-fit: cover;
}
.avatar-wrapper {
position: absolute;
left: 50%;
bottom: -40px;
transform: translateX(-50%);
z-index: 10;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
border: 4px solid #fff;
object-fit: cover;
background-color: #fff;
}
.profile-avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
border: 4px solid #fff;
background-color: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
opacity: 0;
transition: opacity 0.3s;
cursor: pointer;
}
.avatar-wrapper:hover .avatar-mask {
opacity: 1;
}
.profile-info {
padding: 0 24px;
}
.profile-name {
font-size: 20px;
font-weight: 600;
margin: 0 0 4px;
color: #303133;
}
.profile-bio {
color: #909399;
font-size: 14px;
margin: 0 0 24px;
}
.profile-details {
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
margin-bottom: 24px;
margin-top: 24px;
}
.detail-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: #606266;
}
.profile-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.tab-title {
font-size: 20px;
font-weight: 500;
margin-bottom: 24px;
color: #303133;
}
.tab-content {
padding: 0 12px;
}
</style>
<style>
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
text-align: center;
}
</style>

149
ui/src/views/Register.vue Normal file
View File

@@ -0,0 +1,149 @@
<template>
<div class="register-container">
<div class="register-content">
<el-card class="register-card glass">
<div class="register-header">
<h1>创建账户</h1>
</div>
<el-form label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="">
<el-input v-model="form.username" autocomplete="username" placeholder="设置用户名" :prefix-icon="User" />
</el-form-item>
<el-form-item label="">
<el-input v-model="form.password" type="password" autocomplete="new-password" show-password placeholder="设置密码" :prefix-icon="Lock" />
</el-form-item>
<el-form-item label="">
<el-input v-model="form.confirmPassword" type="password" autocomplete="new-password" show-password placeholder="确认密码" :prefix-icon="Lock" />
</el-form-item>
<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>
<div class="form-footer">
<el-button link @click="router.push('/login')">已有账户去登录</el-button>
</div>
</el-form>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
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, 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() {
if (form.password !== form.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
loading.value = true
try {
const { confirmPassword, ...payload } = form
await auth.register(payload)
ElMessage.success('注册成功,请登录')
await router.replace('/login')
} catch (e) {
ElMessage.error(e?.response?.data?.message || '注册失败')
await fetchCaptcha()
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register-container {
min-height: 100vh;
display: flex;
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;
padding: 16px;
}
.register-content {
width: 100%;
max-width: 400px;
}
.register-header {
text-align: center;
margin-bottom: 32px;
}
.register-header h1 {
font-size: 32px;
font-weight: 800;
margin: 0 0 8px;
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.register-header p {
color: #8E8E93;
margin: 0;
font-size: 16px;
}
.register-card {
border: 1px solid rgba(255,255,255,0.6);
}
.form-footer {
text-align: center;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<div class="dashboard-container">
<!-- Welcome Section -->
<el-card class="welcome-card mb-4" shadow="hover">
<div class="welcome-content">
<div class="welcome-left">
<el-avatar :size="64" :src="avatarSrc" class="welcome-avatar">
<template #default>
{{ auth.nickname?.charAt(0) || auth.username?.charAt(0) }}
</template>
</el-avatar>
<div class="welcome-text">
<h2 class="greeting">{{ greeting }}{{ auth.nickname || auth.username }}</h2>
<p class="subtitle">欢迎回到 Hertz Admin今天也是充满活力的一天</p>
</div>
</div>
<!-- <div class="welcome-right">
<div class="stat-item">
<span class="label">我的角色</span>
<span class="value">{{ auth.roles }}</span>
</div>
</div> -->
</div>
</el-card>
<!-- Statistics Cards -->
<el-row :gutter="20" class="mb-4">
<el-col :span="8" :xs="24" class="mb-xs-4">
<el-card shadow="hover" class="stat-card" :body-style="{ padding: '20px' }">
<div class="stat-icon user-bg">
<el-icon><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.userCount }}</div>
<div class="stat-label">总用户数</div>
</div>
</el-card>
</el-col>
<el-col :span="8" :xs="24" class="mb-xs-4">
<el-card shadow="hover" class="stat-card" :body-style="{ padding: '20px' }">
<div class="stat-icon role-bg">
<el-icon><Stamp /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.roleCount }}</div>
<div class="stat-label">角色数量</div>
</div>
</el-card>
</el-col>
<el-col :span="8" :xs="24">
<el-card shadow="hover" class="stat-card" :body-style="{ padding: '20px' }">
<div class="stat-icon menu-bg">
<el-icon><IconMenu /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.menuCount }}</div>
<div class="stat-label">菜单项</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Content Grid -->
<el-row :gutter="20">
<!-- Recent Users -->
<el-col :span="16" :xs="24" class="mb-xs-4">
<el-card shadow="hover" class="h-100">
<template #header>
<div class="card-header">
<span><el-icon class="mr-1"><Timer /></el-icon> 最新加入用户</span>
<el-button text type="primary" @click="router.push('/admin/system/user')">
查看全部 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
</div>
</template>
<el-table :data="recentUsers" style="width: 100%" v-loading="loading">
<el-table-column prop="username" label="用户名" min-width="120">
<template #default="{ row }">
<div class="user-cell">
<el-avatar :size="24" :src="resolveFileUrl(row.avatarPath)" class="mr-2">
{{ row.nickname?.charAt(0) || row.username.charAt(0) }}
</el-avatar>
<span>{{ row.username }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" min-width="120" />
<el-table-column prop="createdAt" label="注册时间" min-width="180">
<template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<!-- Quick Actions -->
<el-col :span="8" :xs="24">
<el-card shadow="hover" class="h-100">
<template #header>
<div class="card-header">
<span>快捷导航</span>
</div>
</template>
<div class="quick-actions">
<div
v-for="action in quickActions"
:key="action.path"
class="action-item"
@click="router.push(action.path)"
>
<div class="action-icon" :style="{ backgroundColor: action.color + '20', color: action.color }">
<el-icon><component :is="action.icon" /></el-icon>
</div>
<span class="action-name">{{ action.name }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { fetchUsers, pageRoles, pageMenus } from '../../api/system'
import { User, Stamp, Menu as IconMenu, Timer, ArrowRight, DataLine, Setting } from '@element-plus/icons-vue'
const router = useRouter()
const auth = useAuthStore()
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
const stats = ref({
userCount: 0,
roleCount: 0,
menuCount: 0
})
const recentUsers = ref([])
const loading = ref(true)
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '夜深了'
if (hour < 9) return '早上好'
if (hour < 12) return '上午好'
if (hour < 14) return '中午好'
if (hour < 17) return '下午好'
if (hour < 19) return '傍晚好'
return '晚上好'
})
function resolveFileUrl(path) {
if (!path) return undefined
if (/^https?:\/\//i.test(path)) return path
const normalizedBase = apiBase.endsWith('/') ? apiBase.slice(0, -1) : apiBase
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${normalizedBase}${normalizedPath}`
}
const avatarSrc = computed(() => resolveFileUrl(auth.avatarPath))
const quickActions = [
{ name: '用户管理', path: '/admin/system/user', icon: User, color: '#409EFF' },
{ name: '角色管理', path: '/admin/system/role', icon: Stamp, color: '#67C23A' },
{ name: '菜单管理', path: '/admin/system/menu', icon: IconMenu, color: '#E6A23C' },
{ name: '个人资料', path: '/admin/profile', icon: Setting, color: '#909399' }
]
async function loadData() {
loading.value = true
try {
// Parallel requests for stats
const [usersRes, rolesRes, menusRes] = await Promise.all([
fetchUsers({ page: 1, size: 5 }), // Get first page to show recent users too
pageRoles({ page: 1, size: 1 }),
pageMenus({ page: 1, size: 1 })
])
stats.value = {
userCount: usersRes.total,
roleCount: rolesRes.total,
menuCount: menusRes.total
}
recentUsers.value = usersRes.records
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.mb-4 {
margin-bottom: 24px;
}
.mr-1 {
margin-right: 4px;
}
.mr-2 {
margin-right: 8px;
}
.h-100 {
height: 100%;
}
/* Welcome Card */
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-left {
display: flex;
align-items: center;
gap: 20px;
}
.welcome-text .greeting {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px;
color: #303133;
}
.welcome-text .subtitle {
font-size: 14px;
color: #909399;
margin: 0;
}
.welcome-right {
display: flex;
gap: 32px;
padding-right: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.stat-item .label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.stat-item .value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
/* Stat Cards */
.stat-card {
border: none;
transition: transform 0.3s;
}
.stat-card :deep(.el-card__body) {
display: flex;
align-items: center;
gap: 20px;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
justify-content: center;
align-items: center;
font-size: 28px;
}
.user-bg { background-color: #ecf5ff; color: #409eff; }
.role-bg { background-color: #f0f9eb; color: #67c23a; }
.menu-bg { background-color: #fdf6ec; color: #e6a23c; }
.stat-info .stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
color: #303133;
margin-bottom: 4px;
}
.stat-info .stat-label {
font-size: 14px;
color: #909399;
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Recent Users */
.user-cell {
display: flex;
align-items: center;
}
/* Quick Actions */
.quick-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.action-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
background-color: #ecf5ff;
cursor: pointer;
transition: all 0.3s;
}
.action-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
font-size: 20px;
}
.action-name {
font-size: 14px;
font-weight: 500;
color: #606266;
}
/* Responsive */
@media (max-width: 768px) {
.welcome-content {
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.welcome-right {
width: 100%;
justify-content: space-between;
padding-right: 0;
border-top: 1px solid #EBEEF5;
padding-top: 16px;
}
.mb-xs-4 {
margin-bottom: 24px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,247 @@
<template>
<div>
<ContentPage>
<!-- <template #header>角色管理</template> -->
<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%; height: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="roleKey" label="角色标识" />
<el-table-column prop="roleName" label="角色名称" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 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="Setting" style="font-size: 15px; padding: 6px 8px" @click="openPermissions(row)" />
</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>
<template #pagination>
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
v-model:page-size="query.size"
v-model:current-page="query.page"
:page-sizes="[10, 20, 50, 100]"
@size-change="load"
@current-change="load"
/>
</template>
</ContentPage>
<!-- Role Dialog -->
<el-dialog v-model="roleDialogOpen" :title="roleData.id ? '编辑角色' : '新增角色'" width="500px">
<el-form ref="roleFormRef" :model="roleData" :rules="roleRules" label-width="80px" v-loading="roleLoading">
<el-form-item label="角色标识" prop="roleKey">
<el-input v-model="roleData.roleKey" :disabled="!!roleData.id" />
</el-form-item>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="roleData.roleName" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="roleData.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="roleDialogOpen = false">取消</el-button>
<el-button type="primary" :loading="roleLoading" @click="saveRole">保存</el-button>
</template>
</el-dialog>
<!-- Permission Dialog -->
<el-dialog v-model="permDialogOpen" title="分配权限" width="500px">
<div v-loading="permLoading" style="height: 400px; overflow-y: auto">
<el-tree
ref="treeRef"
:data="menuTree"
show-checkbox
node-key="id"
default-expand-all
:props="{ label: 'name', children: 'children' }"
/>
</div>
<template #footer>
<el-button @click="permDialogOpen = false">取消</el-button>
<el-button type="primary" :loading="permLoading" @click="savePermissions">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<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'
import {
createRole,
deleteRole,
fetchMenuTree,
fetchRoleMenuIds,
pageRoles,
updateRole,
updateRoleMenus,
} from '../../../api/system'
const query = reactive({
page: 1,
size: 10,
keyword: '',
})
const roles = ref([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const page = await pageRoles(query)
roles.value = page.records
total.value = page.total
} finally {
loading.value = false
}
}
// Role Form
const roleDialogOpen = ref(false)
const roleLoading = ref(false)
const roleFormRef = ref(null)
const roleData = reactive({
id: undefined,
roleKey: '',
roleName: '',
status: 1,
})
const roleRules = {
roleKey: [{ required: true, message: '请输入角色标识', trigger: 'blur' }],
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
}
function openCreate() {
roleData.id = undefined
roleData.roleKey = ''
roleData.roleName = ''
roleData.status = 1
roleDialogOpen.value = true
}
function openEdit(row) {
Object.assign(roleData, row)
roleDialogOpen.value = true
}
async function saveRole() {
if (!roleFormRef.value) return
await roleFormRef.value.validate()
roleLoading.value = true
try {
if (roleData.id) {
await updateRole(roleData)
ElMessage.success('更新成功')
} else {
await createRole(roleData)
ElMessage.success('创建成功')
}
roleDialogOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
roleLoading.value = false
}
}
function handleDelete(row) {
ElMessageBox.confirm(`确认删除角色 "${row.roleName}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await deleteRole(row.id)
ElMessage.success('删除成功')
load()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '删除失败')
}
})
}
// Permissions
const permDialogOpen = ref(false)
const permLoading = ref(false)
const menuTree = ref([])
const currentRole = ref(null)
const treeRef = ref(null)
async function openPermissions(row) {
currentRole.value = row
permDialogOpen.value = true
permLoading.value = true
try {
const [menus, checkedKeys] = await Promise.all([fetchMenuTree(), fetchRoleMenuIds(row.id)])
// fetchMenuTree returns a tree.
// However, if we're not admin, it might return partial tree.
// The requirement says "admin has all menus".
menuTree.value = menus
// We need to wait for next tick or manually set checked keys
// But tree might render async.
// Element Plus tree setCheckedKeys works better after data is set.
setTimeout(() => {
treeRef.value?.setCheckedKeys(checkedKeys, false)
}, 0)
} finally {
permLoading.value = false
}
}
async function savePermissions() {
if (!currentRole.value) return
permLoading.value = true
try {
// We need both checked nodes and half-checked nodes if the backend logic requires it.
// Typically backend expects all menu IDs needed to reconstruct the permission set.
// Element Plus: getCheckedKeys() returns only checked leaf nodes by default if we don't include half-checked.
// But usually for menu permissions we want all checked + half-checked (parent nodes).
const checked = treeRef.value.getCheckedKeys()
const halfChecked = treeRef.value.getHalfCheckedKeys()
const allIds = [...checked, ...halfChecked]
await updateRoleMenus(currentRole.value.id, allIds)
ElMessage.success('权限保存成功')
permDialogOpen.value = false
} catch (e) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
permLoading.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,374 @@
<template>
<ContentPage>
<!-- <template #header>用户管理</template> -->
<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%; 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" />
<el-avatar shape="square" :size="40" v-else>{{ row.nickname?.charAt(0) }}</el-avatar>
<div style="line-height: 1.2">
<div style="font-weight: 600">{{ row.username }}</div>
<div style="font-size: 12px; color: var(--el-text-color-secondary)">{{ row.email || '-' }}</div>
</div>
</div>
</template>
</el-table-column>
<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>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" />
<el-table-column label="性别" width="100">
<template #default="{ row }">
<span v-if="row.gender === 1"></span>
<span v-else-if="row.gender === 2"></span>
<span v-else>未知</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 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="UserFilled" style="font-size: 15px; padding: 6px 8px" @click="openAssignRoles(row)" />
</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>
<template #pagination>
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
v-model:page-size="query.size"
v-model:current-page="query.page"
:page-sizes="[10, 20, 50, 100]"
@size-change="loadUsers"
@current-change="loadUsers"
/>
</template>
</ContentPage>
<el-dialog v-model="roleDialogOpen" title="分配角色" width="520px">
<el-form v-loading="roleLoading" label-width="90px">
<el-form-item label="用户">
<span>{{ currentUser?.username }}</span>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="selectedRoleIds" multiple filterable style="width: 100%">
<el-option v-for="r in allRoles" :key="r.id" :label="`${r.roleName}(${r.roleKey})`" :value="r.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="roleDialogOpen = false">取消</el-button>
<el-button type="primary" :loading="roleLoading" @click="saveRoles">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="formDialogOpen" :title="formData.id ? '编辑用户' : '新增用户'" width="520px">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px" v-loading="formLoading">
<el-form-item label="头像">
<el-upload
class="avatar-uploader"
:show-file-list="false"
:http-request="handleAvatarUpload"
style="border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; overflow: hidden; display: inline-block;"
>
<img v-if="formData.avatarPath" :src="apiBase + formData.avatarPath" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" :disabled="!!formData.id" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="formData.nickname" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="formData.password" type="password" placeholder="不修改请留空" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio :value="0">未知</el-radio>
<el-radio :value="1"></el-radio>
<el-radio :value="2"></el-radio>
</el-radio-group>
</el-form-item>
<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="formDialogOpen = false">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="saveUser">保存</el-button>
</template>
</el-dialog>
</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,
updateUser,
updateUserRoles,
} from '../../../api/system'
import { uploadFile } from '../../../api/common'
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
const query = reactive({
page: 1,
size: 10,
keyword: '',
})
const loading = ref(false)
const users = ref([])
const total = ref(0)
async function loadUsers() {
loading.value = true
try {
const page = await fetchUsers(query)
users.value = page.records
total.value = page.total
} finally {
loading.value = false
}
}
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)
const formData = reactive({
id: undefined,
username: '',
password: '',
nickname: '',
avatarPath: '',
phone: '',
email: '',
gender: 0,
status: 1,
})
const rules = computed(() => {
const r = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
}
if (!formData.id) {
r.password = [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
return r
})
function openCreate() {
formData.id = undefined
formData.username = ''
formData.password = ''
formData.nickname = ''
formData.avatarPath = ''
formData.phone = ''
formData.email = ''
formData.gender = 0
formData.status = 1
formDialogOpen.value = true
}
function openEdit(row) {
Object.assign(formData, row)
formData.password = '' // Don't show password
formDialogOpen.value = true
}
async function saveUser() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (e) {
return
}
formLoading.value = true
try {
if (formData.id) {
await updateUser(formData)
ElMessage.success('更新成功')
} else {
await createUser(formData)
ElMessage.success('创建成功')
}
formDialogOpen.value = false
loadUsers()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
formLoading.value = false
}
}
function handleDelete(row) {
ElMessageBox.confirm(`确认删除用户 "${row.username}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await deleteUser(row.id)
ElMessage.success('删除成功')
loadUsers()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '删除失败')
}
})
}
async function handleAvatarUpload(options) {
try {
const res = await uploadFile(options.file)
// The backend returns { url: '...', thumbUrl: '...' }
// We store the relative path or full URL depending on requirement.
// Here we assume the backend returns a relative path like 'avatar/...'
// and we prepend the base URL when displaying.
// But for simplicity, let's just save what the backend returns.
formData.avatarPath = res.url
} catch (e) {
ElMessage.error('上传失败')
}
}
onMounted(loadUsers)
const roleDialogOpen = ref(false)
const roleLoading = ref(false)
const allRoles = ref([])
const selectedRoleIds = ref([])
const currentUser = ref(null)
async function openAssignRoles(u) {
currentUser.value = u
roleDialogOpen.value = true
roleLoading.value = true
try {
const [roles, roleIds] = await Promise.all([fetchRoles(), fetchUserRoleIds(u.id)])
allRoles.value = roles
selectedRoleIds.value = roleIds
} finally {
roleLoading.value = false
}
}
async function saveRoles() {
if (!currentUser.value) return
roleLoading.value = true
try {
await updateUserRoles(currentUser.value.id, selectedRoleIds.value)
ElMessage.success('保存成功')
roleDialogOpen.value = false
loadUsers()
} catch (e) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
roleLoading.value = false
}
}
</script>
<style scoped>
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}
.avatar {
width: 100px;
height: 100px;
display: block;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="about-container">
<div class="about-header glass">
<img src="/logo.png" alt="Logo" class="about-logo" />
<h1 class="about-title">Hertz Admin</h1>
<p class="about-subtitle">现代化全栈权限管理系统解决方案</p>
</div>
<div class="about-content">
<el-row :gutter="24">
<el-col :span="14">
<el-card class="info-card glass" header="项目简介">
<p class="intro-text">
Hertz Admin 是一个基于 <strong>Spring Boot 3</strong> <strong>Vue 3</strong> 构建的前后端分离权限管理系统
它集成了最新的技术栈提供了一套完整的 RBAC基于角色的访问控制解决方案支持动态菜单系统监控AI 对话等功能
旨在帮助开发者快速搭建企业级后台管理系统
</p>
</el-card>
<el-card class="info-card glass mt-4" header="核心技术栈">
<el-descriptions :column="2" border>
<el-descriptions-item label="后端框架">Spring Boot 3.4</el-descriptions-item>
<el-descriptions-item label="ORM 框架">MyBatis-Plus 3.5</el-descriptions-item>
<el-descriptions-item label="安全框架">Spring Security + JWT</el-descriptions-item>
<el-descriptions-item label="AI 集成">Spring AI (Ollama)</el-descriptions-item>
<el-descriptions-item label="前端框架">Vue 3 (Composition API)</el-descriptions-item>
<el-descriptions-item label="UI 组件库">Element Plus</el-descriptions-item>
<el-descriptions-item label="构建工具">Maven & Vite</el-descriptions-item>
<el-descriptions-item label="数据库">MySQL 8.0 + Redis</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="10">
<el-card class="info-card glass" header="主要功能">
<el-timeline>
<el-timeline-item type="primary" :hollow="true" timestamp="用户权限">
RBAC 模型支持用户角色菜单按钮级权限控制
</el-timeline-item>
<el-timeline-item type="success" :hollow="true" timestamp="动态路由">
基于后端权限动态生成前端路由菜单
</el-timeline-item>
<el-timeline-item type="warning" :hollow="true" timestamp="AI 助手">
集成 LLM 大模型提供智能对话与辅助功能
</el-timeline-item>
<el-timeline-item type="info" :hollow="true" timestamp="系统监控">
实时监控服务器 CPU内存JVM 及磁盘状态
</el-timeline-item>
<el-timeline-item type="danger" :hollow="true" timestamp="安全机制">
JWT 认证验证码全局异常处理操作日志
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.about-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.about-header {
text-align: center;
padding: 48px 24px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(240,249,255,0.9) 100%);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.about-logo {
width: 80px;
height: auto;
margin-bottom: 16px;
}
.about-title {
font-size: 32px;
font-weight: 800;
margin: 0 0 12px;
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.about-subtitle {
font-size: 18px;
color: #666;
margin: 0;
}
.intro-text {
line-height: 1.8;
color: #555;
font-size: 15px;
}
.mt-4 {
margin-top: 16px;
}
:deep(.el-timeline-item__content) {
color: #555;
}
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,838 @@
<template>
<el-container class="chat-container">
<el-aside width="300px" class="chat-sidebar" :class="{ 'mobile-hidden': isMobile && !showSidebar }">
<div class="sidebar-header">
<el-input
v-model="searchQuery"
placeholder="搜索历史对话..."
prefix-icon="Search"
class="search-input"
@input="handleSearch"
/>
<el-button type="primary" class="new-chat-btn" @click="createNewChat" circle>
<el-icon><Plus /></el-icon>
</el-button>
</div>
<el-scrollbar class="conversation-list">
<div
v-for="conv in conversations"
:key="conv.id"
class="conversation-item"
:class="{ active: currentConversationId === conv.id, 'active-edit': editingConversationId === conv.id }"
@click="selectConversation(conv)"
>
<div class="conv-content">
<el-input
v-if="editingConversationId === conv.id"
v-model="editingTitle"
size="small"
@click.stop
@keydown.enter="saveEdit(conv)"
@blur="saveEdit(conv)"
class="edit-input"
/>
<span v-else class="conv-title">{{ conv.title }}</span>
<span class="conv-time">{{ formatTime(conv.updatedAt) }}</span>
</div>
<div class="conv-actions">
<el-button
v-if="editingConversationId !== conv.id"
type="primary"
link
icon="Edit"
class="action-btn"
@click.stop="startEdit(conv)"
/>
<el-button
v-if="editingConversationId !== conv.id"
type="danger"
link
icon="Delete"
class="action-btn"
@click.stop="deleteConversation(conv.id)"
/>
</div>
</div>
</el-scrollbar>
</el-aside>
<el-main class="chat-main" :class="{ 'mobile-full': isMobile && !showSidebar }">
<!-- New Chat / Empty State View -->
<div v-if="isNewChat" class="empty-chat-container">
<div class="welcome-text">{{ welcomeMessage }}</div>
<div class="chat-input-wrapper center-input">
<el-input
ref="chatInputRef"
v-model="inputMessage"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="有问题,尽管问"
class="custom-input"
resize="none"
@keydown.enter.prevent="sendMessage"
:disabled="isStreaming"
/>
<div class="input-actions">
<!-- <el-button circle text>
<el-icon><Microphone /></el-icon>
</el-button> -->
<el-button type="primary" circle class="send-btn-inner" @click="sendMessage" :loading="isStreaming" :disabled="!inputMessage.trim()">
<el-icon><Top /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- Active Chat View -->
<template v-else>
<div class="chat-header">
<el-button v-if="isMobile" icon="Menu" text @click="toggleSidebar" />
<span class="chat-title">{{ currentConversationTitle }}</span>
</div>
<el-scrollbar ref="messageScroll" class="message-area">
<div v-for="(msg, index) in messages" :key="index" class="message-wrapper" :class="msg.role">
<div class="message-avatar">
<el-avatar :icon="msg.role === 'user' ? 'User' : 'Service'" :src="msg.role === 'user' ? userAvatar : assistantAvatar" />
</div>
<div class="message-content">
<div class="bubble markdown-body" v-html="renderMessage(msg.content)"></div>
</div>
</div>
</el-scrollbar>
<div class="input-area">
<div class="chat-input-wrapper full-width">
<el-input
ref="chatInputRef"
v-model="inputMessage"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
class="custom-input"
resize="none"
@keydown.enter.prevent="sendMessage"
:disabled="isStreaming"
/>
<div class="input-actions">
<el-button type="primary" circle class="send-btn-inner" @click="sendMessage" :loading="isStreaming" :disabled="!inputMessage.trim()">
<el-icon><Top /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
</el-main>
</el-container>
</template>
<script setup>
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { chatApi } from '@/api/chat'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Delete, User, Service, Menu, Edit, Top, Microphone } from '@element-plus/icons-vue'
import { marked } from 'marked'
const authStore = useAuthStore()
const conversations = ref([])
const messages = ref([])
const currentConversationId = ref(null)
const currentConversationTitle = ref('新对话')
const inputMessage = ref('')
const searchQuery = ref('')
const isStreaming = ref(false)
const showSidebar = ref(true)
const messageScroll = ref(null)
const isMobile = ref(window.innerWidth < 768)
const editingConversationId = ref(null)
const editingTitle = ref('')
const editInputRef = ref(null)
const chatInputRef = ref(null)
const isNewChat = computed(() => messages.value.length === 0)
const welcomeMessages = [
"有什么可以帮忙的?",
"今天想聊点什么?",
"无论您遇到什么问题,我都在这里。",
"准备好开始新的探索了吗?",
"嗨!有什么我能为您做的吗?",
"期待与您的交流,请随时提问。"
]
const welcomeMessage = ref(welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)])
const userAvatar = computed(() => authStore.avatarPath ? (import.meta.env.VITE_API_BASE || 'http://localhost:8080') + authStore.avatarPath : '')
const assistantAvatar = '/favicon.png'
const renderMessage = (content) => {
if (!content) return ''
try {
// Pre-process content to fix common LLM formatting issues
// 1. Fix bold text followed immediately by a list item (e.g. **Title**1. Item)
let processed = content.replace(/(\*\*[^*]+\*\*)(\s*)(\d+\.)/g, "$1\n\n$3")
// 2. Fix headers not having a newline before them
processed = processed.replace(/([^\n])\s*(#{1,6}\s)/g, "$1\n\n$2")
// 3. Fix horizontal rules not having a newline before them
processed = processed.replace(/([^\n])\s*(---+\s*$)/gm, "$1\n\n$2")
return marked.parse(processed, { breaks: true })
} catch (e) {
console.error('Markdown parsing error:', e)
return content
}
}
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) showSidebar.value = true
})
const toggleSidebar = () => {
showSidebar.value = !showSidebar.value
}
const loadConversations = async () => {
try {
const res = await chatApi.getConversations()
conversations.value = res || []
} catch (error) {
console.error(error)
}
}
const selectConversation = async (conv) => {
currentConversationId.value = conv.id
currentConversationTitle.value = conv.title
if (isMobile.value) showSidebar.value = false
try {
const res = await chatApi.getMessages(conv.id)
messages.value = res || []
scrollToBottom()
} catch (error) {
console.error(error)
}
}
const createNewChat = () => {
currentConversationId.value = null
currentConversationTitle.value = '新对话'
messages.value = []
if (isMobile.value) showSidebar.value = false
welcomeMessage.value = welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)]
nextTick(() => {
chatInputRef.value?.focus()
})
}
const deleteConversation = (id) => {
ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
type: 'warning'
}).then(async () => {
try {
await chatApi.deleteConversation(id)
ElMessage.success('删除成功')
if (currentConversationId.value === id) {
currentConversationId.value = null
messages.value = []
currentConversationTitle.value = ''
}
loadConversations()
} catch (error) {
console.error(error)
}
})
}
const handleSearch = async () => {
if (!searchQuery.value) {
loadConversations()
return
}
try {
const res = await chatApi.searchConversations(searchQuery.value)
conversations.value = res || []
} catch (error) {
console.error(error)
}
}
const startEdit = (conv) => {
editingConversationId.value = conv.id
editingTitle.value = conv.title
nextTick(() => {
// Try to focus the input
const input = document.querySelector('.conversation-item.active-edit input')
if (input) input.focus()
})
}
const saveEdit = async (conv) => {
if (!editingConversationId.value) return
const newTitle = editingTitle.value
// If title is empty or unchanged, just cancel edit
if (!newTitle.trim() || newTitle === conv.title) {
if (editingConversationId.value === conv.id) {
editingConversationId.value = null
editingTitle.value = ''
}
return
}
try {
await chatApi.updateConversation(conv.id, newTitle)
conv.title = newTitle
if (currentConversationId.value === conv.id) {
currentConversationTitle.value = newTitle
}
ElMessage.success('标题已更新')
} catch (error) {
console.error(error)
ElMessage.error('更新失败')
} finally {
if (editingConversationId.value === conv.id) {
editingConversationId.value = null
editingTitle.value = ''
}
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim() || isStreaming.value) return
const content = inputMessage.value
inputMessage.value = ''
if (!currentConversationId.value) {
try {
const res = await chatApi.createConversation(content.substring(0, 20))
await loadConversations()
currentConversationId.value = res.id
currentConversationTitle.value = res.title
} catch(e) {
ElMessage.error('创建对话失败')
return
}
}
messages.value.push({ role: 'user', content })
scrollToBottom()
try {
await chatApi.saveMessage(currentConversationId.value, 'user', content)
} catch (e) {
console.error('Failed to save user message')
}
isStreaming.value = true
const assistantMsg = reactive({ role: 'assistant', content: '' })
messages.value.push(assistantMsg)
try {
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
const response = await fetch(`${baseUrl}/api/ai/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify({
message: content,
temperature: 0.7,
conversationId: currentConversationId.value
})
})
if (!response.ok) throw new Error(response.statusText)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.slice(5)
assistantMsg.content += data
scrollToBottom()
}
}
}
await chatApi.saveMessage(currentConversationId.value, 'assistant', assistantMsg.content)
} catch (e) {
if (e.name === 'AbortError') {
assistantMsg.content += " [已停止]"
} else {
console.error(e)
// Only show error in chat bubble if we haven't received any content yet
if (!assistantMsg.content) {
ElMessage.error('AI 响应失败: ' + e.message)
assistantMsg.content += " [连接失败]"
} else {
// If we have content, it means stream worked but maybe save failed
ElMessage.warning('消息已接收,但保存失败: ' + e.message)
console.error('Failed to save assistant message or stream error', e)
}
}
} finally {
isStreaming.value = false
nextTick(() => {
chatInputRef.value?.focus()
})
}
}
const scrollToBottom = () => {
nextTick(() => {
if (messageScroll.value) {
const wrap = messageScroll.value.wrapRef
wrap.scrollTop = wrap.scrollHeight
}
})
}
const formatTime = (timeStr) => {
if (!timeStr) return ''
return new Date(timeStr).toLocaleString()
}
onMounted(() => {
loadConversations()
})
</script>
<style scoped>
.chat-container {
height: calc(85vh - 60px);
background-color: #fff;
overflow: hidden;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.chat-sidebar {
background-color: #f9fafc;
border-right: 1px solid #eaeaea;
display: flex;
flex-direction: column;
}
.sidebar-header {
height: auto;
padding: 20px 15px;
display: flex;
align-items: center;
gap: 12px;
background-color: #f9fafc;
}
.new-chat-btn {
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.3);
}
.search-input :deep(.el-input__wrapper) {
border-radius: 20px;
box-shadow: 0 0 0 1px #dcdfe6 inset;
}
.conversation-list {
flex: 1;
padding: 10px;
}
.conversation-item {
padding: 12px 15px;
cursor: pointer;
border-radius: 10px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.25s ease;
border: 1px solid transparent;
}
.conversation-item:hover {
background-color: #d6d2d296;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.conversation-item.active {
background-color: #fff;
border-color: var(--ios-primary);
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
}
.conversation-item:hover .conv-actions {
display: flex;
}
.conv-content {
display: flex;
flex-direction: column;
overflow: hidden;
max-width: 190px;
flex: 1;
}
.conv-title {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #303133;
}
.conv-actions {
display: none;
align-items: center;
gap: 8px;
}
.action-btn {
padding: 0 !important;
margin-left: 0 !important;
font-size: 16px;
color: #909399;
}
.action-btn:hover {
color: var(--ios-primary);
}
.edit-input {
margin-bottom: 2px;
}
.conv-time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.chat-main {
display: flex;
flex-direction: column;
padding: 0;
position: relative;
overflow: hidden;
background-color: #fff;
}
.chat-header {
height: 60px;
/* border-bottom: 1px solid #eaeaea; */
display: flex;
align-items: center;
padding: 0 24px;
background: #fff;
font-weight: 600;
font-size: 16px;
color: #303133;
}
.message-area {
flex: 1;
padding: 0px 30px;
/* margin-top: -20px; */
background-color: #fff;
}
.message-wrapper {
display: flex;
margin-bottom: 24px;
align-items: flex-start;
}
.message-wrapper.user {
flex-direction: row-reverse;
}
.message-avatar {
margin: 0 12px;
flex-shrink: 0;
}
.message-content {
max-width: 75%;
}
.bubble {
padding: 12px 18px;
border-radius: 12px;
line-height: 1.6;
font-size: 15px;
word-wrap: break-word;
}
/* Markdown Styles */
.bubble :deep(p) {
margin: 0 0 10px 0;
}
.bubble :deep(p:last-child) {
margin-bottom: 0;
}
.bubble :deep(h1), .bubble :deep(h2), .bubble :deep(h3), .bubble :deep(h4), .bubble :deep(h5), .bubble :deep(h6) {
margin: 10px 0 8px 0;
font-weight: 600;
line-height: 1.4;
}
.bubble :deep(h1) { font-size: 1.5em; border-bottom: 1px solid #eaeaea; padding-bottom: 5px; }
.bubble :deep(h2) { font-size: 1.4em; border-bottom: 1px solid #eaeaea; padding-bottom: 4px; }
.bubble :deep(h3) { font-size: 1.3em; }
.bubble :deep(h4) { font-size: 1.2em; }
.bubble :deep(h5) { font-size: 1.1em; }
.bubble :deep(h6) { font-size: 1.0em; color: #606266; }
.bubble :deep(ul) {
margin: 5px 0 10px 20px;
padding-left: 20px;
list-style-type: disc;
}
.bubble :deep(ol) {
margin: 5px 0 10px 20px;
padding-left: 20px;
list-style-type: decimal;
}
.bubble :deep(li) {
margin-bottom: 4px;
}
.bubble :deep(pre) {
background-color: rgba(0, 0, 0, 0.05);
padding: 10px;
border-radius: 6px;
overflow-x: auto;
font-family: monospace;
margin: 10px 0;
}
.bubble :deep(code) {
background-color: rgba(0, 0, 0, 0.05);
padding: 2px 4px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
.bubble :deep(pre code) {
background-color: transparent;
padding: 0;
}
.bubble :deep(blockquote) {
border-left: 3px solid rgba(0, 0, 0, 0.2);
margin: 10px 0;
padding-left: 10px;
color: inherit;
opacity: 0.8;
}
.bubble :deep(hr) {
height: 1px;
background-color: #e0e0e0;
border: none;
margin: 15px 0;
}
.bubble :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
font-size: 0.9em;
}
.bubble :deep(th), .bubble :deep(td) {
padding: 8px 12px;
border: 1px solid #ddd;
text-align: left;
}
.bubble :deep(th) {
background-color: rgba(0, 0, 0, 0.05);
font-weight: 600;
}
/* Adjust colors for user bubble (dark background) */
.message-wrapper.user .bubble :deep(pre),
.message-wrapper.user .bubble :deep(code) {
background-color: rgba(255, 255, 255, 0.2);
}
.message-wrapper.user .bubble :deep(blockquote) {
border-left-color: rgba(255, 255, 255, 0.4);
}
.message-wrapper.user .bubble :deep(a) {
color: #fff;
text-decoration: underline;
}
/* Adjust colors for assistant bubble (light background) */
.message-wrapper.assistant .bubble :deep(a) {
color: var(--el-color-primary);
}
.message-wrapper.user .bubble {
background: #EBF5FF;
color: #000;
border-radius: 12px 12px 0 12px;
}
.message-wrapper.assistant .bubble {
background-color: #f4f6f8;
color: #2c3e50;
border-radius: 12px 12px 12px 0;
border: 1px solid #eaecf0;
box-shadow: none;
}
.input-area {
padding: 20px 30px;
background: #fff;
display: flex;
justify-content: center;
}
.empty-chat-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-bottom: 100px; /* Offset a bit */
}
.welcome-text {
font-size: 28px;
font-weight: 600;
color: #303133;
margin-bottom: 40px;
}
.chat-input-wrapper {
background-color: #f4f4f4;
border-radius: 28px;
padding: 8px 16px;
display: flex;
align-items: flex-end; /* Align bottom for textarea growth */
gap: 10px;
transition: all 0.3s ease;
border: 1px solid transparent;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.chat-input-wrapper:focus-within {
background-color: #fff;
border-color: #dcdfe6;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
}
.center-input {
width: 100%;
max-width: 700px;
padding: 12px 20px;
}
.full-width {
width: 100%;
max-width: 800px; /* limit width on wide screens */
}
.input-prefix {
display: flex;
align-items: center;
justify-content: center;
height: 40px; /* Match button height */
color: #909399;
cursor: pointer;
}
.custom-input {
flex: 1;
}
.custom-input :deep(.el-textarea__inner) {
box-shadow: none !important;
background: transparent !important;
border: none !important;
padding: 10px 0;
resize: none;
min-height: 24px;
line-height: 1.5;
font-size: 16px;
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 4px; /* Align with text */
}
.send-btn-inner {
width: 36px;
height: 36px;
min-height: 36px;
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
color: #fff;
transition: all 0.2s;
}
.send-btn-inner:disabled {
background-color: #e4e7ed;
border-color: #e4e7ed;
color: #a8abb2;
}
/* Remove old styles */
/* .input-area :deep(.el-textarea__inner) ... */
/* .send-btn ... */
@media (max-width: 768px) {
.chat-sidebar {
position: absolute;
z-index: 100;
height: 100%;
width: 80%;
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
}
.mobile-hidden {
display: none;
}
.chat-main {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="home-container">
<div class="hero-section glass">
<h1 class="hero-title">Hertz Admin</h1>
<p class="hero-subtitle">基于 Spring Boot 3 Vue 3 的现代化权限管理系统</p>
<div class="hero-actions">
<el-button type="primary" size="large" @click="router.push('/login')">立即开始</el-button>
<el-button size="large" @click="router.push('/portal/about')">了解更多</el-button>
</div>
</div>
<div class="features-grid">
<el-card class="feature-card glass">
<h3>RBAC 权限</h3>
<p>基于角色的访问控制细粒度的权限管理</p>
</el-card>
<el-card class="feature-card glass">
<h3>动态菜单</h3>
<p>根据用户角色动态生成侧边栏菜单</p>
</el-card>
<el-card class="feature-card glass">
<h3>现代化 UI</h3>
<p>采用 Element Plus iOS 风格设计</p>
</el-card>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<style scoped>
.home-container {
display: flex;
flex-direction: column;
gap: 40px;
}
.hero-section {
text-align: center;
padding: 80px 40px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(240,249,255,0.8) 100%);
}
.hero-title {
font-size: 48px;
font-weight: 800;
margin: 0 0 16px;
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -1px;
}
.hero-subtitle {
font-size: 20px;
color: #666;
margin: 0 0 32px;
}
.hero-actions {
display: flex;
justify-content: center;
gap: 16px;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.feature-card h3 {
margin: 0 0 8px;
color: var(--ios-primary);
}
.feature-card p {
margin: 0;
color: #666;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,287 @@
<template>
<div class="monitor-container">
<div class="monitor-header">
<h2>系统监控</h2>
<el-button type="primary" :icon="Icons.Refresh" @click="fetchData" :loading="loading">刷新</el-button>
</div>
<div class="monitor-content">
<!-- CPU & Memory Row -->
<div class="monitor-row">
<el-card class="monitor-card glass">
<template #header>
<div class="card-header">
<el-icon><component :is="Icons.Cpu" /></el-icon>
<span>CPU</span>
</div>
</template>
<div class="metric-content" v-if="serverInfo">
<el-progress type="dashboard" :percentage="Number((serverInfo.cpu.used).toFixed(2))" :color="getColors" />
<div class="metric-details">
<div class="detail-item">
<span>核心数:</span>
<span>{{ serverInfo.cpu.cpuNum }}</span>
</div>
<div class="detail-item">
<span>系统使用:</span>
<span>{{ serverInfo.cpu.sys }}%</span>
</div>
<div class="detail-item">
<span>用户使用:</span>
<span>{{ serverInfo.cpu.used }}%</span>
</div>
<div class="detail-item">
<span>空闲:</span>
<span>{{ serverInfo.cpu.free }}%</span>
</div>
</div>
</div>
<el-skeleton :rows="5" animated v-else />
</el-card>
<el-card class="monitor-card glass">
<template #header>
<div class="card-header">
<el-icon><component :is="Icons.Memo" /></el-icon>
<span>内存</span>
</div>
</template>
<div class="metric-content" v-if="serverInfo">
<el-progress type="dashboard" :percentage="Number((serverInfo.mem.usage).toFixed(2))" :color="getColors" />
<div class="metric-details">
<div class="detail-item">
<span>总内存:</span>
<span>{{ serverInfo.mem.total }}GB</span>
</div>
<div class="detail-item">
<span>已用:</span>
<span>{{ serverInfo.mem.used }}GB</span>
</div>
<div class="detail-item">
<span>剩余:</span>
<span>{{ serverInfo.mem.free }}GB</span>
</div>
<div class="detail-item">
<span>使用率:</span>
<span>{{ serverInfo.mem.usage }}%</span>
</div>
</div>
</div>
<el-skeleton :rows="5" animated v-else />
</el-card>
</div>
<!-- JVM Row -->
<!-- <el-card class="monitor-card glass full-width">
<template #header>
<div class="card-header">
<el-icon><component :is="Icons.Platform" /></el-icon>
<span>JVM 信息</span>
</div>
</template>
<el-descriptions border :column="4">
<el-descriptions-item label="启动时间">{{ serverInfo.jvm.startTime }}</el-descriptions-item>
<el-descriptions-item label="安装路径">{{ serverInfo.jvm.home }}</el-descriptions-item>
<el-descriptions-item label="项目路径">{{ serverInfo.sys.userDir }}</el-descriptions-item>
<el-descriptions-item label="GC次数">{{ serverInfo.jvm.gcCount }} </el-descriptions-item>
<el-descriptions-item label="GC耗时">{{ serverInfo.jvm.gcTime }} ms</el-descriptions-item>
<el-descriptions-item label="运行参数" :span="4">{{ serverInfo.jvm.inputArgs }}</el-descriptions-item>
</el-descriptions>
<div class="jvm-metrics mt-4">
<div class="metric-bar">
<span>JVM内存使用 ({{ serverInfo.jvm.usage }}%)</span>
<el-progress :text-inside="true" :stroke-width="20" :percentage="Number((serverInfo.jvm.usage))" :color="getColors" />
<div class="metric-sub-text">
Total: {{ serverInfo.jvm.total }}MB | Used: {{ serverInfo.jvm.used }}MB | Free: {{ serverInfo.jvm.free }}MB
</div>
</div>
</div>
</el-card> -->
<!-- Server Info Row -->
<el-card class="monitor-card glass full-width">
<template #header>
<div class="card-header">
<el-icon><component :is="Icons.Monitor" /></el-icon>
<span>服务器信息</span>
</div>
</template>
<el-descriptions border :column="2" v-if="serverInfo">
<el-descriptions-item label="服务器名称">{{ serverInfo.sys.computerName }}</el-descriptions-item>
<el-descriptions-item label="操作系统">{{ serverInfo.sys.osName }}</el-descriptions-item>
<el-descriptions-item label="服务器IP">{{ serverInfo.sys.computerIp }}</el-descriptions-item>
<el-descriptions-item label="系统架构">{{ serverInfo.sys.osArch }}</el-descriptions-item>
</el-descriptions>
<el-skeleton :rows="3" animated v-else />
</el-card>
<!-- Disk Info Row -->
<el-card class="monitor-card glass full-width">
<template #header>
<div class="card-header">
<el-icon><component :is="Icons.Coin" /></el-icon>
<span>磁盘状态</span>
</div>
</template>
<el-table :data="serverInfo ? serverInfo.disks : []" style="width: 100%" v-if="serverInfo">
<el-table-column prop="dirName" label="盘符路径" width="180" />
<el-table-column prop="sysTypeName" label="文件系统" width="120" />
<el-table-column prop="typeName" label="盘符类型" width="120" />
<el-table-column prop="total" label="总大小" width="120" />
<el-table-column prop="free" label="可用大小" width="120" />
<el-table-column prop="used" label="已用大小" width="120" />
<el-table-column label="使用率" width="180">
<template #default="scope">
<el-progress :percentage="Number(scope.row.usage)" :color="getColors" />
</template>
</el-table-column>
</el-table>
<el-skeleton :rows="5" animated v-else />
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getServerInfo } from '../../api/monitor'
import { ElMessage } from 'element-plus'
import * as Icons from '@element-plus/icons-vue'
const loading = ref(false)
const serverInfo = ref(null)
const getColors = [
{ color: '#5cb87a', percentage: 60 },
{ color: '#e6a23c', percentage: 80 },
{ color: '#f56c6c', percentage: 100 },
]
async function fetchData() {
loading.value = true
try {
const res = await getServerInfo()
if (res.code === 0) {
serverInfo.value = res.data
} else {
ElMessage.error(res.message || '获取系统信息失败')
}
} catch (error) {
console.error('Fetch error:', error)
if (error.response) {
const { status } = error.response
const statusMap = {
400: '请求参数错误',
401: '登录状态已失效,请重新登录',
403: '您没有权限访问该资源',
404: '请求的资源不存在',
408: '请求超时,请稍后重试',
500: '服务器内部错误,请联系管理员',
502: '网关错误',
503: '服务不可用',
504: '网关超时'
}
} else {
ElMessage.error('网络连接失败,请检查您的网络设置')
}
} finally {
loading.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.monitor-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.monitor-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.monitor-header h2 {
margin: 0;
font-weight: 600;
color: var(--el-text-color-primary);
}
.monitor-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.monitor-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
}
.monitor-card {
border-radius: 12px;
}
.full-width {
width: 100%;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.metric-content {
display: flex;
align-items: center;
gap: 32px;
justify-content: center;
}
.metric-details {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 150px;
}
.detail-item {
display: flex;
justify-content: space-between;
font-size: 14px;
color: var(--el-text-color-regular);
}
.detail-item span:last-child {
font-weight: 500;
color: var(--el-text-color-primary);
}
.mt-4 {
margin-top: 16px;
}
.metric-bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.metric-sub-text {
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: right;
}
</style>