feat: 重构AdminLayout布局组件,拆分为独立子组件并实现全新导航体系

This commit is contained in:
FalingCliff 2025-06-15 13:58:39 +08:00
parent 10819fc69a
commit ecb78e5982
15 changed files with 1453 additions and 607 deletions

View File

@ -17,8 +17,10 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"@types/nprogress": "^0.2.3",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"nprogress": "^0.2.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"

View File

@ -11,12 +11,18 @@ importers:
'@ant-design/icons-vue': '@ant-design/icons-vue':
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1(vue@3.5.16(typescript@5.8.3)) version: 7.0.1(vue@3.5.16(typescript@5.8.3))
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
ant-design-vue: ant-design-vue:
specifier: ^4.2.6 specifier: ^4.2.6
version: 4.2.6(vue@3.5.16(typescript@5.8.3)) version: 4.2.6(vue@3.5.16(typescript@5.8.3))
echarts: echarts:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.6.0 version: 5.6.0
nprogress:
specifier: ^0.2.0
version: 0.2.0
pinia: pinia:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.3(typescript@5.8.3)(vue@3.5.16(typescript@5.8.3)) version: 3.0.3(typescript@5.8.3)(vue@3.5.16(typescript@5.8.3))
@ -750,6 +756,9 @@ packages:
'@types/node@22.15.31': '@types/node@22.15.31':
resolution: {integrity: sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==} resolution: {integrity: sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==}
'@types/nprogress@0.2.3':
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
'@types/tough-cookie@4.0.5': '@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@ -1743,6 +1752,9 @@ packages:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'} engines: {node: '>=18'}
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
nth-check@2.1.1: nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@ -2945,6 +2957,8 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/nprogress@0.2.3': {}
'@types/tough-cookie@4.0.5': {} '@types/tough-cookie@4.0.5': {}
'@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)': '@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
@ -4042,6 +4056,8 @@ snapshots:
path-key: 4.0.0 path-key: 4.0.0
unicorn-magic: 0.3.0 unicorn-magic: 0.3.0
nprogress@0.2.0: {}
nth-check@2.1.1: nth-check@2.1.1:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0

1
src/components.d.ts vendored
View File

@ -18,6 +18,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SideMenu: typeof import('./components/layout/SideMenu.vue')['default'] SideMenu: typeof import('./components/layout/SideMenu.vue')['default']
TabsBar: typeof import('./components/layout/TabsBar.vue')['default']
YourComponent: typeof import('./components/YourComponent.vue')['default'] YourComponent: typeof import('./components/YourComponent.vue')['default']
} }
} }

View File

