Files
tiantian-system/app/layouts/console.vue
2026-04-08 17:10:58 +08:00

516 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-layout class="layout-shell">
<!-- 左侧固定侧边栏 -->
<a-layout-sider
class="sider"
:width="220"
:collapsed-width="64"
breakpoint="lg"
theme="dark"
:trigger="null"
collapsible
v-model:collapsed="collapsed"
>
<!-- Logo 区域 -->
<div class="sider-logo" @click="navigateTo('/console')">
<img src="/logo.png" alt="logo" class="logo-img" />
<transition name="logo-text">
<span v-if="!collapsed" class="logo-name">控制台</span>
</transition>
</div>
<!-- 菜单 -->
<a-menu
mode="inline"
theme="dark"
:selected-keys="selectedKeys"
:open-keys="collapsed ? [] : openKeys"
:inline-collapsed="collapsed"
@open-change="onOpenChange"
@click="onMenuClick"
>
<template v-for="entry in menu" :key="isGroup(entry) ? entry.key : entry.to">
<!-- 分组有子菜单 -->
<a-sub-menu v-if="isGroup(entry)" :key="entry.key">
<template v-if="entry.icon" #icon>
<component :is="entry.icon" />
</template>
<template #title>{{ entry.label }}</template>
<a-menu-item v-for="item in entry.children" :key="item.to">
<template v-if="item.icon" #icon>
<component :is="item.icon" />
</template>
{{ item.label }}
</a-menu-item>
</a-sub-menu>
<!-- 单条链接 -->
<a-menu-item v-else :key="entry.to">
<template v-if="entry.icon" #icon>
<component :is="entry.icon" />
</template>
{{ entry.label }}
</a-menu-item>
</template>
</a-menu>
<!-- 折叠按钮 -->
<div
class="sider-collapse-trigger"
role="button"
tabindex="0"
:aria-label="collapsed ? '展开菜单' : '收起菜单'"
@click="toggleCollapsed"
@keydown.enter.prevent="toggleCollapsed"
@keydown.space.prevent="toggleCollapsed"
>
<RightOutlined v-if="collapsed" style="font-size: 10px" />
<LeftOutlined v-else style="font-size: 10px" />
</div>
</a-layout-sider>
<!-- 右侧主区域 -->
<a-layout class="main-layout" :class="{ 'main-layout--collapsed': collapsed }">
<!-- 顶部 Header -->
<a-layout-header class="main-header">
<div class="header-inner">
<!-- 左侧面包屑或页面标题可扩展 -->
<div class="header-left">
<span class="page-title">{{ currentPageTitle }}</span>
</div>
<!-- 右侧用户区 -->
<div class="header-right">
<!-- 消息通知铃铛 -->
<NotificationBell />
<a-dropdown placement="bottomRight" :trigger="['click']">
<div class="user-trigger">
<a-avatar :size="28" :src="user?.avatar || user?.avatarUrl">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="user-name">{{ userDisplayName }}</span>
<DownOutlined style="font-size: 10px; opacity: 0.6; margin-left: 2px" />
</div>
<template #overlay>
<a-menu @click="onUserMenuClick" class="user-dropdown-menu">
<a-menu-item key="account-info">
<UserOutlined style="margin-right: 8px" />
账户信息
</a-menu-item>
<a-menu-item key="orders">
<ShoppingOutlined style="margin-right: 8px" />
我的订单
</a-menu-item>
<a-menu-item key="account-kyc">
<IdcardOutlined style="margin-right: 8px" />
实名认证
</a-menu-item>
<template v-if="isDeveloper">
<a-menu-divider />
<a-menu-item key="developer">
<CodeOutlined style="margin-right: 8px" />
开发者中心
</a-menu-item>
</template>
<template v-if="isAdmin">
<a-menu-divider />
<a-menu-item key="admin">
<SettingOutlined style="margin-right: 8px" />
平台管理
</a-menu-item>
</template>
<a-menu-divider />
<a-menu-item key="logout" class="logout-item">
<LogoutOutlined style="margin-right: 8px" />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</a-layout-header>
<!-- 内容区 -->
<a-layout-content class="main-content">
<a-spin v-if="!ready" size="large" tip="加载中..." class="spin" />
<template v-else>
<slot />
</template>
</a-layout-content>
</a-layout>
<!-- 邀请通知组件 -->
<ClientOnly>
<InviteNotification ref="inviteNotificationRef" />
</ClientOnly>
</a-layout>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import {
CodeOutlined,
DownOutlined,
IdcardOutlined,
LeftOutlined,
LogoutOutlined,
RightOutlined,
SettingOutlined,
ShoppingOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import { consoleNav, type ConsoleNavEntry, type ConsoleNavGroup } from '@/config/console-nav'
import { getUserInfo } from '@/api/layout'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import type { User } from '@/api/system/user/model'
const route = useRoute()
const collapsed = ref(false)
const menu = computed(() => consoleNav)
const user = ref<User | null>(null)
const userDisplayName = computed(() => {
const u = user.value
return u?.nickname?.trim() || u?.username?.trim() || u?.phone?.trim() || u?.mobile?.trim() || '当前用户'
})
const isAdmin = computed(() => !!(user.value as any)?.isAdmin)
const isDeveloper = computed(() => (user.value as any)?.type === 2)
function isGroup(entry: ConsoleNavEntry): entry is ConsoleNavGroup {
return 'children' in entry
}
// 当前页面标题
const currentPageTitle = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return hit.label
} else {
if (path === entry.to || path.startsWith(entry.to + '/')) return entry.label
}
}
return '控制台'
})
// 选中 key
const selectedKeys = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return [hit.to]
} else {
if (path === entry.to) return [entry.to]
}
}
return []
})
// 展开 key自动展开当前路由所在的分组
const openKeys = ref<string[]>([])
function syncOpenKeys() {
const path = route.path
const hit = menu.value.find(
(entry) => isGroup(entry) && entry.children.some((c) => path === c.to || path.startsWith(c.to + '/'))
)
openKeys.value = hit ? [hit.key] : []
}
function onOpenChange(keys: string[]) {
openKeys.value = Array.isArray(keys) ? keys.slice(-1) : []
}
function onMenuClick(info: { key: string }) {
navigateTo(String(info.key))
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch { /* ignore */ }
clearAuthz()
navigateTo('/')
}
function onUserMenuClick(info: { key: string }) {
const key = String(info.key)
if (key === 'home') navigateTo('/')
if (key === 'account-info') navigateTo('/console/account')
if (key === 'orders') navigateTo('/console/orders')
if (key === 'account-members') navigateTo('/console/account/members')
if (key === 'account-security') navigateTo('/console/account/security')
if (key === 'account-kyc') navigateTo('/console/account/kyc')
if (key === 'developer') navigateTo('/developer')
if (key === 'admin') navigateTo('/admin')
if (key === 'logout') logout()
}
watch(() => route.path, syncOpenKeys, { immediate: true })
const ready = ref(false)
const inviteNotificationRef = ref()
onMounted(async () => {
const token = getToken()
if (!token) {
clearAuthz()
message.error('请先登录')
await navigateTo('/login')
ready.value = true
return
}
try {
const me = await getUserInfo()
user.value = me
setAuthzFromUser(me)
} catch {
user.value = null
clearAuthz()
}
syncOpenKeys()
ready.value = true
})
</script>
<style scoped>
/* ===== 整体 shell ===== */
.layout-shell {
min-height: 100vh;
background: #f5f7fa;
}
/* ===== 左侧边栏 ===== */
.sider {
position: fixed !important;
top: 0;
left: 0;
height: 100vh;
z-index: 100;
overflow: hidden;
background: #111827 !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
/* 覆盖 Ant Design sider 背景 */
}
:deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
:deep(.ant-menu-dark) {
background: transparent;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
:deep(.ant-menu-dark .ant-menu-item-selected) {
background-color: rgba(59, 130, 246, 0.25) !important;
}
:deep(.ant-menu-dark .ant-menu-item:hover),
:deep(.ant-menu-dark .ant-menu-submenu-title:hover) {
background-color: rgba(255, 255, 255, 0.06) !important;
}
/* Logo 区域 */
.sider-logo {
height: 56px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px;
cursor: pointer;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
white-space: nowrap;
}
.sider-logo:hover .logo-img,
.sider-logo:hover .logo-name {
opacity: 0.8;
}
.logo-img {
height: 16px;
width: auto;
flex-shrink: 0;
display: block;
}
.logo-name {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
background: linear-gradient(135deg, #ffffff 0%, #a5c8ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-text-enter-active,
.logo-text-leave-active {
transition: opacity 0.2s, width 0.2s;
}
.logo-text-enter-from,
.logo-text-leave-to {
opacity: 0;
width: 0;
}
/* 折叠按钮 */
.sider-collapse-trigger {
position: absolute;
right: -14px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #1f2937;
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 0;
border-radius: 0 9999px 9999px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
z-index: 101;
}
.sider-collapse-trigger:hover {
color: #fff;
background: #374151;
}
/* ===== 右侧主区域 ===== */
.main-layout {
margin-left: 220px;
transition: margin-left 0.2s ease;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
/* 当 sider 折叠时,右侧内容区跟着收缩 */
.main-layout--collapsed {
margin-left: 64px;
}
/* 顶部 Header */
.main-header {
position: sticky;
top: 0;
z-index: 99;
height: 56px;
line-height: 56px;
padding: 0;
background: #fff !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.header-inner {
height: 100%;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-trigger {
height: 36px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: background 0.2s;
}
.user-trigger:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #374151;
}
/* 头像下拉菜单样式 */
.user-dropdown-menu {
min-width: 180px;
}
.user-dropdown-menu .ant-menu-item,
.user-dropdown-menu .ant-menu-submenu-title {
padding: 8px 16px;
}
.logout-item {
color: #ff4d4f;
}
.logout-item:hover {
background-color: #fff1f0;
}
/* 内容区 */
.main-content {
flex: 1;
padding: 20px 24px;
min-height: calc(100vh - 56px);
}
.spin {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
</style>