feat(router): 添加医生工作站等功能模块路由配置

- 新增医生工作站路由,包含待写病历功能
- 添加全部功能模块路由,支持功能列表和配置页面
- 集成待办事项模块路由,完善工作流功能
- 配置相关API接口和服务类,实现用户配置管理
- 实现待写病历列表展示和相关业务逻辑
- 完善首页统计数据显示功能
This commit is contained in:
2026-02-01 15:05:57 +08:00
parent 6f7d723c6b
commit 98fe9f3301
16 changed files with 2811 additions and 0 deletions

View File

@@ -0,0 +1,746 @@
<template>
<div class="config-container">
<div class="page-header">
<h2>首页功能配置</h2>
<p>选择要在首页快捷功能区域显示的功能</p>
</div>
<div class="config-content">
<el-card class="config-card">
<template #header>
<div class="card-header">
<span>功能选择</span>
<el-button class="button" type="primary" @click="saveConfig">保存配置</el-button>
</div>
</template>
<div class="config-layout">
<div class="menu-tree-section">
<el-input
v-model="filterText"
placeholder="输入关键字进行过滤"
size="default"
style="margin-bottom: 16px;"
/>
<el-card v-loading="loading" class="tree-card">
<el-tree
ref="treeRef"
:data="menuTree"
:props="treeProps"
show-checkbox
node-key="menuId"
:default-expanded-keys="expandedKeys"
:default-checked-keys="checkedKeys"
:filter-node-method="filterNode"
:expand-on-click-node="false"
@check="handleCheckChange"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<div class="tree-node-info">
<div class="node-main">
<el-icon :size="16" :color="getIconColor(data)">
<component :is="getIconComponent(data.icon)" />
</el-icon>
<span class="menu-label" style="margin-left: 8px;">{{ node.label }}</span>
<el-tag v-if="data.fullPath" type="info" size="small" effect="plain" class="path-tag-inline">
{{ data.fullPath }}
</el-tag>
<el-tag v-else-if="data.path" type="info" size="small" effect="plain" class="path-tag-inline">
{{ data.path }}
</el-tag>
</div>
</div>
<span>
<el-tag
v-if="data.menuType === 'M'"
type="info"
size="small"
style="margin-right: 8px;">
目录
</el-tag>
<el-tag
v-if="data.menuType === 'C'"
type="success"
size="small"
style="margin-right: 8px;">
菜单
</el-tag>
<el-tag
v-if="data.menuType === 'F'"
type="warning"
size="small">
按钮
</el-tag>
</span>
</span>
</template>
</el-tree>
</el-card>
</div>
<div class="selected-functions-section">
<h4>已选择的功能</h4>
<div class="selected-functions-list">
<div
v-for="item in selectedFunctions"
:key="item.menuId"
class="selected-function-item"
>
<div class="function-info">
<el-icon :size="16" :color="getIconColor(item)">
<component :is="getIconComponent(item.icon)" />
</el-icon>
<div class="function-details">
<span class="function-name">{{ item.menuName }}</span>
<el-tag v-if="item.fullPath" type="info" size="small" class="function-path-below">
{{ item.fullPath }}
</el-tag>
<el-tag v-else-if="item.path" type="info" size="small" class="function-path-below">
{{ item.path }}
</el-tag>
</div>
</div>
<el-button
type="danger"
size="small"
icon="Delete"
circle
@click="removeSelected(item.menuId)"
/>
</div>
<div v-if="selectedFunctions.length === 0" class="no-selected">
暂无选择功能
</div>
</div>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, watch } from 'vue'
import { ElMessage } from 'element-plus'
import useUserStore from '@/store/modules/user'
import { listMenu } from '@/api/system/menu'
import { getMenuFullPath } from '@/api/system/menu'
import { saveCurrentUserConfig, getCurrentUserConfig } from '@/api/system/userConfig'
import {
Menu,
Grid,
Folder,
Tickets,
Document,
Setting,
User,
Goods,
ChatDotSquare,
Histogram,
Wallet,
OfficeBuilding,
Postcard,
Collection,
VideoPlay,
Camera,
Headset,
Phone,
Message,
ChatLineSquare,
ChatRound,
Guide,
Help,
InfoFilled,
CircleCheck,
CircleClose,
Warning,
QuestionFilled,
Star,
Link,
Position,
Picture,
Upload,
Download,
CaretLeft,
CaretRight,
More,
Close,
Check,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Plus,
Minus,
ZoomIn,
ZoomOut,
Refresh,
Search,
Edit,
Delete,
Share,
View,
SwitchButton,
Hide,
Finished,
CirclePlus,
Remove,
CircleCheckFilled,
CircleCloseFilled,
WarningFilled,
InfoFilled as InfoFilledIcon,
SuccessFilled,
QuestionFilled as QuestionFilledIcon
} from '@element-plus/icons-vue'
// 添加 loading 状态
const loading = ref(false)
const treeRef = ref()
const menuTree = ref([])
const expandedKeys = ref([])
const checkedKeys = ref([])
const selectedFunctions = ref([]) // 已选择的功能
const filterText = ref('') // 过滤文本
const userStore = useUserStore()
const treeProps = {
children: 'children',
label: 'menuName'
}
// 监听过滤文本变化
watch(filterText, (val) => {
treeRef.value?.filter(val)
})
// 图标映射
const iconMap = {
'menu': Menu,
'grid': Grid,
'folder': Folder,
'tickets': Tickets,
'document': Document,
'setting': Setting,
'user': User,
'goods': Goods,
'chat-dot-square': ChatDotSquare,
'histogram': Histogram,
'wallet': Wallet,
'office-building': OfficeBuilding,
'postcard': Postcard,
'collection': Collection,
'video-play': VideoPlay,
'camera': Camera,
'headset': Headset,
'phone': Phone,
'message': Message,
'chat-line-square': ChatLineSquare,
'chat-round': ChatRound,
'guide': Guide,
'help': Help,
'info-filled': InfoFilled,
'circle-check': CircleCheck,
'circle-close': CircleClose,
'warning': Warning,
'question-filled': QuestionFilled,
'star': Star,
'link': Link,
'position': Position,
'picture': Picture,
'upload': Upload,
'download': Download,
'caret-left': CaretLeft,
'caret-right': CaretRight,
'more': More,
'close': Close,
'check': Check,
'arrow-up': ArrowUp,
'arrow-down': ArrowDown,
'arrow-left': ArrowLeft,
'arrow-right': ArrowRight,
'plus': Plus,
'minus': Minus,
'zoom-in': ZoomIn,
'zoom-out': ZoomOut,
'refresh': Refresh,
'search': Search,
'edit': Edit,
'delete': Delete,
'share': Share,
'view': View,
'switch-button': SwitchButton,
'hide': Hide,
'finished': Finished,
'circle-plus': CirclePlus,
'remove': Remove,
'circle-check-filled': CircleCheckFilled,
'circle-close-filled': CircleCloseFilled,
'warning-filled': WarningFilled,
'info-filled-icon': InfoFilledIcon,
'success-filled': SuccessFilled,
'question-filled-icon': QuestionFilledIcon
}
// 获取图标组件
const getIconComponent = (iconName) => {
if (!iconName) return Document
// 移除前缀,如 fa-, el-icon-
const cleanIconName = iconName.replace(/^(fa-|el-icon-)/, '').toLowerCase()
return iconMap[cleanIconName] || Document
}
// 获取图标颜色
const getIconColor = (data) => {
if (data.menuType === 'M') return '#409EFF' // 目录蓝色
if (data.menuType === 'C') return '#67C23A' // 菜单绿色
if (data.menuType === 'F') return '#E6A23C' // 按钮橙色
return '#909399' // 默认灰色
}
// 加载菜单数据
const loadMenuData = async () => {
loading.value = true
try {
const response = await listMenu({})
if (response.code === 200) {
// 过滤掉隐藏的菜单项、目录和按钮类型的菜单,只保留当前角色可访问的菜单项
const filteredMenus = filterVisibleMenus(response.data)
menuTree.value = filteredMenus
// 展开所有节点
expandedKeys.value = getAllNodeIds(filteredMenus)
// 获取已保存的配置
await loadSavedConfig()
} else {
ElMessage.error('获取菜单数据失败: ' + response.msg)
}
} catch (error) {
console.error('加载菜单数据失败:', error)
ElMessage.error('加载菜单数据失败')
} finally {
loading.value = false
}
}
// 过滤可见的菜单项(非隐藏且非按钮类型,仅显示当前角色可访问的菜单)
const filterVisibleMenus = (menus) => {
return menus.filter(menu => {
// 过滤掉隐藏的菜单项和按钮类型的菜单保留目录M类型和菜单C类型
// visible为'0'表示可见,'1'表示隐藏
return menu.visible !== '1' && menu.menuType !== 'F'
}).map(menu => {
// 保留完整路径信息
if (menu.fullPath) {
menu.fullPath = menu.fullPath;
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
menu.children = filterVisibleMenus(menu.children)
}
return menu
})
}
// 获取所有节点ID
const getAllNodeIds = (nodes) => {
const ids = []
nodes.forEach(node => {
ids.push(node.menuId)
if (node.children) {
ids.push(...getAllNodeIds(node.children))
}
})
return ids
}
// 保存配置
const saveConfig = async () => {
loading.value = true
try {
// 获取所有选中的节点,包括半选状态的节点
const checkedNodes = treeRef.value.getCheckedNodes()
// 只保留菜单类型C类型的节点
const validMenuNodes = checkedNodes.filter(node => node.menuType === 'C')
// 创建包含菜单ID和完整路径的对象数组
const menuPromises = validMenuNodes.map(async (node) => {
try {
console.log(`开始获取菜单 ${node.menuName} (ID: ${node.menuId}) 的完整路径`);
console.log(`节点对象:`, node);
console.log(`节点的 path 属性:`, node.path);
// 获取菜单的完整路径
const fullPathResponse = await getMenuFullPath(node.menuId);
console.log(`菜单 ${node.menuName} 的完整路径响应:`, fullPathResponse);
let fullPath = fullPathResponse.code === 200 ? (fullPathResponse.data || fullPathResponse.msg) : node.path;
// 确保路径格式正确,去除多余的斜杠
if (fullPath && typeof fullPath === 'string') {
// 将多个连续的斜杠替换为单个斜杠,但保留协议部分的双斜杠(如 http://
fullPath = fullPath.replace(/([^:])\/{2,}/g, '$1/');
}
console.log(`菜单 ${node.menuName} 的完整路径:`, fullPath);
const menuItem = {
menuId: node.menuId,
fullPath: fullPath,
menuName: node.menuName,
path: node.path,
icon: node.icon, // 保存图标信息
menuType: node.menuType // 保存菜单类型信息
};
console.log(`构造的菜单项对象:`, menuItem);
return menuItem;
} catch (error) {
console.error(`获取菜单 ${node.menuName} 的完整路径失败:`, error);
console.error(`错误堆栈:`, error.stack);
// 如果获取完整路径失败,使用现有路径作为备选
console.log(`在错误处理中,节点的 path 属性:`, node.path);
const menuItem = {
menuId: node.menuId,
fullPath: node.path,
menuName: node.menuName,
path: node.path,
icon: node.icon, // 保存图标信息
menuType: node.menuType // 保存菜单类型信息
};
console.log(`构造的菜单项对象(错误处理):`, menuItem);
return menuItem;
}
});
// 等待所有完整路径获取完成
const menuDataWithPaths = await Promise.all(menuPromises);
// 添加调试信息
console.log('准备保存的菜单数据:', menuDataWithPaths);
// 检查每个对象是否包含 fullPath 属性
menuDataWithPaths.forEach((item, index) => {
console.log(`菜单项 ${index} 包含 fullPath:`, item.hasOwnProperty('fullPath'), '值为:', item.fullPath);
});
// 对配置值进行URL编码以避免特殊字符问题
const encodedConfigValue = encodeURIComponent(JSON.stringify(menuDataWithPaths))
console.log('编码后的配置值:', encodedConfigValue);
// 保存到数据库
const saveResult = await saveCurrentUserConfig('homeFeaturesConfig', encodedConfigValue)
if (saveResult.code === 200) {
// 只有在数据库保存成功后,才保存到本地存储
localStorage.setItem('homeFeaturesConfig', JSON.stringify(menuDataWithPaths))
ElMessage.success('配置保存成功')
// 触发全局事件,通知首页更新快捷功能
window.dispatchEvent(new Event('homeFeaturesConfigUpdated'));
} else {
console.error('保存到数据库失败:', saveResult);
ElMessage.error('保存到数据库失败')
}
} catch (error) {
console.error('保存配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
loading.value = false
}
}
// 处理复选框变化
const handleCheckChange = () => {
// 获取当前选中的节点
const checkedNodes = treeRef.value.getCheckedNodes()
// 只保留菜单类型C的节点排除目录M和按钮F
selectedFunctions.value = checkedNodes.filter(node => node.menuType === 'C')
}
// 移除已选择的功能
const removeSelected = (menuId) => {
// 从树中取消勾选该节点
treeRef.value.setChecked(menuId, false, false)
// 更新已选择的功能列表
selectedFunctions.value = selectedFunctions.value.filter(item => item.menuId !== menuId)
}
// 获取默认选择项(前几个菜单项)
const getDefaultSelections = (nodes, count = 8) => {
const selections = []
const traverse = (items) => {
for (const item of items) {
if (selections.length >= count) break
if (item.menuType === 'C') { // 只选择菜单类型C不选择目录M或按钮F
selections.push(item.menuId)
}
if (item.children && item.children.length > 0) {
traverse(item.children)
}
}
}
traverse(nodes)
return selections.slice(0, count)
}
// 过滤节点方法
const filterNode = (value, data) => {
if (!value) return true
return data.menuName.indexOf(value) !== -1
}
// 加载已保存的配置
const loadSavedConfig = async () => {
try {
// 优先从数据库获取已保存的配置
const response = await getCurrentUserConfig('homeFeaturesConfig')
let savedConfig = null;
if (response.code === 200) {
savedConfig = response.data; // 从数据库获取的配置
}
// 如果数据库中没有配置,尝试从本地存储获取
if (!savedConfig) {
savedConfig = localStorage.getItem('homeFeaturesConfig')
}
if (savedConfig) {
// 尝试解码配置值如果是URL编码的
let parsedConfig;
try {
// 先尝试解码
const decodedConfig = decodeURIComponent(savedConfig);
parsedConfig = JSON.parse(decodedConfig);
} catch (e) {
// 如果解码失败,尝试直接解析(兼容旧数据)
try {
parsedConfig = JSON.parse(savedConfig);
} catch (e2) {
console.error('解析配置失败:', e2);
parsedConfig = null;
}
}
if (parsedConfig && Array.isArray(parsedConfig)) {
// 检查数据格式如果是包含对象的数组新格式提取菜单ID
if (parsedConfig.length > 0 && typeof parsedConfig[0] === 'object' && parsedConfig[0].hasOwnProperty('menuId')) {
// 新格式:[{menuId: 1, fullPath: "...", ...}, ...]
checkedKeys.value = parsedConfig.map(item => item.menuId);
} else {
// 旧格式:[1, 2, 3, ...]
checkedKeys.value = parsedConfig;
}
// 根据保存的配置初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
checkedKeys.value.includes(node.menuId) && node.menuType === 'C'
)
selectedFunctions.value = checkedNodes
} else {
// 如果解析失败,使用默认配置
const defaultSelections = getDefaultSelections(menuTree.value)
checkedKeys.value = defaultSelections
// 初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
defaultSelections.includes(node.menuId) && node.menuType === 'C'
)
selectedFunctions.value = checkedNodes
}
} else {
// 如果没有配置,使用默认配置
const defaultSelections = getDefaultSelections(menuTree.value)
checkedKeys.value = defaultSelections
// 初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
defaultSelections.includes(node.menuId) && node.menuType === 'C'
)
selectedFunctions.value = checkedNodes
}
} catch (error) {
console.error('加载配置失败:', error)
// 如果加载失败,使用默认配置
const defaultSelections = getDefaultSelections(menuTree.value)
checkedKeys.value = defaultSelections
// 初始化已选择的功能
const allNodes = getAllNodes(menuTree.value)
const checkedNodes = allNodes.filter(node =>
defaultSelections.includes(node.menuId)
)
selectedFunctions.value = checkedNodes
}
}
// 获取所有节点(递归)
const getAllNodes = (nodes) => {
if (!nodes || !Array.isArray(nodes)) {
return []
}
let result = []
nodes.forEach(node => {
result.push(node)
if (node.children && Array.isArray(node.children)) {
result = result.concat(getAllNodes(node.children))
}
})
return result
}
onMounted(() => {
loadMenuData()
})
</script>
<style scoped lang="scss">
.config-container {
padding: 20px;
background: #f5f7fa;
min-height: calc(100vh - 120px);
.page-header {
margin-bottom: 20px;
text-align: center;
h2 {
font-size: 24px;
color: #303133;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #909399;
}
}
.config-content {
.config-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.menu-tree {
margin-top: 20px;
}
:deep(.el-card__body) {
padding: 20px 0;
}
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
.tree-node-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* 允许flex项目收缩到其内容的固有尺寸以下 */
}
.node-main {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* 允许收缩 */
}
.menu-label {
margin: 0 8px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-tag-inline {
font-size: 12px;
margin-left: 8px;
flex-shrink: 0; /* 防止路径标签被压缩 */
}
}
}
.config-layout {
display: flex;
gap: 20px;
}
.menu-tree-section {
flex: 1;
}
.tree-card {
min-height: 400px;
}
.selected-functions-section {
width: 400px; /* 增加宽度 */
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 16px;
background-color: #fafafa;
}
.selected-functions-list {
max-height: 500px;
overflow-y: auto;
margin-top: 10px;
}
.selected-function-item {
display: flex;
align-items: center;
padding: 8px;
margin-bottom: 8px;
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.function-info {
display: flex;
align-items: flex-start; /* 改为flex-start以适应多行内容 */
flex: 1;
}
.function-details {
display: flex;
flex-direction: column;
margin: 0 8px;
flex: 1;
}
.function-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.function-path-below {
margin-top: 4px;
font-size: 12px;
align-self: flex-start; /* 让路径标签左对齐 */
}
.no-selected {
text-align: center;
color: #999;
font-style: italic;
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,386 @@
<template>
<div class="features-container">
<div class="page-header">
<h2>快捷功能</h2>
<p>这里展示了您配置的快捷功能模块</p>
</div>
<div v-loading="loading" element-loading-text="正在加载快捷功能...">
<div class="features-grid">
<div
v-for="feature in userFeatures"
:key="feature.menuId"
class="feature-card"
@click="goToFeature(feature.fullPath)"
>
<div class="feature-icon">
<el-icon :size="32" :color="getIconColor(feature)">
<component :is="getIconComponent(feature.icon)" />
</el-icon>
</div>
<div class="feature-title">{{ feature.menuName }}</div>
<div class="feature-path" v-if="feature.fullPath">{{ feature.fullPath }}</div>
<div class="feature-path" v-else-if="feature.path">{{ feature.path }}</div>
<div class="feature-desc">{{ feature.remark || '功能描述未设置' }}</div>
</div>
<div v-if="userFeatures.length === 0 && !loading" class="no-features">
暂无配置的快捷功能请前往 <el-link type="primary" @click="goToConfig">功能配置</el-link> 页面进行设置
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listMenu, getMenuFullPath } from '@/api/system/menu'
import { getCurrentUserConfig } from '@/api/system/userConfig'
import {
Menu,
Grid,
Folder,
Tickets,
Document,
Setting,
User,
Goods,
ChatDotSquare,
Histogram,
Wallet,
OfficeBuilding,
Postcard,
Collection,
VideoPlay,
Camera,
Headset,
Phone,
Message,
ChatLineSquare,
ChatRound,
Guide,
Help,
InfoFilled,
CircleCheck,
CircleClose,
Warning,
QuestionFilled,
Star,
Link,
Position,
Picture,
Upload,
Download,
CaretLeft,
CaretRight,
More,
Close,
Check,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Plus,
Minus,
ZoomIn,
ZoomOut,
Refresh,
Search,
Edit,
Delete,
Share,
View,
SwitchButton,
Hide,
Finished,
CirclePlus,
Remove,
CircleCheckFilled,
CircleCloseFilled,
WarningFilled,
InfoFilled as InfoFilledIcon,
SuccessFilled,
QuestionFilled as QuestionFilledIcon
} from '@element-plus/icons-vue'
// 添加 loading 状态
const loading = ref(false)
const router = useRouter()
const userFeatures = ref([])
// 图标映射
const iconMap = {
'menu': Menu,
'grid': Grid,
'folder': Folder,
'tickets': Tickets,
'document': Document,
'setting': Setting,
'user': User,
'goods': Goods,
'chat-dot-square': ChatDotSquare,
'histogram': Histogram,
'wallet': Wallet,
'office-building': OfficeBuilding,
'postcard': Postcard,
'collection': Collection,
'video-play': VideoPlay,
'camera': Camera,
'headset': Headset,
'phone': Phone,
'message': Message,
'chat-line-square': ChatLineSquare,
'chat-round': ChatRound,
'guide': Guide,
'help': Help,
'info-filled': InfoFilled,
'circle-check': CircleCheck,
'circle-close': CircleClose,
'warning': Warning,
'question-filled': QuestionFilled,
'star': Star,
'link': Link,
'position': Position,
'picture': Picture,
'upload': Upload,
'download': Download,
'caret-left': CaretLeft,
'caret-right': CaretRight,
'more': More,
'close': Close,
'check': Check,
'arrow-up': ArrowUp,
'arrow-down': ArrowDown,
'arrow-left': ArrowLeft,
'arrow-right': ArrowRight,
'plus': Plus,
'minus': Minus,
'zoom-in': ZoomIn,
'zoom-out': ZoomOut,
'refresh': Refresh,
'search': Search,
'edit': Edit,
'delete': Delete,
'share': Share,
'view': View,
'switch-button': SwitchButton,
'hide': Hide,
'finished': Finished,
'circle-plus': CirclePlus,
'remove': Remove,
'circle-check-filled': CircleCheckFilled,
'circle-close-filled': CircleCloseFilled,
'warning-filled': WarningFilled,
'info-filled-icon': InfoFilledIcon,
'success-filled': SuccessFilled,
'question-filled-icon': QuestionFilledIcon
}
// 获取图标组件
const getIconComponent = (iconName) => {
if (!iconName) return Document
// 移除前缀,如 fa-, el-icon-
const cleanIconName = iconName.replace(/^(fa-|el-icon-)/, '').toLowerCase()
return iconMap[cleanIconName] || Document
}
// 获取图标颜色
const getIconColor = (data) => {
if (data.menuType === 'M') return '#409EFF' // 目录蓝色
if (data.menuType === 'C') return '#67C23A' // 菜单绿色
if (data.menuType === 'F') return '#E6A23C' // 按钮橙色
return '#909399' // 默认灰色
}
// 加载用户配置的快捷功能
const loadUserFeatures = async () => {
loading.value = true;
try {
// 获取用户配置的快捷功能数据
const configResponse = await getCurrentUserConfig('homeFeaturesConfig')
let menuDataWithPaths = [];
if (configResponse.code === 200 && configResponse.data) {
// 解析配置数据
try {
const decodedConfig = decodeURIComponent(configResponse.data);
menuDataWithPaths = JSON.parse(decodedConfig);
} catch (e) {
// 如果解码失败,尝试直接解析
try {
menuDataWithPaths = JSON.parse(configResponse.data);
} catch (e2) {
console.error('解析用户配置失败:', e2);
menuDataWithPaths = [];
}
}
} else {
// 如果没有配置,从本地存储获取
const localConfig = localStorage.getItem('homeFeaturesConfig');
if (localConfig) {
menuDataWithPaths = JSON.parse(localConfig);
}
}
// 直接使用保存的完整路径数据无需再次调用API
userFeatures.value = menuDataWithPaths;
} catch (error) {
console.error('加载用户快捷功能失败:', error)
} finally {
loading.value = false;
}
}
// 将树形菜单结构扁平化
const flattenMenuTree = (menuTree) => {
const result = [];
const traverse = (items) => {
for (const item of items) {
result.push(item);
if (item.children && item.children.length > 0) {
traverse(item.children);
}
}
};
traverse(menuTree);
return result;
}
// 跳转到功能页面
const goToFeature = (path) => {
if (path) {
// 检查是否为外部链接
if (path.startsWith('http://') || path.startsWith('https://')) {
// 如果是外部链接,使用 window.open 打开
window.open(path, '_blank');
} else {
// 确保内部路径以 / 开头,以保证正确的路由跳转
const normalizedPath = path.startsWith('/') ? path : '/' + path;
router.push(normalizedPath);
}
}
}
// 跳转到配置页面
const goToConfig = () => {
router.push('/features/config')
}
onMounted(() => {
loadUserFeatures()
})
</script>
<style scoped lang="scss">
.features-container {
padding: 20px;
background: #f5f7fa;
min-height: calc(100vh - 120px);
.page-header {
margin-bottom: 30px;
text-align: center;
h2 {
font-size: 24px;
color: #303133;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #909399;
}
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
.feature-card {
background: white;
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #ebeef5;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #c6e2ff;
}
.feature-icon {
margin-bottom: 16px;
}
.feature-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.feature-path {
font-size: 12px;
color: #409EFF;
margin-bottom: 8px;
word-break: break-all;
padding: 2px 8px;
background-color: #ecf5ff;
border-radius: 4px;
display: inline-block;
}
.feature-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
}
.no-features {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: #909399;
font-size: 16px;
.el-link {
font-size: inherit;
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.features-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
.feature-card {
padding: 16px;
.feature-title {
font-size: 14px;
}
.feature-path {
font-size: 10px;
padding: 2px 4px;
}
.feature-desc {
font-size: 11px;
}
}
}
}
</style>