<script setup lang="ts">
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { useRoute, useRouter } from 'vue-router'
|
import { ElMessageBox } from 'element-plus'
|
import {
|
Calendar,
|
Bell,
|
HomeFilled,
|
OfficeBuilding,
|
Operation,
|
Setting,
|
SwitchButton,
|
Tickets,
|
User,
|
Wallet,
|
} from '@element-plus/icons-vue'
|
import { logoutAdmin } from '../api/auth'
|
import { getAdminPendingNotices } from '../api/admin'
|
import type { AdminPendingNotice } from '../api/admin'
|
import { useAppStore } from '../stores/app'
|
|
interface MenuItem {
|
path?: string
|
index?: string
|
label: string
|
children?: MenuItem[]
|
}
|
|
interface MenuGroup {
|
index: string
|
label: string
|
icon: unknown
|
items: MenuItem[]
|
}
|
|
const route = useRoute()
|
const router = useRouter()
|
const appStore = useAppStore()
|
const avatarText = computed(() => (appStore.user.name || 'A').trim().slice(0, 1).toUpperCase())
|
|
const notices = ref<AdminPendingNotice[]>([])
|
const adminMenuRef = ref<{ open: (index: string) => void; close: (index: string) => void }>()
|
|
function formatNoticeTime(value: string) {
|
return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '-'
|
}
|
|
async function loadNotices() {
|
try {
|
notices.value = await getAdminPendingNotices()
|
} catch {
|
notices.value = []
|
}
|
}
|
|
function openNotice(notice: AdminPendingNotice) {
|
router.push(notice.path)
|
}
|
|
const cardMenus: MenuItem[] = [
|
{ path: '/talents', label: '持卡人管理' },
|
{ path: '/family-card-applications', label: '家属卡申请' },
|
{ path: '/upgrade-applications', label: '升级申请' },
|
{ path: '/family-cards', label: '家属卡管理' },
|
{ path: '/child-cards', label: '子女管理' },
|
]
|
|
const merchantMenus: MenuItem[] = [
|
{ path: '/merchants', label: '商家查询' },
|
{ path: '/products', label: '团单审核' },
|
]
|
|
const financeMenus: MenuItem[] = [
|
{ path: '/merchant-settlement', label: '商户结算审核' },
|
]
|
|
const orderMenus: MenuItem[] = [
|
{ path: '/orders', label: '订单列表' },
|
{ path: '/refund-records', label: '退款记录' },
|
{ path: '/hotel-free-verifications', label: '酒店免费核销记录' },
|
]
|
|
const reservationMenus: MenuItem[] = [
|
{ path: '/appointment-management', label: '预约服务管理' },
|
{ path: '/medical-reservations', label: '体检预约管理' },
|
{ path: '/medical-applications', label: '就医申请管理' },
|
{ path: '/parking-plates', label: '停车车牌管理' },
|
{ path: '/car-wash-benefits', label: '洗车权益管理' },
|
{ path: '/fuel-services', label: '加油服务管理' },
|
{ path: '/spouse-employment', label: '配偶就业管理' },
|
{ path: '/hotel-reservations', label: '酒店预约管理' },
|
]
|
|
const operationMenus: MenuItem[] = [
|
{ path: '/equity-images', label: '权益图片管理' },
|
{ path: '/talent-news-management', label: '人才资讯管理' },
|
{ path: '/talent-policy-management', label: '人才政策管理' },
|
{ path: '/movie-tickets', label: '电影票管理' },
|
]
|
|
const systemMenus: MenuItem[] = [
|
{ path: '/system-settings', label: '平台设置' },
|
{ path: '/protocol-settings', label: '协议设置' },
|
{ path: '/roles', label: '角色管理' },
|
{ path: '/staff', label: '员工管理' },
|
]
|
|
const menuGroups: MenuGroup[] = [
|
{ index: 'card-management', label: '持卡管理', icon: User, items: cardMenus },
|
{ index: 'merchant-management', label: '商家管理', icon: OfficeBuilding, items: merchantMenus },
|
{ index: 'order-management', label: '订单管理', icon: Tickets, items: orderMenus },
|
{ index: 'reservation-management', label: '预约管理', icon: Calendar, items: reservationMenus },
|
{ index: 'finance-management', label: '财务管理', icon: Wallet, items: financeMenus },
|
{ index: 'operation-management', label: '运营管理', icon: Operation, items: operationMenus },
|
{ index: 'system-management', label: '系统管理', icon: Setting, items: systemMenus },
|
]
|
|
// Keep the navigation in sync with the permissions returned at login. Newer
|
// routes without a dedicated permission code intentionally remain visible.
|
const routePermissions: Record<string, string> = {
|
'/talents': 'admin.talents.view', '/family-card-applications': 'admin.family-card-applications.view', '/upgrade-applications': 'admin.upgrade-applications.view', '/family-cards': 'admin.family-cards.view', '/child-cards': 'admin.child-cards.view',
|
'/merchants': 'admin.merchants.view', '/products': 'admin.products.view', '/orders': 'admin.orders.view', '/refund-records': 'admin.refunds.view', '/hotel-free-verifications': 'admin.hotel-free-verifications.view',
|
'/appointment-management': 'admin.appointment-management.view', '/medical-reservations': 'admin.medical-reservations.view', '/medical-applications': 'admin.medical-applications.view', '/parking-plates': 'admin.parking-plates.view', '/car-wash-benefits': 'admin.car-wash-benefits.view', '/fuel-services': 'admin.fuel-services.view', '/spouse-employment': 'admin.spouse-employment.view', '/hotel-reservations': 'admin.hotel-reservations.view',
|
'/merchant-settlement': 'admin.merchant-settlement.view', '/equity-images': 'admin.equity-images.view', '/talent-news-management': 'admin.news.view', '/talent-policy-management': 'admin.policies.view', '/movie-tickets': 'admin.movie-tickets.view',
|
'/system-settings': 'admin.settings.view', '/protocol-settings': 'admin.protocol-settings.view', '/roles': 'admin.roles.view', '/staff': 'admin.staff.view',
|
}
|
const canAccess = (path?: string) => {
|
if (!path || appStore.user.role === '平台管理') return true
|
const required = routePermissions[path]
|
return !required || Boolean(appStore.user.permissionCodes?.includes('*') || appStore.user.permissionCodes?.includes(required))
|
}
|
const visibleMenuGroups = computed(() => menuGroups
|
.map((group) => ({ ...group, items: group.items.filter((item) => canAccess(item.path)) }))
|
.filter((group) => group.items.length > 0))
|
|
const defaultOpeneds = computed(() => {
|
const currentGroup = menuGroups.find((item) => findMenuLabel(item.items, activeMenu.value))
|
return currentGroup ? [currentGroup.index] : []
|
})
|
|
const pageTitle = computed(() => {
|
if (route.name === 'merchant-create' && route.query.mode === 'edit') {
|
return '编辑商家'
|
}
|
return String(route.meta.title || '管理后台')
|
})
|
|
const activeMenu = computed(() => {
|
if (route.path === '/talents/new' || /^\/talents\/[^/]+$/.test(route.path)) {
|
return '/talents'
|
}
|
if (route.path === '/merchants/new') {
|
return '/merchants'
|
}
|
if (/^\/products\/[^/]+$/.test(route.path)) {
|
return '/products'
|
}
|
if (/^\/orders\/[^/]+$/.test(route.path)) {
|
return '/orders'
|
}
|
if (/^\/medical-reservations\/[^/]+$/.test(route.path)) {
|
return '/medical-reservations'
|
}
|
if (/^\/medical-applications\/[^/]+$/.test(route.path)) {
|
return '/medical-applications'
|
}
|
if (/^\/parking-plates\/[^/]+$/.test(route.path)) {
|
return '/parking-plates'
|
}
|
if (/^\/fuel-services\/[^/]+$/.test(route.path)) {
|
return '/fuel-services'
|
}
|
if (/^\/spouse-employment\/[^/]+$/.test(route.path)) {
|
return '/spouse-employment'
|
}
|
if (/^\/hotel-reservations\/[^/]+$/.test(route.path)) {
|
return '/hotel-reservations'
|
}
|
if (route.path.startsWith('/merchant-settlement/')) {
|
return '/merchant-settlement'
|
}
|
if (route.path === '/talent-news' || route.path.startsWith('/talent-news-categories/')) {
|
return '/talent-news-management'
|
}
|
if (route.path === '/talent-policies' || route.path === '/talent-policy-categories') {
|
return '/talent-policy-management'
|
}
|
if (route.path === '/movie-ticket-records') {
|
return '/movie-tickets'
|
}
|
return route.path
|
})
|
|
function currentMenuGroupIndex() {
|
return menuGroups.find((group) => findMenuLabel(group.items, activeMenu.value))?.index
|
}
|
|
function closeOtherMenuGroups(openedGroupIndex?: string) {
|
menuGroups.forEach((group) => {
|
if (group.index !== openedGroupIndex) adminMenuRef.value?.close(group.index)
|
})
|
}
|
|
function handleMenuOpen(_index: string, indexPath: string[]) {
|
// indexPath 的第一个值始终是一级模块;无论点击一级或嵌套菜单,均只保留该模块展开。
|
const openedGroupIndex = indexPath[0]
|
if (menuGroups.some((group) => group.index === openedGroupIndex)) {
|
closeOtherMenuGroups(openedGroupIndex)
|
}
|
}
|
|
watch(activeMenu, () => {
|
const groupIndex = currentMenuGroupIndex()
|
nextTick(() => {
|
closeOtherMenuGroups(groupIndex)
|
if (groupIndex) adminMenuRef.value?.open(groupIndex)
|
})
|
}, { immediate: true })
|
|
function findMenuLabel(items: MenuItem[], path: string): string | undefined {
|
for (const item of items) {
|
if (item.path === path) return item.label
|
if (item.children) {
|
const childLabel = findMenuLabel(item.children, path)
|
if (childLabel) return childLabel
|
}
|
}
|
return undefined
|
}
|
|
const breadcrumbs = computed(() => {
|
if (route.name === 'talent-create') {
|
return ['持卡管理', '持卡人管理', '新增']
|
}
|
if (route.name === 'talent-detail') {
|
return ['持卡管理', '持卡人管理', '详情']
|
}
|
if (route.name === 'merchant-create') {
|
return ['商家管理', '商家查询', route.query.mode === 'edit' ? '编辑' : '新建']
|
}
|
if (route.name === 'product-audit-detail') {
|
return ['商家管理', '团单审核', '详情']
|
}
|
if (route.name === 'merchant-onboarding-detail') {
|
return ['财务管理', '商户进件', '查看信息']
|
}
|
|
const group = menuGroups.find((item) => findMenuLabel(item.items, activeMenu.value))
|
if (group) {
|
return [group.label, pageTitle.value]
|
}
|
return [pageTitle.value]
|
})
|
|
async function logout() {
|
try {
|
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', {
|
confirmButtonText: '退出',
|
cancelButtonText: '取消',
|
type: 'warning',
|
})
|
} catch {
|
return
|
}
|
|
try {
|
await logoutAdmin()
|
} finally {
|
appStore.logout()
|
router.push('/login')
|
}
|
}
|
|
onMounted(loadNotices)
|
</script>
|
|
<template>
|
<el-container class="admin-shell">
|
<el-header class="admin-header">
|
<div class="brand">
|
<span class="brand-logo" aria-hidden="true">
|
<i></i>
|
<i></i>
|
<i></i>
|
</span>
|
<span class="brand-text">攀枝花市东区人才服务平台</span>
|
</div>
|
|
<div class="header-right">
|
<el-popover placement="bottom-end" :width="330" trigger="click" popper-class="notice-popover" @show="loadNotices">
|
<template #reference>
|
<el-badge :is-dot="notices.length > 0" class="notice-badge">
|
<el-button :icon="Bell" circle aria-label="消息通知" />
|
</el-badge>
|
</template>
|
|
<div class="notice-panel">
|
<div class="notice-panel-header">
|
<strong>待办通知</strong>
|
</div>
|
<div v-if="notices.length" class="notice-list">
|
<button
|
v-for="notice in notices"
|
:key="notice.id"
|
type="button"
|
class="notice-item"
|
@click="openNotice(notice)"
|
>
|
<span class="notice-content">{{ notice.content }}</span>
|
<time>{{ formatNoticeTime(notice.time) }}</time>
|
</button>
|
</div>
|
<div v-else class="notice-empty">暂无通知</div>
|
</div>
|
</el-popover>
|
<el-tooltip content="退出登录" placement="bottom">
|
<el-button :icon="SwitchButton" circle @click="logout" />
|
</el-tooltip>
|
<div class="admin-avatar" :title="appStore.user.name" :aria-label="`${appStore.user.name}头像`">{{ avatarText }}</div>
|
</div>
|
</el-header>
|
|
<el-container class="admin-body">
|
<el-aside width="220px" class="admin-aside">
|
<el-menu
|
ref="adminMenuRef"
|
router
|
:default-active="activeMenu"
|
:default-openeds="defaultOpeneds"
|
unique-opened
|
@open="handleMenuOpen"
|
class="admin-menu"
|
>
|
<el-menu-item index="/dashboard">
|
<el-icon><HomeFilled /></el-icon>
|
<span>首页</span>
|
</el-menu-item>
|
|
<el-sub-menu v-for="group in visibleMenuGroups" :key="group.index" :index="group.index">
|
<template #title>
|
<el-icon>
|
<component :is="group.icon" />
|
</el-icon>
|
<span>{{ group.label }}</span>
|
</template>
|
|
<template v-for="item in group.items" :key="item.path || item.index">
|
<el-sub-menu v-if="item.children" :index="item.index || item.label" class="nested-admin-menu">
|
<template #title>
|
<span>{{ item.label }}</span>
|
</template>
|
<el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
|
<span>{{ child.label }}</span>
|
</el-menu-item>
|
</el-sub-menu>
|
<el-menu-item v-else :index="item.path || item.index || item.label">
|
<span>{{ item.label }}</span>
|
</el-menu-item>
|
</template>
|
</el-sub-menu>
|
</el-menu>
|
</el-aside>
|
|
<el-main class="admin-main">
|
<div class="main-breadcrumb">
|
<span v-for="(item, index) in breadcrumbs" :key="`${item}-${index}`">
|
<span v-if="index > 0" class="breadcrumb-separator">/</span>
|
{{ item }}
|
</span>
|
</div>
|
<router-view />
|
</el-main>
|
</el-container>
|
</el-container>
|
</template>
|