@ -1,363 +1,50 @@
<template> <template>
<a-layout class="admin-layout"> <a-layout class="admin-layout">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" class="admin-side" collapsible> <SideMenu v-model:collapsed="collapsed" :menus="menus" @navigate="navigateTo" />
<div class="logo">
<img alt="Logo" src="@/assets/logo.svg" />
<h1 v-show="!collapsed">Admin System</h1>
</div>
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline">
<template v-for="menu in menus" :key="menu.key">
<!-- 有子菜单的情况 -->
<template v-if="menu.children && menu.children.length > 0">
<a-sub-menu :key="menu.key">
<template #title>
<span>
<component :is="menu.icon" />
<span>{{ menu.title }}</span>
</span>
</template>
<a-menu-item
v-for="child in menu.children"
:key="child.key"
@click="() => navigateTo(child.path)"
>
<component :is="child.icon" />
<span>{{ child.title }}</span>
</a-menu-item>
</a-sub-menu>
</template>
<!-- 没有子菜单的情况 -->
<template v-else>
<a-menu-item :key="menu.key" @click="() => navigateTo(menu.path)">
<component :is="menu.icon" />
<span>{{ menu.title }}</span>
</a-menu-item>
</template>
</template>
</a-menu>
</a-layout-sider>
<!-- 内容区域 --> <!-- 右侧布局 -->
<a-layout class="admin-right-layout"> <a-layout :class="['admin-right-layout', { 'layout-collapsed': collapsed }]">
<!-- 头部 --> <!-- 头部 -->
<a-layout-header class="admin-header"> <HeaderNav v-model:collapsed="collapsed" @logout="handleLogout" />
<div class="header-left">
<menu-unfold-outlined
v-if="collapsed"
class="trigger"
@click="() => (collapsed = !collapsed)"
/>
<menu-fold-outlined v-else class="trigger" @click="() => (collapsed = !collapsed)" />
<breadcrumb />
</div>
<div class="header-right">
<a-dropdown>
<a class="user-dropdown" @click.prevent>
<a-avatar>
<template #icon>
<user-outlined />
</template>
</a-avatar>
<span class="username">管理员</span>
</a>
<template #overlay>
<a-menu>
<a-menu-item key="profile">
<user-outlined />
个人中心
</a-menu-item>
<a-menu-item key="settings">
<setting-outlined />
个人设置
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="handleLogout">
<logout-outlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<!-- 标签页 --> <!-- 标签页 -->
<div class="admin-tabs"> <TabsBar ref="tabsBarRef" />
<a-tabs
v-model:activeKey="activeTab"
:hide-add="true"
type="editable-card"
@edit="onTabEdit"
@tabClick="onTabClick"
>
<a-tab-pane
v-for="tab in tabs"
:key="tab.path"
:closable="tabs.length > 1"
:tab="tab.title"
>
<template #closeIcon>
<a-dropdown :trigger="['hover']">
<CloseOutlined />
<template #overlay>
<a-menu @click="({ key }: any) => handleTabAction(key, tab)">
<a-menu-item key="current">关闭当前标签</a-menu-item>
<a-menu-item key="others">关闭其它标签</a-menu-item>
<a-menu-item key="left">关闭左侧标签</a-menu-item>
<a-menu-item key="right">关闭右侧标签</a-menu-item>
<a-menu-item key="all">关闭所有标签</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-tab-pane>
</a-tabs>
</div>
<!-- 内容 --> <!-- <EFBFBD><EFBFBD><EFBFBD>容区域 -->
<a-layout-content class="admin-content"> <MainContent />
<router-view v-slot="{ Component }">
<transition mode="out-in" name="fade">
<component :is="Component" />
</transition>
</router-view>
<a-spin :spinning="loading" class="global-loading" tip="加载中...">
<div class="spin-content"></div>
</a-spin>
</a-layout-content>
<!-- 页脚 --> <!-- 页脚 -->
<a-layout-footer class="admin-footer"> <FooterBar />
Admin System ©{{ new Date().getFullYear() }} Created by Your Company
</a-layout-footer>
</a-layout> </a-layout>
</a-layout> </a-layout>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, reactive, ref, watch } from 'vue' import { message } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { import {
CloseOutlined,
DashboardOutlined, DashboardOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuOutlined, MenuOutlined,
MenuUnfoldOutlined,
SettingOutlined, SettingOutlined,
TeamOutlined, TeamOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue' import NProgress from 'nprogress'
import Breadcrumb from '@/components/common/Breadcrumb.vue' import 'nprogress/nprogress.css'
//
import SideMenu from './SideMenu.vue'
import HeaderNav from './HeaderNav.vue'
import TabsBar from './TabsBar.vue'
import MainContent from './MainContent.vue'
import FooterBar from './FooterBar.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const tabsBarRef = ref<InstanceType<typeof TabsBar> | null>(null)
// //
const collapsed = ref(false) const collapsed = ref(false)
//
const loading = ref(false)
//
interface TabItem {
title: string
path: string
name: string
query?: Record<string, any>
params?: Record<string, any>
}
const tabs = ref<TabItem[]>([])
const activeTab = ref('')
//
const addTab = (route: any) => {
const { path, meta, name, query, params } = route
const title = meta?.title || '未命名页面'
//
const isExist = tabs.value.some((tab) => tab.path === path)
if (!isExist) {
tabs.value.push({
title,
path,
name,
query,
params,
})
}
activeTab.value = path
}
//
const closeTab = (targetPath: string) => {
const targetIndex = tabs.value.findIndex((tab) => tab.path === targetPath)
if (targetIndex === -1) return
//
if (activeTab.value === targetPath) {
//
if (targetIndex < tabs.value.length - 1) {
activeTab.value = tabs.value[targetIndex + 1].path
router.push(tabs.value[targetIndex + 1])
} else if (targetIndex > 0) {
activeTab.value = tabs.value[targetIndex - 1].path
router.push(tabs.value[targetIndex - 1])
}
}
tabs.value.splice(targetIndex, 1)
saveTabsToStorage()
}
//
const onTabEdit = (targetKey: string, action: 'add' | 'remove') => {
if (action === 'remove') {
closeTab(targetKey)
}
}
//
const onTabClick = (key: string) => {
//
const tab = tabs.value.find((item) => item.path === key)
if (tab) {
//
router.push({
path: tab.path,
query: tab.query,
})
}
}
//
const handleTabAction = (action: string, tab: TabItem) => {
const currentIndex = tabs.value.findIndex((item) => item.path === tab.path)
switch (action) {
case 'current':
closeTab(tab.path)
break
case 'others':
tabs.value = tabs.value.filter((item) => item.path === tab.path)
break
case 'left':
if (currentIndex > 0) {
tabs.value = tabs.value.filter(
(item, index) => index >= currentIndex || item.path === '/dashboard',
)
}
break
case 'right':
if (currentIndex < tabs.value.length - 1) {
tabs.value = tabs.value.filter(
(item, index) => index <= currentIndex || item.path === '/dashboard',
)
}
break
case 'all':
//
const homePage = tabs.value.find((item) => item.path === '/dashboard')
tabs.value = homePage ? [homePage] : []
if (homePage) {
activeTab.value = homePage.path
router.push(homePage)
}
break
}
}
//
router.beforeEach((to, from, next) => {
loading.value = true
// meta.title
if (to.meta?.title) {
addTab(to)
}
next()
})
router.afterEach(() => {
//
setTimeout(() => {
loading.value = false
}, 300)
})
//
const saveTabsToStorage = () => {
localStorage.setItem('admin-tabs', JSON.stringify(tabs.value))
localStorage.setItem('admin-active-tab', activeTab.value)
}
//
const loadTabsFromStorage = () => {
const savedTabs = localStorage.getItem('admin-tabs')
const savedActiveTab = localStorage.getItem('admin-active-tab')
if (savedTabs) {
try {
tabs.value = JSON.parse(savedTabs)
//
if (savedActiveTab) {
activeTab.value = savedActiveTab
//
const activeTabData = tabs.value.find((tab) => tab.path === savedActiveTab)
if (activeTabData && activeTabData.path !== route.path) {
router.push({
path: activeTabData.path,
query: activeTabData.query,
})
return true //
}
}
} catch (e) {
console.error('Failed to parse saved tabs:', e)
}
}
return false //
}
//
watch(
tabs,
() => {
saveTabsToStorage()
},
{ deep: true },
)
watch(activeTab, () => {
localStorage.setItem('admin-active-tab', activeTab.value)
})
//
onMounted(() => {
//
const restored = loadTabsFromStorage()
//
if (!restored || tabs.value.length === 0) {
if (route.meta?.title) {
addTab(route)
}
}
})
//
const selectedKeys = ref<string[]>([])
const openKeys = ref<string[]>([])
// //
const menus = reactive([ const menus = reactive([
{ {
@ -394,20 +81,6 @@ const menus = reactive([
}, },
]) ])
//
const updateSelectedMenu = () => {
const paths = route.path.split('/')
const currentPath = paths[paths.length - 1]
//
selectedKeys.value = [currentPath]
//
if (paths.length > 2) {
openKeys.value = [paths[1]]
}
}
// //
const navigateTo = (path: string) => { const navigateTo = (path: string) => {
router.push(path) router.push(path)
@ -421,180 +94,36 @@ const handleLogout = () => {
} }
// //
watch( router.beforeEach((to, from, next) => {
() => route.path, NProgress.start()
() => {
updateSelectedMenu()
},
)
onMounted(() => { // meta.title
updateSelectedMenu() if (to.meta?.title && tabsBarRef.value) {
tabsBarRef.value.addTab(to)
}
next()
})
router.afterEach(() => {
NProgress.done()
}) })
</script> </script>
<style scoped> <style scoped>
.admin-layout { .admin-layout {
min-height: 100vh;
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: row;
overflow: hidden;
}
.admin-side {
background: white;
box-shadow: 2px 0 6px rgb(238, 238, 238);
z-index: 10;
height: 100vh;
overflow-y: auto;
} }
.admin-right-layout { .admin-right-layout {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 0;
min-height: 0;
height: 100vh; height: 100vh;
overflow: hidden; transition: all 0.2s;
}
.logo {
height: 64px;
padding: 16px;
display: flex;
align-items: center;
overflow: hidden;
}
.logo img {
height: 32px;
margin-right: 8px;
}
.logo h1 {
font-size: 18px;
margin: 0;
white-space: nowrap;
}
.admin-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 9;
}
.header-left {
display: flex;
align-items: center;
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 0 24px 0 0;
}
.trigger:hover {
color: #1890ff;
}
.header-right {
display: flex;
align-items: center;
}
.user-dropdown {
display: flex;
align-items: center;
cursor: pointer;
padding: 0 12px;
transition: all 0.3s;
}
.user-dropdown:hover {
background: rgba(0, 0, 0, 0.025);
}
.username {
margin-left: 8px;
}
.admin-content {
margin: 8px 16px 16px;
padding: 24px;
background: #fff;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
height: 0; /* 让flex: 1生效 */
border-radius: 2px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.admin-footer {
text-align: center;
padding: 16px 50px;
flex-shrink: 0;
background: #fff;
}
.global-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.spin-content {
width: 100%;
height: 100%;
}
.admin-tabs {
background: #fff;
padding: 6px 16px 0;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 8;
}
:deep(.ant-tabs) {
.ant-tabs-nav {
margin-bottom: 0;
}
.ant-tabs-tab {
padding: 8px 16px;
transition: all 0.3s;
&:hover {
color: #1890ff;
}
}
.ant-tabs-tab-remove {
margin: 0;
padding: 0;
}
}
/* 路由过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
} }
</style> </style>

View File

@ -2,7 +2,7 @@
<a-layout-footer class="footer"> <a-layout-footer class="footer">
<div class="footer-content"> <div class="footer-content">
<div class="copyright"> <div class="copyright">
Admin Template ©{{ currentYear }} Created by Your Company Admin Template ©{{ currentYear }} Created by FallingCliff
</div> </div>
<div class="links"> <div class="links">
<a href="#">帮助</a> <a href="#">帮助</a>
@ -14,6 +14,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
const currentYear = ref(new Date().getFullYear()) const currentYear = ref(new Date().getFullYear())
</script> </script>

View File

@ -1,29 +1,24 @@
<template> <template>
<a-layout-header class="header">
<a-layout-header class="header">
<div class="header-left"> <div class="header-left">
<menu-unfold-outlined <menu-unfold-outlined v-if="collapsed" class="trigger" @click="toggleCollapsed" />
v-if="collapsed" <menu-fold-outlined v-else class="trigger" @click="toggleCollapsed" />
class="trigger" <breadcrumb />
@click="toggleCollapsed"
/>
<menu-fold-outlined
v-else
class="trigger"
@click="toggleCollapsed"
/>
<span class="logo">Admin Template</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<a-space> <a-space>
<a-badge count="5"> <a-badge count="5">
<a-button type="text"> <a-button type="text">
<template #icon><BellOutlined /></template> <template #icon>
<BellOutlined />
</template>
</a-button> </a-button>
</a-badge> </a-badge>
<a-dropdown> <a-dropdown>
<a-button type="text"> <a-button type="text">
<template #icon><UserOutlined /></template> <template #icon>
<UserOutlined />
</template>
Admin User Admin User
</a-button> </a-button>
<template #overlay> <template #overlay>
@ -37,7 +32,7 @@
设置 设置
</a-menu-item> </a-menu-item>
<a-menu-divider /> <a-menu-divider />
<a-menu-item key="logout"> <a-menu-item key="logout" @click="handleLogout">
<logout-outlined /> <logout-outlined />
退出登录 退出登录
</a-menu-item> </a-menu-item>
@ -49,23 +44,27 @@
</a-layout-header> </a-layout-header>
</template> </template>
<script setup lang="ts"> <script lang="ts" setup>
import { import {
BellOutlined,
LogoutOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
BellOutlined,
UserOutlined,
SettingOutlined, SettingOutlined,
LogoutOutlined, UserOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
const props = defineProps<{ collapsed: boolean }>() const props = defineProps<{ collapsed: boolean }>()
const emit = defineEmits(['update:collapsed']) const emit = defineEmits(['update:collapsed', 'logout'])
function toggleCollapsed() { function toggleCollapsed() {
emit('update:collapsed', !props.collapsed) emit('update:collapsed', !props.collapsed)
} }
function handleLogout() {
emit('logout')
}
</script> </script>
<style scoped> <style scoped>
@ -76,7 +75,10 @@ function toggleCollapsed() {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 1; z-index: 10;
position: sticky;
top: 0;
width: 100%;
} }
.header-left { .header-left {

View File

@ -1,15 +1,5 @@
<template> <template>
<a-layout-content class="main-content"> <a-layout-content class="main-content">
<div class="content-header">
<a-breadcrumb>
<a-breadcrumb-item>首页</a-breadcrumb-item>
<a-breadcrumb-item>系统管理</a-breadcrumb-item>
<a-breadcrumb-item>用户管理</a-breadcrumb-item>
</a-breadcrumb>
<div class="content-title">
<h2>用户管理</h2>
</div>
</div>
<div class="content-container"> <div class="content-container">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
@ -21,16 +11,69 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
interface BreadcrumbItem {
title: string
path?: string
}
const breadcrumbs = ref<BreadcrumbItem[]>([])
const pageTitle = ref('')
//
const updatePageInfo = () => {
const { matched, meta } = route
//
breadcrumbs.value = matched
.filter(item => item.meta?.title) //
.map(item => ({
title: item.meta?.title as string,
path: item.path
}))
//
if (breadcrumbs.value.length === 0) {
breadcrumbs.value = [{ title: '首页', path: '/dashboard' }]
}
//
pageTitle.value = meta?.title as string || '未命名页面'
}
//
watch(
() => route.path,
() => {
updatePageInfo()
},
{ immediate: true }
)
</script> </script>
<style scoped> <style scoped>
.main-content { .main-content {
margin: 24px; flex: 1 1 0;
padding: 24px; overflow: auto;
min-height: 0;
margin: 16px;
padding: 20px;
background: #fff; background: #fff;
min-height: 280px;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
flex-direction: column;
}
@media screen and (max-width: 768px) {
.main-content {
margin: 12px;
padding: 16px;
}
} }
.content-header { .content-header {

View File

@ -13,70 +13,44 @@
v-model:selectedKeys="selectedKeys" v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys" v-model:openKeys="openKeys"
mode="inline" mode="inline"
theme="dark"
> >
<a-menu-item key="dashboard"> <template v-for="menu in menus" :key="menu.key">
<template #icon> <!-- 有子菜单的情况 -->
<dashboard-outlined /> <template v-if="menu.children && menu.children.length > 0">
<a-sub-menu :key="menu.key">
<template #icon>
<component :is="menu.icon" />
</template>
<template #title>{{ menu.title }}</template>
<a-menu-item
v-for="child in menu.children"
:key="child.key"
@click="() => navigateTo(child.path)"
>
<template #icon>
<component :is="child.icon" />
</template>
<span>{{ child.title }}</span>
</a-menu-item>
</a-sub-menu>
</template> </template>
<span>仪表盘</span> <!-- 没有子菜单的情况 -->
</a-menu-item> <template v-else>
<a-menu-item :key="menu.key" @click="() => navigateTo(menu.path)">
<a-sub-menu key="system"> <template #icon>
<template #icon> <component :is="menu.icon" />
<setting-outlined /> </template>
<span>{{ menu.title }}</span>
</a-menu-item>
</template> </template>
<template #title>系统管理</template> </template>
<a-menu-item key="user">
<template #icon>
<user-outlined />
</template>
<span>用户管理</span>
</a-menu-item>
<a-menu-item key="role">
<template #icon>
<team-outlined />
</template>
<span>角色管理</span>
</a-menu-item>
<a-menu-item key="menu">
<template #icon>
<menu-outlined />
</template>
<span>菜单管理</span>
</a-menu-item>
</a-sub-menu>
<a-sub-menu key="components">
<template #icon>
<appstore-outlined />
</template>
<template #title>组件示例</template>
<a-menu-item key="table">
<template #icon>
<table-outlined />
</template>
<span>表格</span>
</a-menu-item>
<a-menu-item key="form">
<template #icon>
<form-outlined />
</template>
<span>表单</span>
</a-menu-item>
<a-menu-item key="chart">
<template #icon>
<bar-chart-outlined />
</template>
<span>图表</span>
</a-menu-item>
</a-sub-menu>
</a-menu> </a-menu>
</a-layout-sider> </a-layout-sider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, reactive, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { import {
DashboardOutlined, DashboardOutlined,
SettingOutlined, SettingOutlined,
@ -93,17 +67,114 @@ const props = defineProps<{
collapsed: boolean collapsed: boolean
}>() }>()
const { collapsed } = toRefs(props);
defineEmits(['update:collapsed']) defineEmits(['update:collapsed'])
const selectedKeys = ref(['dashboard']) const router = useRouter()
const openKeys = ref(['system']) const route = useRoute()
//
const selectedKeys = ref<string[]>([])
const openKeys = ref<string[]>([])
//
const menus = reactive([
{
key: 'dashboard',
title: '仪表盘',
icon: DashboardOutlined,
path: '/dashboard',
},
{
key: 'system',
title: '系统管理',
icon: SettingOutlined,
children: [
{
key: 'user',
title: '用户管理',
icon: UserOutlined,
path: '/system/user',
},
{
key: 'role',
title: '角色管理',
icon: TeamOutlined,
path: '/system/role',
},
{
key: 'menu',
title: '菜单管理',
icon: MenuOutlined,
path: '/system/menu',
},
],
},
{
key: 'components',
title: '组件示例',
icon: AppstoreOutlined,
children: [
{
key: 'table',
title: '表格',
icon: TableOutlined,
path: '/components/table',
},
{
key: 'form',
title: '表单',
icon: FormOutlined,
path: '/components/form',
},
{
key: 'chart',
title: '图表',
icon: BarChartOutlined,
path: '/components/chart',
},
],
},
])
//
const navigateTo = (path: string) => {
router.push(path)
}
//
const updateSelectedMenu = () => {
const paths = route.path.split('/')
const currentPath = paths[paths.length - 1]
//
selectedKeys.value = [currentPath]
//
if (paths.length > 2) {
openKeys.value = [paths[1]]
}
}
//
watch(
() => route.path,
() => {
updateSelectedMenu()
},
)
//
onMounted(() => {
updateSelectedMenu()
})
</script> </script>
<style scoped> <style scoped>
.side-menu { .side-menu {
height: 100vh; height: 100vh;
position: fixed; background: white;
left: 0;
overflow: auto; overflow: auto;
} }
@ -113,17 +184,15 @@ const openKeys = ref(['system'])
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.1);
margin-bottom: 8px; margin-bottom: 8px;
} }
.logo { .logo {
height: 32px; height: 28px;
margin-right: 8px; margin-right: 8px;
} }
.title { .title {
color: white;
font-size: 18px; font-size: 18px;
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;

View File

@ -0,0 +1,248 @@
<template>
<div class="admin-tabs">
<a-tabs
v-model:activeKey="activeTab"
:hide-add="true"
type="editable-card"
@edit="onTabEdit"
@tabClick="onTabClick"
>
<a-tab-pane
v-for="tab in tabs"
:key="tab.path"
:closable="tabs.length > 1"
:tab="tab.title"
>
<template #closeIcon>
<a-dropdown :trigger="['hover']">
<CloseOutlined />
<template #overlay>
<a-menu @click="({ key }: any) => handleTabAction(key, tab)">
<a-menu-item key="current">关闭当前标签</a-menu-item>
<a-menu-item key="others">关闭其它标签</a-menu-item>
<a-menu-item key="left">关闭左侧标签</a-menu-item>
<a-menu-item key="right">关闭右侧标签</a-menu-item>
<a-menu-item key="all">关闭所有标签</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CloseOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
//
interface TabItem {
title: string
path: string
name: string
query?: Record<string, any>
params?: Record<string, any>
}
const tabs = ref<TabItem[]>([])
const activeTab = ref('')
//
const addTab = (route: any) => {
const { path, meta, name, query, params } = route
const title = meta?.title || '未命名页面'
//
const isExist = tabs.value.some((tab) => tab.path === path)
if (!isExist) {
tabs.value.push({
title,
path,
name,
query,
params,
})
}
activeTab.value = path
}
//
const closeTab = (targetPath: string) => {
const targetIndex = tabs.value.findIndex((tab) => tab.path === targetPath)
if (targetIndex === -1) return
//
if (activeTab.value === targetPath) {
//
if (targetIndex < tabs.value.length - 1) {
activeTab.value = tabs.value[targetIndex + 1].path
router.push(tabs.value[targetIndex + 1])
} else if (targetIndex > 0) {
activeTab.value = tabs.value[targetIndex - 1].path
router.push(tabs.value[targetIndex - 1])
}
}
tabs.value.splice(targetIndex, 1)
saveTabsToStorage()
}
//
const onTabEdit = (targetKey: string, action: 'add' | 'remove') => {
if (action === 'remove') {
closeTab(targetKey)
}
}
//
const onTabClick = (key: string) => {
//
const tab = tabs.value.find((item) => item.path === key)
if (tab) {
//
router.push({
path: tab.path,
query: tab.query,
})
}
}
//
const handleTabAction = (action: string, tab: TabItem) => {
const currentIndex = tabs.value.findIndex((item) => item.path === tab.path)
switch (action) {
case 'current':
closeTab(tab.path)
break
case 'others':
tabs.value = tabs.value.filter((item) => item.path === tab.path)
break
case 'left':
if (currentIndex > 0) {
tabs.value = tabs.value.filter(
(item, index) => index >= currentIndex || item.path === '/dashboard',
)
}
break
case 'right':
if (currentIndex < tabs.value.length - 1) {
tabs.value = tabs.value.filter(
(item, index) => index <= currentIndex || item.path === '/dashboard',
)
}
break
case 'all':
//
const homePage = tabs.value.find((item) => item.path === '/dashboard')
tabs.value = homePage ? [homePage] : []
if (homePage) {
activeTab.value = homePage.path
router.push(homePage)
}
break
}
}
//
const saveTabsToStorage = () => {
localStorage.setItem('admin-tabs', JSON.stringify(tabs.value))
localStorage.setItem('admin-active-tab', activeTab.value)
}
//
const loadTabsFromStorage = () => {
const savedTabs = localStorage.getItem('admin-tabs')
const savedActiveTab = localStorage.getItem('admin-active-tab')
if (savedTabs) {
try {
tabs.value = JSON.parse(savedTabs)
//
if (savedActiveTab) {
activeTab.value = savedActiveTab
//
const activeTabData = tabs.value.find((tab) => tab.path === savedActiveTab)
if (activeTabData && activeTabData.path !== route.path) {
router.push({
path: activeTabData.path,
query: activeTabData.query,
})
return true //
}
}
} catch (e) {
console.error('Failed to parse saved tabs:', e)
}
}
return false //
}
//
watch(
tabs,
() => {
saveTabsToStorage()
},
{ deep: true },
)
watch(activeTab, () => {
localStorage.setItem('admin-active-tab', activeTab.value)
})
//
onMounted(() => {
//
const restored = loadTabsFromStorage()
//
if (!restored || tabs.value.length === 0) {
if (route.meta?.title) {
addTab(route)
}
}
})
//
defineExpose({
addTab,
closeTab
})
</script>
<style scoped>
.admin-tabs {
padding: 5px 10px 0;
background: white;
}
:deep(.ant-tabs) {
.ant-tabs-nav {
margin-bottom: 0;
}
.ant-tabs-tab {
padding: 8px 16px;
transition: all 0.3s;
&:hover {
color: #1890ff;
}
}
.ant-tabs-tab-remove {
margin-left: 5px;
}
}
</style>

View File

0
src/views/error/404.vue Normal file
View File

View File

View File

View File

@ -0,0 +1,504 @@
<template>
<div class="role-container">
<a-card>
<!-- 操作按钮区域 -->
<div class="table-operations">
<a-button type="primary" @click="showModal('add')">
<template #icon><plus-outlined /></template>
新增角色
</a-button>
<a-button danger :disabled="!hasSelected" @click="handleBatchDelete">
<template #icon><delete-outlined /></template>
批量删除
</a-button>
</div>
<!-- 表格区域 -->
<a-table
:columns="columns"
:data-source="roleData"
:row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
:pagination="pagination"
:loading="loading"
@change="handleTableChange"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === '1' ? 'green' : 'red'">
{{ record.status === '1' ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="showModal('edit', record)">编辑</a>
<a-divider type="vertical" />
<a @click="showPermissionModal(record)">权限设置</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此角色吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a class="danger-link">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 角色表单弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="modalType === 'add' ? '新增角色' : '编辑角色'"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="角色名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-input v-model:value="formData.code" placeholder="请输入角色编码" />
</a-form-item>
<a-form-item label="角色描述" name="description">
<a-textarea
v-model:value="formData.description"
placeholder="请输入角色描述"
:rows="4"
/>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="formData.sort"
placeholder="请输入排序"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio value="1">启用</a-radio>
<a-radio value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
<!-- 权限设置弹窗 -->
<a-modal
v-model:visible="permissionModalVisible"
title="权限设置"
width="600px"
@ok="handlePermissionOk"
@cancel="handlePermissionCancel"
>
<div v-if="currentRole">
<div class="permission-title">
<span>当前角色: {{ currentRole.name }}</span>
</div>
<a-tree
v-model:checkedKeys="checkedKeys"
:tree-data="permissionTree"
checkable
:default-expand-all="true"
/>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import {
PlusOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
//
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name'
},
{
title: '角色编码',
dataIndex: 'code',
key: 'code'
},
{
title: '描述',
dataIndex: 'description',
key: 'description'
},
{
title: '排序',
dataIndex: 'sort',
key: 'sort',
sorter: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status'
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
sorter: true
},
{
title: '操作',
key: 'action',
width: 250
}
]
//
const generateMockRoleData = () => {
return [
{
id: '1',
name: '超级管理员',
code: 'ADMIN',
description: '系统最高权限,可以操作系统所有功能',
sort: 1,
status: '1',
createTime: '2023-05-01 12:00:00',
permissions: ['1', '1-1', '1-2', '1-3', '2', '2-1', '2-2', '3', '3-1', '3-2']
},
{
id: '2',
name: '运营人员',
code: 'OPERATOR',
description: '负责系统日常运营工作',
sort: 2,
status: '1',
createTime: '2023-05-02 12:00:00',
permissions: ['1', '1-1', '1-2', '2', '2-1']
},
{
id: '3',
name: '财务人员',
code: 'FINANCE',
description: '负责财务相关工作',
sort: 3,
status: '1',
createTime: '2023-05-03 12:00:00',
permissions: ['1', '1-1', '3', '3-1']
},
{
id: '4',
name: '客服人员',
code: 'CUSTOMER_SERVICE',
description: '负责客户服务工作',
sort: 4,
status: '1',
createTime: '2023-05-04 12:00:00',
permissions: ['1', '1-1', '2', '2-1']
},
{
id: '5',
name: '访客',
code: 'VISITOR',
description: '系统访客,只有查看权限',
sort: 5,
status: '0',
createTime: '2023-05-05 12:00:00',
permissions: ['1', '1-1']
}
]
}
//
const permissionTree = [
{
title: '系统管理',
key: '1',
children: [
{
title: '用户管理',
key: '1-1',
children: [
{ title: '查询用户', key: '1-1-1' },
{ title: '新增用户', key: '1-1-2' },
{ title: '编辑用户', key: '1-1-3' },
{ title: '删除用户', key: '1-1-4' }
]
},
{
title: '角色管理',
key: '1-2',
children: [
{ title: '查询角色', key: '1-2-1' },
{ title: '新增角色', key: '1-2-2' },
{ title: '编辑角色', key: '1-2-3' },
{ title: '删除角色', key: '1-2-4' }
]
},
{
title: '菜单管理',
key: '1-3',
children: [
{ title: '查询菜单', key: '1-3-1' },
{ title: '新增菜单', key: '1-3-2' },
{ title: '编辑菜单', key: '1-3-3' },
{ title: '删除菜单', key: '1-3-4' }
]
}
]
},
{
title: '内容管理',
key: '2',
children: [
{
title: '文章管理',
key: '2-1',
children: [
{ title: '查询文章', key: '2-1-1' },
{ title: '新增文章', key: '2-1-2' },
{ title: '编辑文章', key: '2-1-3' },
{ title: '删除文章', key: '2-1-4' }
]
},
{
title: '分类管理',
key: '2-2',
children: [
{ title: '查询分类', key: '2-2-1' },
{ title: '新增分类', key: '2-2-2' },
{ title: '编辑分类', key: '2-2-3' },
{ title: '删除分类', key: '2-2-4' }
]
}
]
},
{
title: '订单管理',
key: '3',
children: [
{
title: '订单列表',
key: '3-1',
children: [
{ title: '查询订单', key: '3-1-1' },
{ title: '订单详情', key: '3-1-2' },
{ title: '订单导出', key: '3-1-3' }
]
},
{
title: '退款管理',
key: '3-2',
children: [
{ title: '查询退款', key: '3-2-1' },
{ title: '审核退款', key: '3-2-2' }
]
}
]
}
]
//
const loading = ref(false)
const roleData = ref(generateMockRoleData())
const selectedRowKeys = ref<string[]>([])
const modalVisible = ref(false)
const modalType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const permissionModalVisible = ref(false)
const currentRole = ref<any>(null)
const checkedKeys = ref<string[]>([])
//
const formData = reactive({
id: '',
name: '',
code: '',
description: '',
sort: 0,
status: '1'
})
//
const formRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
//
const pagination = reactive({
current: 1,
pageSize: 10,
total: roleData.value.length,
showSizeChanger: true,
showTotal: (total: number) => `${total}`
})
//
const hasSelected = computed(() => selectedRowKeys.value.length > 0)
//
onMounted(() => {
fetchRoleData()
})
//
const fetchRoleData = () => {
loading.value = true
// API
setTimeout(() => {
pagination.total = roleData.value.length
loading.value = false
}, 500)
}
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchRoleData()
}
const onSelectChange = (keys: string[]) => {
selectedRowKeys.value = keys
}
const showModal = (type: 'add' | 'edit', record?: any) => {
modalType.value = type
//
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && record) {
Object.keys(formData).forEach(key => {
if (key in record) {
formData[key as keyof typeof formData] = record[key]
}
})
} else {
//
formData.status = '1'
formData.sort = roleData.value.length + 1
}
modalVisible.value = true
}
const handleModalOk = () => {
formRef.value?.validate().then(() => {
if (modalType.value === 'add') {
//
const newRole = {
id: (roleData.value.length + 1).toString(),
...formData,
createTime: new Date().toLocaleString(),
permissions: []
}
roleData.value.push(newRole)
message.success('添加角色成功')
} else {
//
const index = roleData.value.findIndex(item => item.id === formData.id)
if (index !== -1) {
roleData.value[index] = {
...roleData.value[index],
...formData
}
message.success('编辑角色成功')
}
}
modalVisible.value = false
fetchRoleData()
}).catch(error => {
console.log('表单验证失败:', error)
})
}
const handleModalCancel = () => {
modalVisible.value = false
}
const showPermissionModal = (record: any) => {
currentRole.value = record
checkedKeys.value = [...record.permissions]
permissionModalVisible.value = true
}
const handlePermissionOk = () => {
if (currentRole.value) {
//
const index = roleData.value.findIndex(item => item.id === currentRole.value.id)
if (index !== -1) {
roleData.value[index].permissions = [...checkedKeys.value]
message.success('权限设置成功')
}
}
permissionModalVisible.value = false
}
const handlePermissionCancel = () => {
permissionModalVisible.value = false
}
const handleDelete = (record: any) => {
//
roleData.value = roleData.value.filter(item => item.id !== record.id)
message.success('删除角色成功')
// selectedRowKeys
selectedRowKeys.value = selectedRowKeys.value.filter(key => key !== record.id)
fetchRoleData()
}
const handleBatchDelete = () => {
//
roleData.value = roleData.value.filter(item => !selectedRowKeys.value.includes(item.id))
message.success(`成功删除 ${selectedRowKeys.value.length} 个角色`)
selectedRowKeys.value = []
fetchRoleData()
}
</script>
<style lang="less" scoped>
.role-container {
padding: 0;
.table-operations {
margin-bottom: 16px;
button {
margin-right: 8px;
}
}
.permission-title {
margin-bottom: 16px;
font-weight: 500;
}
.danger-link {
color: #ff4d4f;
&:hover {
color: #ff7875;
}
}
}
</style>

View File

@ -0,0 +1,431 @@
<template>
<div class="user-container">
<a-card>
<!-- 搜索区域 -->
<a-form layout="inline" :model="searchForm" class="search-form">
<a-form-item label="用户名">
<a-input v-model:value="searchForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="手机号">
<a-input v-model:value="searchForm.phone" placeholder="请输入手机号" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" style="width: 120px">
<a-select-option value="">全部</a-select-option>
<a-select-option value="1">启用</a-select-option>
<a-select-option value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><search-outlined /></template>
查询
</a-button>
<a-button @click="resetSearch">
<template #icon><reload-outlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 操作按钮区域 -->
<div class="table-operations">
<a-button type="primary" @click="showModal('add')">
<template #icon><plus-outlined /></template>
新增用户
</a-button>
<a-button danger :disabled="!hasSelected" @click="handleBatchDelete">
<template #icon><delete-outlined /></template>
批量删除
</a-button>
</div>
<!-- 表格区域 -->
<a-table
:columns="columns"
:data-source="userData"
:row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
:pagination="pagination"
:loading="loading"
@change="handleTableChange"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === '1' ? 'green' : 'red'">
{{ record.status === '1' ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="showModal('edit', record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此用户吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a class="danger-link">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 用户表单弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="modalType === 'add' ? '新增用户' : '编辑用户'"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="用户名" name="username">
<a-input v-model:value="formData.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item
label="密码"
name="password"
:rules="modalType === 'add' ? formRules.password : undefined"
>
<a-input-password
v-model:value="formData.password"
placeholder="请输入密码"
:disabled="modalType === 'edit'"
/>
</a-form-item>
<a-form-item label="姓名" name="realName">
<a-input v-model:value="formData.realName" placeholder="请输入姓名" />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入手机号" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="角色" name="roleIds">
<a-select
v-model:value="formData.roleIds"
mode="multiple"
placeholder="请选择角色"
style="width: 100%"
>
<a-select-option v-for="role in roleOptions" :key="role.value" :value="role.value">
{{ role.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio value="1">启用</a-radio>
<a-radio value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
//
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
sorter: true
},
{
title: '姓名',
dataIndex: 'realName',
key: 'realName'
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone'
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email'
},
{
title: '角色',
dataIndex: 'roleName',
key: 'roleName'
},
{
title: '状态',
dataIndex: 'status',
key: 'status'
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
sorter: true
},
{
title: '操作',
key: 'action',
width: 150
}
]
//
const roleOptions = [
{ label: '管理员', value: '1' },
{ label: '运营', value: '2' },
{ label: '财务', value: '3' },
{ label: '客服', value: '4' }
]
//
const generateMockData = () => {
const data = []
for (let i = 1; i <= 20; i++) {
data.push({
id: i.toString(),
username: `user${i}`,
realName: `用户${i}`,
phone: `1381234${i.toString().padStart(4, '0')}`,
email: `user${i}@example.com`,
roleIds: i % 3 === 0 ? ['1'] : i % 3 === 1 ? ['2'] : ['3', '4'],
roleName: i % 3 === 0 ? '管理员' : i % 3 === 1 ? '运营' : '财务, 客服',
status: i % 4 === 0 ? '0' : '1',
createTime: `2023-05-${i.toString().padStart(2, '0')} 12:00:00`
})
}
return data
}
//
const loading = ref(false)
const userData = ref(generateMockData())
const selectedRowKeys = ref<string[]>([])
const modalVisible = ref(false)
const modalType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
//
const searchForm = reactive({
username: '',
phone: '',
status: ''
})
//
const formData = reactive({
id: '',
username: '',
password: '',
realName: '',
phone: '',
email: '',
roleIds: [] as string[],
status: '1'
})
//
const formRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
roleIds: [{ required: true, message: '请选择角色', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
//
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `${total}`
})
//
const hasSelected = computed(() => selectedRowKeys.value.length > 0)
//
onMounted(() => {
fetchUserData()
})
//
const fetchUserData = () => {
loading.value = true
// API
setTimeout(() => {
const filteredData = userData.value.filter(item => {
return (
(searchForm.username ? item.username.includes(searchForm.username) : true) &&
(searchForm.phone ? item.phone.includes(searchForm.phone) : true) &&
(searchForm.status ? item.status === searchForm.status : true)
)
})
pagination.total = filteredData.length
loading.value = false
}, 500)
}
const handleSearch = () => {
pagination.current = 1
fetchUserData()
}
const resetSearch = () => {
Object.keys(searchForm).forEach(key => {
searchForm[key as keyof typeof searchForm] = ''
})
pagination.current = 1
fetchUserData()
}
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchUserData()
}
const onSelectChange = (keys: string[]) => {
selectedRowKeys.value = keys
}
const showModal = (type: 'add' | 'edit', record?: any) => {
modalType.value = type
//
if (formRef.value) {
formRef.value.resetFields()
}
if (type === 'edit' && record) {
Object.keys(formData).forEach(key => {
if (key in record) {
formData[key as keyof typeof formData] = record[key]
}
})
} else {
//
formData.status = '1'
formData.roleIds = []
}
modalVisible.value = true
}
const handleModalOk = () => {
formRef.value?.validate().then(() => {
if (modalType.value === 'add') {
//
const newUser = {
id: (userData.value.length + 1).toString(),
...formData,
roleName: formData.roleIds.map(id =>
roleOptions.find(role => role.value === id)?.label || ''
).join(', '),
createTime: new Date().toLocaleString()
}
userData.value.unshift(newUser)
message.success('添加用户成功')
} else {
//
const index = userData.value.findIndex(item => item.id === formData.id)
if (index !== -1) {
userData.value[index] = {
...userData.value[index],
...formData,
roleName: formData.roleIds.map(id =>
roleOptions.find(role => role.value === id)?.label || ''
).join(', ')
}
message.success('编辑用户成功')
}
}
modalVisible.value = false
fetchUserData()
}).catch(error => {
console.log('表单验证失败:', error)
})
}
const handleModalCancel = () => {
modalVisible.value = false
}
const handleDelete = (record: any) => {
//
userData.value = userData.value.filter(item => item.id !== record.id)
message.success('删除用户成功')
// selectedRowKeys
selectedRowKeys.value = selectedRowKeys.value.filter(key => key !== record.id)
fetchUserData()
}
const handleBatchDelete = () => {
//
userData.value = userData.value.filter(item => !selectedRowKeys.value.includes(item.id))
message.success(`成功删除 ${selectedRowKeys.value.length} 个用户`)
selectedRowKeys.value = []
fetchUserData()
}
</script>
<style lang="less" scoped>
.user-container {
padding: 0;
.search-form {
margin-bottom: 16px;
}
.table-operations {
margin-bottom: 16px;
button {
margin-right: 8px;
}
}
.danger-link {
color: #ff4d4f;
&:hover {
color: #ff7875;
}
}
}