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

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>