2026-07-29 09:32:14 +08:00
|
|
|
import { computed, defineComponent, h, Transition, KeepAlive } from 'vue';
|
2026-07-24 15:57:20 +08:00
|
|
|
import type { CSSProperties } from 'vue';
|
2026-07-14 10:31:17 +08:00
|
|
|
import { useRouter, useRoute, RouterView } from 'vue-router';
|
2026-07-25 18:00:22 +08:00
|
|
|
import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue';
|
2026-07-14 10:31:17 +08:00
|
|
|
import type { MenuProps } from 'ant-design-vue';
|
|
|
|
|
import {
|
2026-07-25 18:00:22 +08:00
|
|
|
EditOutlined,
|
|
|
|
|
LogoutOutlined,
|
2026-07-14 10:31:17 +08:00
|
|
|
MenuFoldOutlined,
|
|
|
|
|
MenuUnfoldOutlined,
|
|
|
|
|
} from '@ant-design/icons-vue';
|
2026-07-23 11:27:33 +08:00
|
|
|
import { useMenuStore } from '@/stores/menuStore';
|
2026-07-25 18:00:22 +08:00
|
|
|
import { useUserStore } from '@/stores/userStore';
|
2026-07-27 16:29:12 +08:00
|
|
|
import { useTabsStore } from '@/stores/tabsStore';
|
2026-07-25 18:00:22 +08:00
|
|
|
import { useState, useBreadcrumb, auth } from '@/hooks';
|
2026-07-27 16:29:12 +08:00
|
|
|
import { ChangePasswordModal, PageTabs } from '@/components';
|
2026-07-30 15:43:24 +08:00
|
|
|
import { updateUserPwd } from '@/api/users';
|
2026-07-28 17:40:35 +08:00
|
|
|
import { renderRouteIcon } from '@/utils/routeIcon';
|
2026-07-25 18:00:22 +08:00
|
|
|
import logoutSvg from '@/assets/img/icon/logout.svg';
|
2026-07-14 10:31:17 +08:00
|
|
|
import styles from './BasicLayout.module.less';
|
|
|
|
|
|
|
|
|
|
const { Header, Sider, Content, Footer } = Layout;
|
|
|
|
|
|
2026-07-24 15:57:20 +08:00
|
|
|
const headerStyle: CSSProperties = {
|
|
|
|
|
background: '#fff',
|
2026-07-27 16:29:12 +08:00
|
|
|
padding: '0',
|
2026-07-24 15:57:20 +08:00
|
|
|
display: 'flex',
|
2026-07-27 16:29:12 +08:00
|
|
|
flexDirection: 'column',
|
|
|
|
|
justifyContent: 'center',
|
2026-07-24 15:57:20 +08:00
|
|
|
color: '#1a1a1a',
|
|
|
|
|
boxShadow: '0 1px 4px rgba(0, 21, 41, 0.08)',
|
2026-07-27 16:29:12 +08:00
|
|
|
height: 'auto',
|
2026-07-24 15:57:20 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const siderStyle: CSSProperties = {
|
|
|
|
|
background: '#fff',
|
|
|
|
|
boxShadow: '2px 0 8px rgba(0, 21, 41, 0.08)',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const footerStyle: CSSProperties = {
|
|
|
|
|
padding: '0 50px 16px 50px',
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
color: '#3a3a3a',
|
|
|
|
|
userSelect: 'none',
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-23 11:27:33 +08:00
|
|
|
/**
|
|
|
|
|
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
2026-07-28 17:40:35 +08:00
|
|
|
* 递归处理 iconName → vue-icons-plus 图标 VNode
|
2026-07-23 11:27:33 +08:00
|
|
|
*/
|
|
|
|
|
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
|
|
|
|
return items.map((item) => {
|
|
|
|
|
const resolved: any = {
|
|
|
|
|
key: item.key,
|
|
|
|
|
label: item.label,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-28 17:40:35 +08:00
|
|
|
if (item.iconName) {
|
|
|
|
|
resolved.icon = () => renderRouteIcon(item.iconName, { size: 16 });
|
2026-07-23 11:27:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (item.children?.length) {
|
|
|
|
|
resolved.children = resolveMenuItems(item.children);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolved;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 基础布局:左侧导航(后端菜单驱动) + 顶部栏 + 内容区 + 页脚
|
2026-07-14 10:31:17 +08:00
|
|
|
*/
|
|
|
|
|
export default defineComponent({
|
|
|
|
|
name: 'BasicLayout',
|
|
|
|
|
setup() {
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
const router = useRouter();
|
2026-07-29 09:32:14 +08:00
|
|
|
const [collapsed, setCollapsed] = useState(false);
|
2026-07-14 10:31:17 +08:00
|
|
|
|
2026-07-23 11:27:33 +08:00
|
|
|
const { menuItems } = useMenuStore();
|
2026-08-05 15:40:57 +08:00
|
|
|
const { phone, nickname } = useUserStore();
|
2026-07-30 18:02:41 +08:00
|
|
|
const {
|
|
|
|
|
tabs,
|
|
|
|
|
cachedViews,
|
|
|
|
|
removeTab,
|
|
|
|
|
removeLeftTabs,
|
|
|
|
|
removeRightTabs,
|
|
|
|
|
removeOtherTabs,
|
|
|
|
|
removeAllTabs,
|
|
|
|
|
} = useTabsStore();
|
2026-07-25 18:00:22 +08:00
|
|
|
const { breadcrumbs } = useBreadcrumb();
|
2026-07-23 11:27:33 +08:00
|
|
|
|
2026-07-14 10:31:17 +08:00
|
|
|
const selectedKeys = computed<string[]>(() => [route.path]);
|
|
|
|
|
|
2026-07-23 11:27:33 +08:00
|
|
|
const resolvedItems = computed(() => resolveMenuItems(menuItems.value));
|
2026-07-14 10:31:17 +08:00
|
|
|
|
|
|
|
|
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
|
|
|
|
router.push(key as string);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleCollapsed = () => {
|
2026-07-29 09:32:14 +08:00
|
|
|
setCollapsed(!collapsed.value);
|
2026-07-14 10:31:17 +08:00
|
|
|
};
|
|
|
|
|
|
2026-07-27 16:29:12 +08:00
|
|
|
const activeTabKey = computed(() => route.path);
|
|
|
|
|
|
2026-07-25 18:00:22 +08:00
|
|
|
/** ===== 退出登录 ===== */
|
|
|
|
|
const handleLogout = () => {
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
title: '提示',
|
|
|
|
|
content: '是否退出登录?',
|
|
|
|
|
okText: '确认',
|
|
|
|
|
cancelText: '取消',
|
|
|
|
|
onOk: () => {
|
|
|
|
|
const { clearUser } = useUserStore();
|
|
|
|
|
clearUser();
|
|
|
|
|
auth.logout();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** ===== 修改密码弹窗 ===== */
|
|
|
|
|
const [changePwdVisible, setChangePwdVisible] = useState<boolean>(false);
|
|
|
|
|
const [changePwdSubmitting, setChangePwdSubmitting] = useState<boolean>(false);
|
|
|
|
|
|
|
|
|
|
const handleChangePassword = () => {
|
|
|
|
|
setChangePwdVisible(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleChangePwdSave = async (payload: { newPassword: string }) => {
|
|
|
|
|
if (changePwdSubmitting.value) return;
|
|
|
|
|
setChangePwdSubmitting(true);
|
|
|
|
|
try {
|
2026-07-30 15:43:24 +08:00
|
|
|
const res = await updateUserPwd({
|
|
|
|
|
password: payload.newPassword,
|
|
|
|
|
confirmPassword: payload.newPassword,
|
|
|
|
|
});
|
2026-07-31 18:15:24 +08:00
|
|
|
if (res.code == 200) {
|
2026-07-30 15:43:24 +08:00
|
|
|
message.success('密码修改成功');
|
|
|
|
|
setChangePwdVisible(false);
|
2026-08-05 15:40:57 +08:00
|
|
|
window.setTimeout(() => {
|
|
|
|
|
auth.logout();
|
|
|
|
|
}, 1500);
|
2026-07-30 15:43:24 +08:00
|
|
|
} else {
|
|
|
|
|
message.error(res.msg || '修改失败');
|
|
|
|
|
}
|
2026-07-25 18:00:22 +08:00
|
|
|
} catch (err: any) {
|
2026-07-30 15:43:24 +08:00
|
|
|
console.error('修改密码失败:', err);
|
2026-07-25 18:00:22 +08:00
|
|
|
} finally {
|
|
|
|
|
setChangePwdSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** ===== 头像下拉菜单:修改密码 / 退出登录 ===== */
|
|
|
|
|
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
|
|
|
|
if (key === 'changePassword') {
|
|
|
|
|
handleChangePassword();
|
|
|
|
|
} else if (key === 'logout') {
|
|
|
|
|
handleLogout();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-27 16:29:12 +08:00
|
|
|
/** ===== PageTabs 事件 ===== */
|
|
|
|
|
const handleTabChange = (key: string) => {
|
|
|
|
|
if (key !== route.path) {
|
|
|
|
|
router.push(key);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleTabClose = (key: string) => {
|
|
|
|
|
removeTab(key);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-30 18:02:41 +08:00
|
|
|
const handleCloseLeftTabs = (key: string) => {
|
|
|
|
|
removeLeftTabs(key);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCloseRightTabs = (key: string) => {
|
|
|
|
|
removeRightTabs(key);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCloseOtherTabs = (key: string) => {
|
|
|
|
|
removeOtherTabs(key);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCloseAllTabs = () => {
|
|
|
|
|
removeAllTabs();
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-14 10:31:17 +08:00
|
|
|
return () => (
|
2026-07-28 10:54:36 +08:00
|
|
|
<Layout class={styles.basicLayoutMain}>
|
2026-07-24 15:57:20 +08:00
|
|
|
<Sider style={siderStyle} collapsed={collapsed.value} trigger={null} collapsible>
|
2026-07-14 10:31:17 +08:00
|
|
|
<div class={styles.logo}>
|
2026-07-24 15:57:20 +08:00
|
|
|
<span class={styles.logoText}>{collapsed.value ? '6羽' : '六羽赛事运营平台'}</span>
|
2026-07-14 10:31:17 +08:00
|
|
|
</div>
|
|
|
|
|
<Menu
|
2026-07-24 15:57:20 +08:00
|
|
|
theme="light"
|
2026-07-14 10:31:17 +08:00
|
|
|
mode="inline"
|
|
|
|
|
selectedKeys={selectedKeys.value}
|
2026-07-23 11:27:33 +08:00
|
|
|
items={resolvedItems.value}
|
2026-07-14 10:31:17 +08:00
|
|
|
onClick={handleMenuClick}
|
|
|
|
|
/>
|
|
|
|
|
</Sider>
|
|
|
|
|
|
2026-07-24 15:57:20 +08:00
|
|
|
<Layout class={styles.main}>
|
|
|
|
|
<Header style={headerStyle}>
|
2026-07-27 16:29:12 +08:00
|
|
|
{/*
|
|
|
|
|
── 顶部行:折叠按钮 + 面包屑 + 右侧账号信息 ──
|
|
|
|
|
按需求把原来平铺在 Header 里的三块统一包到一个 row 容器内,
|
|
|
|
|
形成「行容器 + tabs 容器」的两层结构
|
|
|
|
|
*/}
|
|
|
|
|
<div class={styles.headerRow}>
|
|
|
|
|
<span class={styles.trigger} onClick={toggleCollapsed}>
|
|
|
|
|
{collapsed.value ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
{/* 面包屑:紧跟在折叠按钮之后 */}
|
|
|
|
|
<Breadcrumb separator=">" class={styles.breadcrumb}>
|
|
|
|
|
{breadcrumbs.value.map((item, index) => {
|
|
|
|
|
const isLast = index === breadcrumbs.value.length - 1;
|
|
|
|
|
return (
|
|
|
|
|
<Breadcrumb.Item key={item.path || index}>
|
|
|
|
|
{isLast || !item.path ? (
|
|
|
|
|
<span>{item.title}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<a onClick={() => router.push(item.path as string)}>{item.title}</a>
|
|
|
|
|
)}
|
|
|
|
|
</Breadcrumb.Item>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Breadcrumb>
|
|
|
|
|
|
|
|
|
|
{/* 右侧: 账号信息 + 退出 */}
|
|
|
|
|
<div class={styles.headerRight}>
|
2026-08-05 15:40:57 +08:00
|
|
|
<span class={styles.userPhone}>{nickname.value}</span>
|
2026-07-27 16:29:12 +08:00
|
|
|
<Dropdown trigger={['hover']} placement="bottomLeft">
|
|
|
|
|
{{
|
|
|
|
|
default: () => <img src={logoutSvg} class={styles.logoutIcon} />,
|
|
|
|
|
overlay: () => (
|
|
|
|
|
<Menu onClick={handleUserMenuClick}>
|
|
|
|
|
<Menu.Item key="changePassword">
|
|
|
|
|
<span class={styles.menuItem}>
|
|
|
|
|
<EditOutlined />
|
|
|
|
|
修改密码
|
|
|
|
|
</span>
|
|
|
|
|
</Menu.Item>
|
|
|
|
|
<Menu.Divider />
|
|
|
|
|
<Menu.Item key="logout">
|
|
|
|
|
<span class={`${styles.menuItem} ${styles.menuItemDanger}`}>
|
|
|
|
|
<LogoutOutlined />
|
|
|
|
|
退出登录
|
|
|
|
|
</span>
|
|
|
|
|
</Menu.Item>
|
|
|
|
|
</Menu>
|
|
|
|
|
),
|
|
|
|
|
}}
|
|
|
|
|
</Dropdown>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/*
|
|
|
|
|
── Tabs 行:Chrome 风格页面标签栏 ──
|
|
|
|
|
数据来自 tabsStore,点击切换路由,关闭按钮走 store.removeTab
|
|
|
|
|
*/}
|
|
|
|
|
<div class={styles.pageTabs}>
|
|
|
|
|
<PageTabs
|
|
|
|
|
tabs={tabs.value}
|
|
|
|
|
activeKey={activeTabKey.value}
|
|
|
|
|
onChange={handleTabChange}
|
|
|
|
|
onClose={handleTabClose}
|
2026-07-30 18:02:41 +08:00
|
|
|
onCloseLeft={handleCloseLeftTabs}
|
|
|
|
|
onCloseRight={handleCloseRightTabs}
|
|
|
|
|
onCloseOther={handleCloseOtherTabs}
|
|
|
|
|
onCloseAll={handleCloseAllTabs}
|
2026-07-27 16:29:12 +08:00
|
|
|
/>
|
2026-07-25 18:00:22 +08:00
|
|
|
</div>
|
2026-07-14 10:31:17 +08:00
|
|
|
</Header>
|
|
|
|
|
|
2026-07-27 16:29:12 +08:00
|
|
|
{/*
|
|
|
|
|
── 路由出口 ──
|
|
|
|
|
用 <KeepAlive :include="cachedViews"> 缓存已访问过的页面,
|
|
|
|
|
include 是组件 name 列表,所以页面组件需要在 defineComponent 中显式给出 name
|
|
|
|
|
*/}
|
2026-07-14 10:31:17 +08:00
|
|
|
<Content class={styles.content}>
|
2026-07-28 15:24:42 +08:00
|
|
|
<div class={styles.pageView}>
|
|
|
|
|
<RouterView
|
|
|
|
|
v-slots={{
|
|
|
|
|
default: ({ Component, route }: any) => (
|
|
|
|
|
<KeepAlive include={cachedViews.value}>
|
2026-07-29 11:03:54 +08:00
|
|
|
<Transition name="page-fade-slide">
|
2026-07-28 15:24:42 +08:00
|
|
|
{Component ? h(Component, { key: route.path }) : null}
|
|
|
|
|
</Transition>
|
|
|
|
|
</KeepAlive>
|
|
|
|
|
),
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-07-14 10:31:17 +08:00
|
|
|
</Content>
|
|
|
|
|
|
2026-07-24 15:57:20 +08:00
|
|
|
<Footer style={footerStyle}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
|
2026-07-14 10:31:17 +08:00
|
|
|
</Layout>
|
2026-07-25 18:00:22 +08:00
|
|
|
|
|
|
|
|
{/* 修改密码弹窗 */}
|
2026-07-27 10:00:12 +08:00
|
|
|
{changePwdVisible.value && (
|
|
|
|
|
<ChangePasswordModal
|
|
|
|
|
visible={changePwdVisible.value}
|
|
|
|
|
submitting={changePwdSubmitting.value}
|
|
|
|
|
onClose={() => setChangePwdVisible(false)}
|
|
|
|
|
onSave={handleChangePwdSave}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-14 10:31:17 +08:00
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
});
|