重写复现方法

This commit is contained in:
2025-09-02 17:27:50 +08:00
parent 239be643c1
commit 108534a964
14 changed files with 2217 additions and 1702 deletions

45
capi-ui/src/api/menu.ts Normal file
View File

@@ -0,0 +1,45 @@
import request from '@/utils/request'
/**
* 菜单模块接口定义
*/
export interface MenuItem {
menuId: string
menuName: string
menuCode: string
moduleCode: string
cicon: string
chref: string
ftenantId: string
fflowId: string | null
fflowTaskId: string | null
fflowState: string | null
}
export interface ModuleItem {
moduleName: string
moduleCode: string
icon: string
href: string
menus: MenuItem[]
}
export interface MenuResponse {
code: number
message: string
result: ModuleItem[]
}
/**
* 获取模块菜单数据
*/
export const getModuleMenus = async (): Promise<ModuleItem[]> => {
const response = await request.get<MenuResponse>('/Sys/login/getModules')
if (response.code !== 200) {
console.error('获取菜单数据失败:', response.message)
throw new Error(`获取菜单失败: ${response.message}`)
}
return response.result || []
}

View File

@@ -1,38 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<p>
Welcome:
<a href="https://hx.dcloud.net.cn/" target="_blank">HBuilderX</a>
</p>
<p>
<a href="https://vitejs.dev/guide/features.html" target="_blank">
Vite Documentation
</a>
|
<a href="https://v3.vuejs.org/" target="_blank">Vue 3 Documentation</a>
</p>
<button type="button" @click="count++">count is: {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test hot module replacement.
</p>
</template>
<style scoped>
a {
color: #42b983;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,652 @@
<template>
<div class="app-container">
<a-layout style="min-height: 100vh;">
<!-- 顶部导航栏 -->
<a-layout-header class="header">
<div class="logo">
<img src="/my.png" class="logo-img" alt="系统Logo" />
<span class="system-name">cApi</span>
</div>
<div class="header-actions">
<div class="search-box">
<SearchOutlined class="search-icon" />
<input type="text" placeholder="搜索" class="search-input" />
</div>
<div class="action-icons">
<a-tooltip title="收藏">
<StarOutlined class="action-icon" />
</a-tooltip>
<a-tooltip title="通知">
<BellOutlined class="action-icon" />
</a-tooltip>
<a-tooltip title="帮助">
<QuestionCircleOutlined class="action-icon" />
</a-tooltip>
<a-tooltip title="设置">
<SettingOutlined class="action-icon" />
</a-tooltip>
</div>
<a-dropdown
placement="bottomRight"
:auto-adjust-overflow="true"
@visible-change="handleDropdownVisible"
>
<div class="user-avatar">
<img src="https://picsum.photos/id/1005/200/200" alt="用户头像" class="avatar-img" />
</div>
<template #overlay>
<a-menu class="user-menu" @click="handleUserMenuClick">
<a-menu-item key="profile">个人资料</a-menu-item>
<a-menu-item key="settings">账户设置</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" class="logout-menu-item">
<LogoutOutlined class="mr-2" /> 退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<!-- 中间主体布局 -->
<a-layout style="flex: 1; height: calc(100vh - 64px);">
<!-- 左侧菜单折叠时悬浮子菜单展开时点击展开 -->
<a-layout-sider
class="side-menu-container"
width="200"
:collapsed="collapsed"
:trigger="null"
collapsible
:class="{ 'sider-collapsed': collapsed }"
>
<!-- 加载状态 -->
<div v-if="loading" class="menu-loading">
<a-spin size="large" />
</div>
<!-- 菜单内容 -->
<div v-else class="menu-scroll-container">
<!-- 父级菜单列表根据折叠状态切换交互 -->
<div
v-for="(module, moduleKey) in moduleMenusConfig"
:key="module.moduleCode"
class="parent-menu-item"
>
<!-- 折叠状态使用Dropdown实现悬浮子菜单 -->
<a-dropdown
v-if="collapsed"
placement="right"
:auto-adjust-overflow="true"
:open="collapsedMenuVisible[module.moduleCode]"
@openChange="(visible) => handleCollapsedMenuVisible(visible, module.moduleCode)"
>
<!-- 折叠时的父级菜单触发按钮 -->
<div class="parent-menu-header collapsed-mode" @click.stop>
<component :is="getIconComponent(module.icon)" class="parent-menu-icon" />
<!-- 折叠时显示模块名称tooltip -->
<a-tooltip :title="module.moduleName" placement="right">
<span class="collapsed-tooltip-placeholder" />
</a-tooltip>
</div>
<!-- 折叠时的悬浮子菜单内容 -->
<template #overlay>
<a-menu
class="collapsed-sub-menu"
@click="(e) => handleCollapsedSubMenuClick(e, module.menus)"
>
<a-menu-item
v-for="menu in module.menus"
:key="menu.menuCode"
:selected="selectedKey === menu.menuCode"
>
<component :is="getIconComponent(menu.cicon)" class="sub-menu-icon" />
<span>{{ menu.menuName }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- 展开状态原点击展开子菜单逻辑 -->
<div v-else>
<!-- 父级菜单标题点击展开/关闭子菜单 -->
<div
class="parent-menu-header"
@click="toggleSubMenu(module.moduleCode)"
>
<component :is="getIconComponent(module.icon)" class="parent-menu-icon" />
<span class="parent-menu-title">{{ module.moduleName }}</span>
<component
:is="isSubMenuOpen(module.moduleCode) ? CaretUpOutlined : CaretDownOutlined"
class="parent-menu-caret"
/>
</div>
<!-- 子菜单列表根据状态显示/隐藏 -->
<div
class="sub-menu-list"
:class="{ 'sub-menu-visible': isSubMenuOpen(module.moduleCode) }"
>
<div
v-for="menu in module.menus"
:key="menu.menuCode"
class="sub-menu-item"
:class="{ 'sub-menu-item-active': selectedKey === menu.menuCode }"
@click="handleSubMenuItemClick(menu)"
>
<component :is="getIconComponent(menu.cicon)" class="sub-menu-icon" />
<span>{{ menu.menuName }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 折叠/展开按钮 -->
<div class="collapse-trigger" @click="collapsed = !collapsed">
<component :is="collapsed ? MenuUnfoldOutlined : MenuFoldOutlined" />
</div>
</a-layout-sider>
<!-- 右侧内容区 -->
<a-layout-content class="right-content">
<div class="tab-bar">
<a-tag
:class="['console-tab', activeTabKey === 'console' ? 'console-tab--active' : '']"
@click="switchToConsole"
>
<HomeOutlined class="console-tab-icon" />
<span>控制台</span>
</a-tag>
<a-button
size="small"
class="tab-nav-btn"
@click="switchTab('prev')"
:disabled="currentTabIndex === 0 || otherTabs.length === 0"
ghost
>
&lt;
</a-button>
<a-tabs
v-model:activeKey="activeTabKey"
@change="handleTabChange"
class="google-tabs"
:animated="false"
hide-add
>
<a-tab-pane
v-for="tab in otherTabs"
:key="tab.key"
:tab="renderTabTitle(tab)"
/>
</a-tabs>
<a-button
size="small"
class="tab-nav-btn"
@click="switchTab('next')"
:disabled="currentTabIndex === otherTabs.length - 1 || otherTabs.length === 0"
ghost
>
&gt;
</a-button>
<a-button
size="small"
class="close-all-btn"
@click="closeAllTabs"
:disabled="otherTabs.length === 0"
ghost
>
<CloseOutlined class="close-icon" /> 关闭所有
</a-button>
</div>
<a-layout-content class="main-content">
<div class="home-content">
<!-- 加载状态显示 -->
<div v-if="contentLoading" class="content-loading">
<a-spin size="large" />
</div>
<!-- 组件内容显示 -->
<component
v-else
:is="currentMainComponent"
:key="activeTabKey"
/>
<!-- 组件加载失败提示 -->
<div v-if="componentError" class="component-error">
<ExclamationCircleOutlined class="error-icon" />
<p>组件加载失败请检查路径是否正确</p>
<p class="error-path">{{ errorPath }}</p>
</div>
</div>
</a-layout-content>
</a-layout-content>
</a-layout>
</a-layout>
<!-- 退出确认对话框 -->
<a-modal
v-model:open="logoutVisible"
:centered="true"
:closable="false"
:mask-closable="false"
:footer="null"
:width="420"
wrap-class-name="logout-modal-wrapper"
>
<div class="logout-body">
<p class="logout-title">
<ExclamationCircleOutlined style="color:#faad14; margin-right:8px;" />
温馨提示
</p>
<p class="logout-desc">是否确认退出系统</p>
<div class="logout-actions">
<a-button size="large" @click="logoutVisible = false">取消</a-button>
<a-button
type="primary"
size="large"
style="margin-left: 16px"
@click="doLogout"
>
确定
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h, reactive, watch, onMounted, defineAsyncComponent } from 'vue';
import { Layout, Menu, Button, Tabs, Card, Tag, Dropdown, Modal, Tooltip, Spin } from 'ant-design-vue';
import { getModuleMenus, ModuleItem, MenuItem } from '@/api/menu'
import {
ApiOutlined,
UserOutlined,
CloseOutlined,
LinkOutlined,
LogoutOutlined,
ExclamationCircleOutlined,
BarChartOutlined,
DownloadOutlined,
SettingOutlined,
FormOutlined,
HistoryOutlined,
HomeOutlined,
DatabaseOutlined,
SearchOutlined,
StarOutlined,
BellOutlined,
QuestionCircleOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
CaretUpOutlined,
CaretDownOutlined
} from '@ant-design/icons-vue';
// 导入控制台组件
import Main from './Main.vue';
// 状态管理
const collapsed = ref(false); // 菜单折叠状态
const activeTabKey = ref('console'); // 当前激活的标签页
const logoutVisible = ref(false); // 退出确认框显示状态
const selectedKey = ref(''); // 当前选中的子菜单项
const expandedParentMenus = ref<string[]>([]); // 展开状态下的子菜单展开记录
const loading = ref(true); // 菜单加载状态
const contentLoading = ref(false); // 内容区加载状态
const componentError = ref(false); // 组件加载错误状态
const errorPath = ref(''); // 错误路径记录
// 状态定义
const moduleMenusConfig = ref<ModuleItem[]>([])
// 初始化折叠状态下的菜单显示状态
const collapsedMenuVisible = reactive<Record<string, boolean>>({})
// 标签页数据类型定义
interface TabItem {
key: string;
title: string;
component: any;
closable: boolean;
chref: string; // 存储组件路径
}
// 标签页数据
const allTabs = ref<TabItem[]>([
{ key: 'console', title: '控制台', component: Main, closable: false, chref: '' }
]);
// 计算属性
const otherTabs = computed(() => allTabs.value.filter(tab => tab.key !== 'console'));
const currentTabIndex = computed(() => otherTabs.value.findIndex(tab => tab.key === activeTabKey.value));
const currentMainComponent = computed(() => {
try {
const targetTab = allTabs.value.find(tab => tab.key === activeTabKey.value);
return targetTab ? targetTab.component : null;
} catch (error) {
console.error('组件加载失败:', error);
return null;
}
});
// 从接口获取菜单数据
const fetchMenuData = async () => {
try {
loading.value = true;
const modules = await getModuleMenus()
moduleMenusConfig.value = modules
// 初始化折叠状态下的菜单显示状态
modules.forEach(module => {
collapsedMenuVisible[module.moduleCode] = false
})
} catch (error) {
console.error('菜单接口请求失败:', error);
} finally {
loading.value = false;
}
};
// 页面加载时获取菜单数据
onMounted(() => {
fetchMenuData();
});
// 根据图标名称获取图标组件
const getIconComponent = (iconName: string) => {
const iconMap: Record<string, any> = {
ApiOutlined,
UserOutlined,
BarChartOutlined,
DownloadOutlined,
SettingOutlined,
FormOutlined,
HistoryOutlined,
HomeOutlined,
DatabaseOutlined,
LinkOutlined
};
return iconMap[iconName] || ApiOutlined;
};
// 根据chref路径获取组件
const getComponentByPath = async (chref: string) => {
try {
// 显示加载状态
contentLoading.value = true;
componentError.value = false;
// 添加@vite-ignore注释告诉Vite忽略动态导入分析
const component = defineAsyncComponent(() =>
import(/* @vite-ignore */ `@/views/${chref}.vue`)
);
return component;
} catch (error) {
console.error(`加载组件失败: @/views/${chref}.vue`, error);
componentError.value = true;
errorPath.value = `@/views/${chref}.vue`;
return null;
} finally {
contentLoading.value = false;
}
};
// 检查展开状态下子菜单是否打开
const isSubMenuOpen = (moduleCode: string) => {
return expandedParentMenus.value.includes(moduleCode);
};
// 【优化核心】展开状态:切换子菜单展开/关闭,点击当前父级时关闭其他父级
const toggleSubMenu = (moduleCode: string) => {
if (isSubMenuOpen(moduleCode)) {
// 当前父级已展开,点击则关闭
expandedParentMenus.value = [];
} else {
// 当前父级未展开,点击则打开当前并关闭其他所有父级
expandedParentMenus.value = [moduleCode];
}
};
// 折叠状态:控制悬浮菜单显示/隐藏
const handleCollapsedMenuVisible = (visible: boolean, moduleCode: string) => {
// 关闭其他所有悬浮菜单
Object.keys(collapsedMenuVisible).forEach(key => {
if (key !== moduleCode) collapsedMenuVisible[key] = false;
});
// 更新当前菜单状态
collapsedMenuVisible[moduleCode] = visible;
};
// 标签页标题渲染
const renderTabTitle = (tab: TabItem) => {
return h('div', { class: 'tab-title-container' }, [
h('span', { class: 'tab-title-text' }, tab.title),
tab.closable && h(
'span',
{
class: 'tab-close-btn',
onClick: (e: Event) => {
e.stopPropagation();
deleteTab(tab.key);
}
},
h(CloseOutlined, { size: 12 })
)
]);
};
// 切换到控制台
const switchToConsole = () => {
activeTabKey.value = 'console';
selectedKey.value = '';
componentError.value = false;
// 关闭所有展开的子菜单
expandedParentMenus.value = [];
// 关闭所有折叠状态的悬浮菜单
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
};
// 标签页切换(上一个/下一个)
const switchTab = (direction: 'prev' | 'next') => {
componentError.value = false;
if (direction === 'prev' && currentTabIndex.value > 0) {
activeTabKey.value = otherTabs.value[currentTabIndex.value - 1].key;
} else if (direction === 'next' && currentTabIndex.value < otherTabs.value.length - 1) {
activeTabKey.value = otherTabs.value[currentTabIndex.value + 1].key;
}
};
// 关闭单个标签页
const deleteTab = (key: string) => {
if (key === 'console') return;
const tabIndex = allTabs.value.findIndex(tab => tab.key === key);
if (tabIndex === -1) return;
allTabs.value = allTabs.value.filter(tab => tab.key !== key);
if (key === activeTabKey.value) {
const targetTab = allTabs.value[tabIndex - 1] || allTabs.value.find(tab => tab.key === 'console');
activeTabKey.value = targetTab.key;
componentError.value = false;
}
};
// 关闭所有标签页
const closeAllTabs = () => {
allTabs.value = allTabs.value.filter(tab => tab.key === 'console');
activeTabKey.value = 'console';
selectedKey.value = '';
expandedParentMenus.value = [];
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
componentError.value = false;
};
// 展开状态:子菜单项点击
const handleSubMenuItemClick = async (menu: MenuItem) => {
selectedKey.value = menu.menuCode;
componentError.value = false;
try {
// 检查标签页是否已存在
const existingTab = allTabs.value.find(tab => tab.key === menu.menuCode);
if (existingTab) {
// 标签页已存在,直接切换
activeTabKey.value = menu.menuCode;
return;
}
// 动态加载组件
const component = await getComponentByPath(menu.chref);
if (component) {
// 添加新标签页
allTabs.value.push({
key: menu.menuCode,
title: menu.menuName,
component: component,
closable: true,
chref: menu.chref
});
activeTabKey.value = menu.menuCode;
}
} catch (error) {
console.error('处理菜单点击失败:', error);
}
};
// 折叠状态:悬浮子菜单项点击
const handleCollapsedSubMenuClick = async (e: { key: string }, menus: MenuItem[]) => {
const menuCode = e.key;
const menu = menus.find(item => item.menuCode === menuCode);
if (menu) {
selectedKey.value = menuCode;
componentError.value = false;
try {
// 检查标签页是否已存在
const existingTab = allTabs.value.find(tab => tab.key === menuCode);
if (existingTab) {
// 标签页已存在,直接切换
activeTabKey.value = menuCode;
// 关闭所有悬浮菜单
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
return;
}
// 动态加载组件
const component = await getComponentByPath(menu.chref);
if (component) {
// 添加新标签页
allTabs.value.push({
key: menu.menuCode,
title: menu.menuName,
component: component,
closable: true,
chref: menu.chref
});
activeTabKey.value = menu.menuCode;
}
} catch (error) {
console.error('处理菜单点击失败:', error);
} finally {
// 点击后关闭所有悬浮菜单
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
}
}
};
// 标签页切换时同步菜单状态
const handleTabChange = (key: string) => {
activeTabKey.value = key;
componentError.value = false;
if (key === 'console') {
selectedKey.value = '';
expandedParentMenus.value = [];
Object.keys(collapsedMenuVisible).forEach(k => collapsedMenuVisible[k] = false);
return;
}
// 同步选中状态
selectedKey.value = key;
// 展开状态:同步展开对应父菜单,关闭其他父菜单
if (!collapsed.value) {
moduleMenusConfig.value.forEach(module => {
const hasKey = module.menus.some(menu => menu.menuCode === key);
if (hasKey) {
// 只展开当前子菜单所属的父级
expandedParentMenus.value = [module.moduleCode];
}
});
}
};
// 用户菜单相关方法
const handleDropdownVisible = (visible: boolean) => {
console.log('用户下拉菜单状态:', visible ? '显示' : '隐藏');
};
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
handleLogout();
} else {
console.log('用户菜单点击:', key);
}
};
const handleLogout = () => {
logoutVisible.value = true;
};
const doLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
logoutVisible.value = false;
alert('退出登录成功,即将跳转到登录页');
// 实际项目中替换为router.push('/login');
};
// 监听折叠状态变化,重置菜单状态
watch(collapsed, (newVal) => {
if (newVal) {
// 切换到折叠状态:关闭所有展开的子菜单
expandedParentMenus.value = [];
} else {
// 切换到展开状态:关闭所有悬浮菜单
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
// 如果有选中的子菜单,展开对应父菜单,关闭其他父菜单
if (selectedKey.value) {
moduleMenusConfig.value.forEach(module => {
const hasKey = module.menus.some(menu => menu.menuCode === selectedKey.value);
if (hasKey) {
expandedParentMenus.value = [module.moduleCode];
}
});
}
}
});
</script>
<style scoped>
@import "styles/app-layout.css";
</style>

