Files
tiantian-system/app/layouts/console.vue
赵忠林 d8c559b5b1 feat(ui): 新增“天天系统”ERP管理平台主页布局与控制台页面优化
- 为控制台首页添加页面标题动态设置
- 为应用中心页面添加页面标题动态设置
- 修改控制台布局,实现动态浏览器标签页标题更新
- 新增“天天系统”ERP管理平台主页,包含侧边栏导航、顶部栏及数据概览模块
- 实现主页搜索框、通知、语言和用户信息区域交互
- 添加欢迎区、快捷入口、最近使用应用列表及应用详情抽屉功能
- 支持小程序扫码弹窗展示和应用类型图标及颜色区分
- 优化页面样式,支持响应式布局及交互效果
- 更新Nuxt国际化重定向路径片段标识符以兼容新版本
2026-04-09 00:58:15 +08:00

521 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 '控制台'
})
// 动态设置浏览器标签页标题
useHead(() => ({
title: currentPageTitle.value
}))
// 选中 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>