View File

@@ -0,0 +1,9 @@
<template>
jjjjjjjjj
</template>
<script>
</script>
<style>
</style>

View File

@@ -0,0 +1,511 @@
/* 基础样式 */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, .app-container { height: 100vh; overflow: hidden; }
.app-container { display: flex; flex-direction: column; }
/* 顶部导航栏样式 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 64px;
width: 100%;
background: linear-gradient(90deg, #e6f7ff 0%, #f0f9ff 100%);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.logo {
display: flex;
align-items: center;
padding-right: 52px;
border-right: 0px solid rgba(24, 144, 255, 0.1);
gap: 10px;
}
.logo-img {
width: 65px;
height: 65px;
object-fit: contain;
vertical-align: middle;
}
.system-name {
font-size: 18px;
font-weight: 600;
color: #1890ff;
}
.header-actions { display: flex; align-items: center; gap: 16px; }
.search-box {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.8);
border-radius: 4px;
padding: 0 12px;
height: 36px;
width: 240px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.search-icon { color: #8c8c8c; margin-right: 8px; }
.search-input {
background: transparent;
border: none;
outline: none;
color: #333;
width: 100%;
font-size: 14px;
}
.search-input::placeholder { color: #b3b3b3; }
.action-icons {
display: flex;
align-items: center;
gap: 16px;
padding-right: 16px;
border-right: 1px solid rgba(24, 144, 255, 0.1);
}
.action-icon {
color: #1890ff;
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
}
.action-icon:hover { color: #096dd9; transform: scale(1.1); }
.user-avatar { cursor: pointer; }
.avatar-img {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid transparent;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.user-avatar:hover .avatar-img {
border-color: #1890ff;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.2);
}
.user-menu { width: 160px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); }
.logout-menu-item { color: #f5222d !important; }
/* 左侧菜单样式 - 核心区域 */
.side-menu-container {
background: #fff;
border-right: 1px solid #e0e0e0;
transition: all 0.3s ease;
position: relative;
display: flex;
flex-direction: column;
z-index: 10; /* 确保菜单在内容上方 */
}
/* 折叠状态的侧边栏 */
.sider-collapsed {
width: 64px !important;
}
/* 菜单加载状态 */
.menu-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
/* 菜单滚动容器 */
.menu-scroll-container {
flex: 1;
padding: 16px 0;
overflow-y: auto;
padding-bottom: 60px; /* 为底部按钮留出空间 */
}
/* 父级菜单项容器 */
.parent-menu-item {
margin-bottom: 4px;
border-radius: 4px;
overflow: hidden;
}
/* 父级菜单标题基础样式 */
.parent-menu-header {
height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background-color: #fff;
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
/* 展开状态父菜单交互 */
.parent-menu-header:hover {
background-color: #f5f9ff;
border-left-color: #1890ff;
}
/* 折叠状态父菜单样式 */
.parent-menu-header.collapsed-mode {
justify-content: center;
padding: 0;
border-left: none;
height: 48px; /* 增大点击区域 */
width: 100%;
}
.parent-menu-header.collapsed-mode:hover {
background-color: #f5f9ff;
border-left: none;
}
/* 折叠状态tooltip占位元素 */
.collapsed-tooltip-placeholder {
display: inline-block;
width: 24px;
height: 24px;
}
/* 父级菜单图标 */
.parent-menu-icon {
color: #1890ff;
font-size: 16px;
margin-right: 12px;
}
/* 折叠状态图标 */
.parent-menu-header.collapsed-mode .parent-menu-icon {
margin-right: 0;
font-size: 20px; /* 折叠时图标放大 */
}
/* 父级菜单文字 */
.parent-menu-title {
flex: 1;
font-size: 14px;
color: #333;
font-weight: 500;
}
/* 父级菜单箭头 */
.parent-menu-caret {
color: #888;
font-size: 14px;
transition: transform 0.2s ease;
}
/* 展开状态子菜单列表容器 */
.sub-menu-list {
background-color: #fafafa;
overflow: hidden;
max-height: 0;
transition: max-height 0.3s ease-out;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
}
/* 展开状态子菜单显示 */
.sub-menu-visible {
max-height: 500px;
transition: max-height 0.3s ease-in;
}
/* 展开状态子菜单项样式 */
.sub-menu-item {
height: 38px;
padding: 0 16px 0 44px;
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
color: #666;
border-left: 2px solid transparent;
}
/* 子菜单图标 */
.sub-menu-icon {
color: #666;
font-size: 14px;
margin-right: 10px;
}
/* 展开状态子菜单项交互 */
.sub-menu-item:hover {
background-color: #f0f7ff;
color: #1890ff;
padding-left: 42px;
border-left-color: #1890ff;
}
.sub-menu-item:hover .sub-menu-icon {
color: #1890ff;
}
/* 子菜单项选中样式 */
.sub-menu-item-active {
background-color: #e6f4ff;
color: #1890ff;
font-weight: 500;
padding-left: 42px;
border-left-color: #1890ff;
}
.sub-menu-item-active .sub-menu-icon {
color: #1890ff;
}
/* 折叠状态:悬浮子菜单样式 */
:deep(.collapsed-sub-menu) {
min-width: 180px !important;
border-radius: 6px !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15) !important;
padding: 6px 0 !important;
z-index: 999 !important; /* 确保悬浮菜单在最上层 */
}
:deep(.collapsed-sub-menu .ant-menu-item) {
height: 40px !important;
line-height: 40px !important;
padding: 0 16px !important;
transition: all 0.2s ease !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
:deep(.collapsed-sub-menu .ant-menu-item:hover) {
background-color: #f5f9ff !important;
color: #1890ff !important;
}
:deep(.collapsed-sub-menu .ant-menu-item-selected) {
background-color: #e6f4ff !important;
color: #1890ff !important;
}
:deep(.collapsed-sub-menu .ant-menu-item .anticon) {
font-size: 14px !important;
}
/* 折叠/展开按钮 */
.collapse-trigger {
position: absolute;
bottom: 16px;
right: -18px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
z-index: 11; /* 确保按钮在菜单上方 */
color: #1890ff;
transition: all 0.2s ease;
}
.collapse-trigger:hover {
color: #096dd9;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.15);
transform: scale(1.05);
}
/* 右侧内容区样式 */
.right-content {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: #f8f9fa;
}
.tab-bar {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 0 20px;
height: 44px;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: all 0.3s ease;
}
.console-tab {
height: 32px;
padding: 0 16px;
border-radius: 6px 6px 0 0;
border: none;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
color: #666;
background: transparent;
transition: all 0.2s ease;
}
.console-tab-icon { margin-right: 6px; font-size: 14px; }
.console-tab--active {
background: #e8f4ff !important;
color: #1890ff;
font-weight: 500;
box-shadow: 0 1px 2px rgba(24, 144, 255, 0.1);
}
.console-tab:hover:not(.console-tab--active) {
background: #f5f9ff;
color: #1890ff;
}
.tab-nav-btn {
width: 28px;
height: 28px;
padding: 0;
color: #666;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.tab-nav-btn:hover { background: #f5f9ff; color: #1890ff; }
.tab-nav-btn:disabled { color: #ccc !important; cursor: not-allowed; background: transparent !important; }
.google-tabs { flex: 1; overflow: hidden; }
:deep(.ant-tabs-nav) { height: 100%; margin: 0 !important; }
:deep(.ant-tabs-nav-list) { height: 100%; align-items: center; }
:deep(.ant-tabs-tab) {
height: 36px;
padding: 0 16px;
margin: 0 2px;
border-radius: 6px 6px 0 0;
background: transparent;
transition: all 0.2s ease;
font-size: 14px;
color: #666;
}
:deep(.ant-tabs-tab:hover:not(.ant-tabs-tab-active)) { background: #f5f9ff; color: #1890ff; }
:deep(.ant-tabs-tab.ant-tabs-tab-active) {
background: #e8f4ff !important;
color: #1890ff !important;
font-weight: 500;
box-shadow: 0 1px 2px rgba(24, 144, 255, 0.1);
border-bottom: 2px solid #1890ff;
}
:deep(.ant-tabs-ink-bar) { display: none; }
.tab-title-container {
display: flex;
align-items: center;
gap: 6px;
height: 100%;
padding: 0 4px;
}
.tab-title-text { white-space: nowrap; }
.tab-close-btn {
width: 16px;
height: 16px;
display: none;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #999;
cursor: pointer;
transition: all 0.2s;
}
.tab-title-container:hover .tab-close-btn { display: inline-flex; }
:deep(.ant-tabs-tab-active) .tab-close-btn { color: #1890ff; }
.tab-close-btn:hover { background: #d1eaff; color: #1890ff; }
.close-all-btn {
color: #666;
margin-left: 2px;
font-size: 13px;
display: flex;
align-items: center;
border-radius: 4px;
padding: 0 10px;
}
.close-all-btn:hover { color: #1890ff; background: #f5f9ff; }
.close-all-btn:disabled { color: #ccc !important; cursor: not-allowed; background: transparent !important; }
.close-icon { margin-right: 4px; font-size: 12px; }
.main-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.home-content {
background: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
height: 100%;
overflow: auto;
min-height: calc(100vh - 64px - 44px - 32px);
}
/* 内容加载状态 */
.content-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
}
/* 组件加载错误样式 */
.component-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
color: #f5222d;
padding: 20px;
text-align: center;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-path {
font-family: monospace;
margin-top: 8px;
color: #8c8c8c;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
max-width: 90%;
}
/* 退出对话框样式 */
:deep(.logout-modal-wrapper .ant-modal) { width: 480px !important; }
.logout-body { text-align: center; padding: 8px 0 4px; }
.logout-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
text-align: left;
color: #333;
}
.logout-desc {
font-size: 16px;
color: #555;
margin-bottom: 32px;
text-align: left;
}
.logout-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}

View File

@@ -1,26 +1,30 @@
import { createRouter, createWebHistory } from 'vue-router'
// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/login'
},
/* 1. 固定路由 */
const constantRoutes: RouteRecordRaw[] = [
{ path: '/', redirect: '/login' },
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/Login.vue')
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/sys/Dashboard.vue'),
path: '/index',
name: 'Index',
component: () => import('@/views/sys/index.vue'),
meta: { requiresAuth: true }
},
{
path: '/biz/data/index', // 路由PATH与chref对应
name: 'DataIndex',
component: () => import('@/biz/data/index.vue') // 对应的组件
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
routes: [...constantRoutes]
})
export default router

View File

@@ -0,0 +1,162 @@
<template>
<!-- 根容器100%填充父容器home-content确保自适应 -->
<div class="empty-page-container">
<!-- 空白页核心内容区垂直居中 -->
<div class="empty-content">
<!-- 空状态图标 -->
<div class="empty-icon">
<FileTextOutlined />
</div>
<!-- 标题与描述 -->
<h2 class="empty-title">这是一个空白页面</h2>
<p class="empty-desc">
你可以在这里扩展功能比如添加数据展示表单提交图表分析等内容<br>
页面已适配父容器尺寸支持高度/宽度自适应
</p>
<!-- 操作按钮提供基础交互入口 -->
<div class="empty-actions">
<a-button type="primary" @click="handleCreate">
<PlusOutlined class="mr-2" /> 创建内容
</a-button>
<a-button @click="handleGuide" style="margin-left: 12px;">
<QuestionCircleOutlined class="mr-2" /> 查看使用指南
</a-button>
</div>
<!-- 辅助提示展示页面基础信息 -->
<div class="empty-meta">
<span class="meta-item">页面路径@/views/EmptyTemplatePage.vue</span>
<span class="meta-item">适配场景列表页详情页表单页等初始模板</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 引入必要组件(与原项目组件库一致)
import { Button } from 'ant-design-vue';
import { FileTextOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue';
// 页面基础状态(可根据需求扩展)
const pageName = ref('空白模板页面');
// 按钮交互事件(示例:可根据业务逻辑修改)
const handleCreate = () => {
console.log(`[${pageName.value}] 触发「创建内容」操作`);
// 实际场景可扩展:打开创建弹窗、跳转表单页面等
alert('可在此处实现「创建内容」的业务逻辑');
};
const handleGuide = () => {
console.log(`[${pageName.value}] 查看使用指南`);
// 实际场景可扩展:打开帮助文档、显示引导弹窗等
alert('使用指南:\n1. 此页面已适配父容器自适应尺寸\n2. 可在 empty-content 内添加业务组件\n3. 样式可参考原项目规范调整');
};
</script>
<style scoped>
/* 根容器100%填充父容器,确保自适应 */
.empty-page-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* 空白页内容区:居中展示,控制宽高比例 */
.empty-content {
width: 100%;
max-width: 600px;
padding: 48px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
/* 空状态图标:突出显示,统一风格 */
.empty-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: #e8f4ff;
display: flex;
align-items: center;
justify-content: center;
color: #1890ff;
}
.empty-icon :deep(svg) {
width: 40px;
height: 40px;
}
/* 标题样式:与原项目标题层级一致 */
.empty-title {
font-size: 18px;
font-weight: 500;
color: #333;
margin: 0;
}
/* 描述文本:浅色、易读 */
.empty-desc {
font-size: 14px;
color: #666;
line-height: 1.6;
margin: 0;
}
/* 操作按钮区:间距控制 */
.empty-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
/* 辅助信息:弱化显示,底部对齐 */
.empty-meta {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
font-size: 12px;
color: #999;
}
/* 响应式:小屏幕适配 */
@media (max-width: 768px) {
.empty-content {
padding: 32px 16px;
gap: 16px;
}
.empty-icon {
width: 64px;
height: 64px;
}
.empty-icon :deep(svg) {
width: 32px;
height: 32px;
}
.empty-title {
font-size: 16px;
}
.empty-meta {
flex-direction: column;
gap: 8px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
<!-- src/views/sys/index.vue -->
<template>
<AppLayout />
</template>
<script setup lang="ts">
import AppLayout from '@/components/Layout/AppLayout.vue'
</script>
<style scoped>
/* 可选:局部样式覆盖 */
</style>

View File

@@ -1,4 +1,6 @@
<template>
ffffffffffffffffffff
</template>
<script>

View File

@@ -59,4 +59,5 @@ public class ApiModule implements Serializable {
*/
@TableField("f_flow_state")
private Integer fFlowState;
}

View File

@@ -0,0 +1,31 @@
package com.mini.capi.sys.domain;
import com.mini.capi.biz.domain.ApiMenus;
import com.mini.capi.biz.domain.ApiModule;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class ModuleMenuDTO implements Serializable {
private String moduleName;
private String moduleCode;
private String icon;
private String href;
List<ApiMenus> menus = new ArrayList<>();
public ModuleMenuDTO() {
}
public ModuleMenuDTO(String moduleName,String moduleCode,String icon,String href,List<ApiMenus> menus){
this.moduleName = moduleName;
this.moduleCode = moduleCode;
this.icon = icon;
this.href = href;
this.menus = menus;
}
}

View File

@@ -1,6 +1,5 @@
package com.mini.capi.sys.pageController;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.mini.capi.biz.domain.ApiMenus;
import com.mini.capi.biz.domain.ApiModule;
@@ -8,13 +7,16 @@ import com.mini.capi.biz.domain.ApiUser;
import com.mini.capi.biz.service.ApiMenusService;
import com.mini.capi.biz.service.ApiModuleService;
import com.mini.capi.biz.service.ApiUserService;
import com.mini.capi.model.ApiResult;
import com.mini.capi.model.auth.Result;
import com.mini.capi.sys.domain.ModuleMenuDTO;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpSession;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -98,20 +100,21 @@ public class loginPageController {
}
@GetMapping("/modules")
public List<ApiModule> modules() {
return moduleService.list();
@GetMapping("/getModules")
public ApiResult<List<ModuleMenuDTO>> getModules() {
List<ApiModule> apiModules = moduleService.list();
List<ModuleMenuDTO> menuDTOList = new ArrayList<>();
for (ApiModule apiModule : apiModules) {
ModuleMenuDTO menuDTO = new ModuleMenuDTO(apiModule.getModuleName(), apiModule.getModuleCode(), apiModule.getCIcon(), apiModule.getCHref(), getMenus(apiModule.getModuleCode()));
menuDTOList.add(menuDTO);
}
return ApiResult.success(menuDTOList);
}
@GetMapping("/menus")
public List<ApiMenus> menus(@RequestParam String moduleCode) {
public List<ApiMenus> getMenus(String moduleCode) {
QueryWrapper<ApiMenus> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("module_code", moduleCode);
return menusService.list(queryWrapper);
}
@GetMapping("/me")
public ApiUser me(HttpSession session) {
return (ApiUser) session.getAttribute("Authorization");
}
}