first commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<div class="not-found-big">404</div>
|
||||
<div class="not-found-message">页面未找到</div>
|
||||
<div class="not-found-desc">很抱歉,您访问的页面不存在或已被删除。</div>
|
||||
<router-link to="/" class="back-home-btn">返回首页</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 这里可以根据需要添加自定义逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(120deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
background: #fff;
|
||||
padding: 60px 48px 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||
text-align: center;
|
||||
animation: float-in 0.8s cubic-bezier(.7,.13,.45,.81) both;
|
||||
}
|
||||
|
||||
.not-found-big {
|
||||
font-size: 100px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.not-found-message {
|
||||
font-size: 28px;
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.not-found-desc {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.back-home-btn {
|
||||
display: inline-block;
|
||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
color: #fff;
|
||||
padding: 10px 36px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(252,92,125,0.07);
|
||||
}
|
||||
|
||||
.back-home-btn:hover {
|
||||
background: linear-gradient(-90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
}
|
||||
|
||||
@keyframes float-in {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,734 @@
|
||||
<script setup>
|
||||
import CommonAside from '@/components/CommonAside.vue';
|
||||
import CommonHeader from '@/components/CommonHeader.vue';
|
||||
import { useTabsStore } from '@/stores';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { ref, watch, reactive, nextTick, onMounted, computed } from 'vue';
|
||||
import { More, Close, CircleClose, ArrowUp } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const tabsStore = useTabsStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const defaultDashboardPath = '/dashboard';
|
||||
|
||||
// 根据当前路由恢复 tab(刷新时使用)
|
||||
function restoreTabFromRoute() {
|
||||
const currentPath = route.fullPath;
|
||||
const currentName = route.name;
|
||||
const currentMeta = route.meta || {};
|
||||
|
||||
// 如果当前路径不在 tabList 中,添加它
|
||||
const existTab = tabsStore.tabList.find(t => t.fullPath === currentPath);
|
||||
if (!existTab) {
|
||||
// 从路由 meta 获取标题,如果没有则使用路由 name 或路径
|
||||
const title = currentMeta.title || currentName || '页面';
|
||||
// 使用 addTab 会自动保存到 localStorage
|
||||
tabsStore.addTab({
|
||||
title: title,
|
||||
fullPath: currentPath,
|
||||
name: currentName || title,
|
||||
icon: currentMeta.icon
|
||||
});
|
||||
} else {
|
||||
// 如果已存在,只激活它(不触发路由跳转)
|
||||
tabsStore.setActiveTab(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载后,根据当前路由恢复 tab
|
||||
onMounted(() => {
|
||||
// 等待路由完全加载后再恢复
|
||||
nextTick(() => {
|
||||
// 刷新时,localStorage 中已保存的 tabs 会在 store 初始化时恢复
|
||||
// 这里只需要确保当前路由对应的 tab 存在并激活
|
||||
restoreTabFromRoute();
|
||||
|
||||
// 为 tabs 容器添加右键事件监听(使用事件委托)
|
||||
const tabsWrapper = document.querySelector('.multi-tabs-wrapper');
|
||||
if (tabsWrapper) {
|
||||
tabsWrapper.addEventListener('contextmenu', (e) => {
|
||||
// 排除编辑器相关区域,避免影响编辑器功能
|
||||
// 如果点击在编辑器区域内,完全不做任何处理(优先检查)
|
||||
const editorWrapper = e.target.closest?.('.wang-editor-wrapper');
|
||||
if (editorWrapper) {
|
||||
// 在编辑器区域内,不处理右键菜单,也不阻止事件
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查其他编辑器元素(工具栏、下拉面板等)
|
||||
const editorElements = [
|
||||
'.w-e-toolbar',
|
||||
'.w-e-drop-panel',
|
||||
'.w-e-modal',
|
||||
'.w-e-toolbar-menu',
|
||||
'[data-menu-key]',
|
||||
'[class*="w-e-"]'
|
||||
];
|
||||
|
||||
for (const selector of editorElements) {
|
||||
if (e.target.closest && e.target.closest(selector)) {
|
||||
// 在编辑器元素内,不处理右键菜单
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在非编辑器区域的 tab item 上才处理右键菜单
|
||||
const tabItem = e.target.closest('.el-tabs__item');
|
||||
if (tabItem) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleTabsContextMenu(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听路由变化,自动添加/激活对应的 tab
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(newPath) => {
|
||||
if (newPath) {
|
||||
restoreTabFromRoute();
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
// 1. 侧栏菜单点击:加入/激活Tab并切换路由
|
||||
const handleAsideMenuClick = async (menuItem) => {
|
||||
const targetPath = menuItem.path;
|
||||
|
||||
// 先添加tab
|
||||
tabsStore.addTab({
|
||||
title: menuItem.title,
|
||||
fullPath: targetPath,
|
||||
name: menuItem.title,
|
||||
icon: menuItem.icon
|
||||
});
|
||||
|
||||
// 如果当前路由已经是目标路由,不需要跳转
|
||||
if (router.currentRoute.value.fullPath === targetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查路由是否存在,如果不存在等待一下再尝试
|
||||
let routeExists = router.resolve(targetPath).matched.length > 0;
|
||||
|
||||
if (!routeExists) {
|
||||
// 等待路由加载(最多等待500ms)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
routeExists = router.resolve(targetPath).matched.length > 0;
|
||||
if (routeExists) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果路由存在,直接跳转
|
||||
if (routeExists) {
|
||||
router.push(targetPath).catch(err => {
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
console.error('路由跳转失败:', err);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果路由不存在,尝试刷新页面(最后的手段)
|
||||
console.warn('路由不存在,尝试刷新页面:', targetPath);
|
||||
// 不刷新页面,而是显示错误提示
|
||||
ElMessage.warning(`路由 ${targetPath} 不存在,请刷新缓存后重试`);
|
||||
}
|
||||
};
|
||||
const tabsCloseTab = (targetKey) => {
|
||||
tabsStore.removeTab(targetKey);
|
||||
if (route.fullPath !== tabsStore.activeTab) {
|
||||
router.push(tabsStore.activeTab);
|
||||
}
|
||||
};
|
||||
const closeOthers = () => {
|
||||
tabsStore.closeOthers();
|
||||
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
|
||||
router.push(tabsStore.activeTab);
|
||||
}
|
||||
};
|
||||
const closeAll = () => {
|
||||
tabsStore.closeAll();
|
||||
router.push(defaultDashboardPath);
|
||||
};
|
||||
// 主动监听tab激活,保证切tab时内容区切换(仅用于tab点击切换,菜单点击由handleAsideMenuClick处理)
|
||||
// 使用 immediate: false 避免初始化时触发,使用 flush: 'post' 确保在 DOM 更新后执行
|
||||
watch(
|
||||
() => tabsStore.activeTab,
|
||||
(newVal, oldVal) => {
|
||||
// 如果新值和当前路由路径不同,且不是初始化(oldVal 不为 undefined),才进行跳转
|
||||
// 注意:这个watch主要用于处理tab点击切换,菜单点击由handleAsideMenuClick直接处理
|
||||
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
|
||||
// 检查路由是否存在
|
||||
const routeExists = router.resolve(newVal).matched.length > 0;
|
||||
|
||||
if (routeExists) {
|
||||
// 路由存在,直接跳转
|
||||
nextTick(() => {
|
||||
router.push(newVal).catch(err => {
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
console.error('路由跳转失败:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 如果路由不存在,等待一下再重试(可能是路由还在加载中)
|
||||
setTimeout(() => {
|
||||
const retryRouteExists = router.resolve(newVal).matched.length > 0;
|
||||
if (retryRouteExists && router.currentRoute.value.fullPath !== newVal) {
|
||||
router.push(newVal).catch(err => {
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
console.error('路由跳转失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
// ========== 右键菜单逻辑 ========== //
|
||||
const contextMenu = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
tab: null,
|
||||
});
|
||||
const contextDropdownRef = ref(null);
|
||||
|
||||
// 处理 tabs 容器的右键事件
|
||||
const handleTabsContextMenu = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// 找到点击的 tab item 元素(el-tabs__item)
|
||||
let target = event.target;
|
||||
let tabItem = null;
|
||||
|
||||
// 向上查找 el-tabs__item 元素
|
||||
while (target && target !== event.currentTarget) {
|
||||
if (target.classList && target.classList.contains('el-tabs__item')) {
|
||||
tabItem = target;
|
||||
break;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
|
||||
if (!tabItem) return;
|
||||
|
||||
// Element Plus 的 tab item 的 id 格式通常是 "tab-{name}",其中 name 是 tab-pane 的 name 属性
|
||||
const tabId = tabItem.id;
|
||||
if (tabId && tabId.startsWith('tab-')) {
|
||||
const tabName = tabId.replace('tab-', '');
|
||||
const matchedTab = tabsStore.tabList.find(t => t.fullPath === tabName);
|
||||
if (matchedTab) {
|
||||
showContextMenu(event, matchedTab);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果通过 id 找不到,尝试通过 aria-controls 或其他属性
|
||||
const ariaControls = tabItem.getAttribute('aria-controls');
|
||||
if (ariaControls) {
|
||||
const tabName = ariaControls.replace('pane-', '');
|
||||
const matchedTab = tabsStore.tabList.find(t => t.fullPath === tabName);
|
||||
if (matchedTab) {
|
||||
showContextMenu(event, matchedTab);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 显示右键菜单
|
||||
const showContextMenu = (event, tab) => {
|
||||
// 使用 nextTick 确保在下一个事件循环中更新状态,避免在渲染过程中触发更新
|
||||
nextTick(() => {
|
||||
contextMenu.visible = true;
|
||||
contextMenu.x = event.clientX;
|
||||
contextMenu.y = event.clientY;
|
||||
contextMenu.tab = tab;
|
||||
|
||||
// 延迟添加事件监听器,确保菜单已渲染
|
||||
// 关键:完全排除编辑器区域,确保编辑器的事件不被干扰
|
||||
setTimeout(() => {
|
||||
const hideMenuHandler = (e) => {
|
||||
// 如果右键菜单已经隐藏,直接返回
|
||||
if (!contextMenu.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target;
|
||||
if (!target) {
|
||||
hideContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查点击是否在右键菜单本身上
|
||||
if (target.closest?.('.context-menu')) {
|
||||
return; // 点击在菜单上,不隐藏
|
||||
}
|
||||
|
||||
// ========== 关键修复:优先检查编辑器区域 ==========
|
||||
// 如果在编辑器区域内,立即返回,不执行任何操作,让编辑器的事件正常处理
|
||||
|
||||
// 先检查是否在编辑器包装器内(最快判断)
|
||||
const wangEditorWrapper = target.closest?.('.wang-editor-wrapper');
|
||||
if (wangEditorWrapper) {
|
||||
// 在编辑器包装器内,完全不做任何处理,让编辑器正常处理
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查所有可能的编辑器元素(包括工具栏、下拉面板、模态框等)
|
||||
const editorSelectors = [
|
||||
'.w-e-toolbar',
|
||||
'.w-e-drop-panel',
|
||||
'.w-e-modal',
|
||||
'.w-e-toolbar-menu',
|
||||
'.toolbar-container',
|
||||
'.editor-container',
|
||||
'.w-e-text-container',
|
||||
'.w-e-text',
|
||||
// 检查是否有 WangEditor 相关的元素
|
||||
'[data-menu-key]',
|
||||
'[class*="w-e-"]'
|
||||
];
|
||||
|
||||
for (const selector of editorSelectors) {
|
||||
if (target.closest?.(selector)) {
|
||||
// 在编辑器区域内,完全不做任何处理,让编辑器的事件正常处理
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 其他区域点击,隐藏菜单
|
||||
hideContextMenu();
|
||||
};
|
||||
|
||||
// 关键修复:使用 capture: false 在冒泡阶段处理,并且延迟注册
|
||||
// 这样可以确保编辑器的监听器(通常在目标元素上)先处理事件
|
||||
// 然后再处理我们的监听器(在 body 上)
|
||||
// 注意:只有在右键菜单显示时才注册,并且排除编辑器区域
|
||||
setTimeout(() => {
|
||||
// 再次检查右键菜单是否仍然可见
|
||||
if (contextMenu.visible) {
|
||||
document.body.addEventListener('click', hideMenuHandler, { once: true, capture: false, passive: true });
|
||||
}
|
||||
}, 100); // 延迟注册,确保编辑器的事件监听器已经注册并可以正常处理
|
||||
|
||||
const hideContextMenuHandler = (e) => {
|
||||
const target = e.target;
|
||||
if (!target) {
|
||||
hideContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
// 排除编辑器区域和右键菜单本身
|
||||
if (target.closest?.('.w-e-toolbar') || target.closest?.('.context-menu') || target.closest?.('.wang-editor-wrapper')) {
|
||||
return;
|
||||
}
|
||||
hideContextMenu();
|
||||
};
|
||||
|
||||
document.body.addEventListener('contextmenu', hideContextMenuHandler, { once: true });
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
function hideContextMenu() {
|
||||
contextMenu.visible = false;
|
||||
contextMenu.tab = null;
|
||||
}
|
||||
function closeTabContextTab() {
|
||||
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath) {
|
||||
tabsStore.removeTab(contextMenu.tab.fullPath);
|
||||
}
|
||||
hideContextMenu();
|
||||
}
|
||||
// 关闭左侧
|
||||
function closeLeftContextTab() {
|
||||
if (contextMenu.tab) {
|
||||
tabsStore.closeLeft(contextMenu.tab.fullPath);
|
||||
// 如果当前路由对应的tab被关闭了,跳转到激活的tab
|
||||
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
|
||||
router.push(tabsStore.activeTab);
|
||||
}
|
||||
}
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
// 关闭右侧
|
||||
function closeRightContextTab() {
|
||||
if (contextMenu.tab) {
|
||||
tabsStore.closeRight(contextMenu.tab.fullPath);
|
||||
// 如果当前路由对应的tab被关闭了,跳转到激活的tab
|
||||
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
|
||||
router.push(tabsStore.activeTab);
|
||||
}
|
||||
}
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
// 关闭其他
|
||||
function closeOthersContextTab() {
|
||||
if (contextMenu.tab) {
|
||||
tabsStore.setActiveTab(contextMenu.tab.fullPath);
|
||||
tabsStore.closeOthers();
|
||||
if (!tabsStore.tabList.find(tab => tab.fullPath === route.fullPath)) {
|
||||
router.push(tabsStore.activeTab);
|
||||
}
|
||||
}
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
// 关闭全部
|
||||
function closeAllTabs() {
|
||||
tabsStore.closeAll();
|
||||
hideContextMenu();
|
||||
router.push(defaultDashboardPath);
|
||||
}
|
||||
|
||||
// 计算是否可以关闭左侧/右侧
|
||||
const canCloseLeft = computed(() => {
|
||||
if (!contextMenu.tab) return false;
|
||||
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
|
||||
// 至少左侧有一个可关闭的tab(排除首页)
|
||||
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath);
|
||||
});
|
||||
|
||||
const canCloseRight = computed(() => {
|
||||
if (!contextMenu.tab) return false;
|
||||
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
|
||||
// 右侧至少有一个tab
|
||||
return targetIndex < tabsStore.tabList.length - 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="common-layout">
|
||||
<el-container class="main-container">
|
||||
<common-aside @menu-click="handleAsideMenuClick" />
|
||||
<el-container>
|
||||
<el-header class="main-header">
|
||||
<common-header />
|
||||
</el-header>
|
||||
<el-main class="right-main">
|
||||
<div class="multi-tabs-wrapper">
|
||||
<el-tabs
|
||||
v-model="tabsStore.activeTab"
|
||||
type="card"
|
||||
class="multi-tabs"
|
||||
closable
|
||||
@tab-remove="tabsCloseTab"
|
||||
>
|
||||
<el-tab-pane
|
||||
v-for="tab in tabsStore.tabList"
|
||||
:key="tab.fullPath"
|
||||
:label="tab.title"
|
||||
:name="tab.fullPath"
|
||||
:closable="tab.fullPath !== defaultDashboardPath"
|
||||
:data-tab-path="tab.fullPath"
|
||||
/>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
left: contextMenu.x + 'px',
|
||||
top: contextMenu.y + 'px'
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<div class="context-menu-item"
|
||||
:class="{ 'is-disabled': contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath }"
|
||||
@click="!((contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath)) && closeTabContextTab()">
|
||||
关闭
|
||||
</div>
|
||||
<div class="context-menu-item"
|
||||
:class="{ 'is-disabled': !canCloseLeft }"
|
||||
@click="canCloseLeft && closeLeftContextTab()">
|
||||
关闭左侧
|
||||
</div>
|
||||
<div class="context-menu-item"
|
||||
:class="{ 'is-disabled': !canCloseRight }"
|
||||
@click="canCloseRight && closeRightContextTab()">
|
||||
关闭右侧
|
||||
</div>
|
||||
<div class="context-menu-item"
|
||||
:class="{ 'is-disabled': contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath && tabsStore.tabList.length <= 1 }"
|
||||
@click="!((contextMenu.tab && contextMenu.tab.fullPath === defaultDashboardPath && tabsStore.tabList.length <= 1)) && closeOthersContextTab()">
|
||||
关闭其他
|
||||
</div>
|
||||
<div class="context-menu-item"
|
||||
:class="{ 'is-disabled': tabsStore.tabList.length <= 1 }"
|
||||
@click="tabsStore.tabList.length > 1 && closeAllTabs()">
|
||||
关闭全部
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
<!-- 右侧操作按钮 -->
|
||||
<div class="tabs-extra-actions">
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link size="small" class="extra-btn">
|
||||
<el-icon><More /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="closeOthers">
|
||||
关闭其他
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="closeAll">
|
||||
关闭全部
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容毛玻璃卡片容器 -->
|
||||
<div class="main-panel glass-card">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="10">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 回到顶部按钮 -->
|
||||
<el-backtop :target="'.right-main'" :visibility-height="300" :right="30" :bottom="50">
|
||||
<div class="backtop-button">
|
||||
<el-icon :size="20">
|
||||
<ArrowUp />
|
||||
</el-icon>
|
||||
</div>
|
||||
</el-backtop>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.common-layout, .main-container {
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
background-color: var(--bg-color-page);
|
||||
transition: background-color 0.3s ease;
|
||||
:deep(.el-aside) {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
height: 100vh;
|
||||
}
|
||||
.main-header {
|
||||
background-color: var(--header-bg-color, #0081ff);
|
||||
transition: background-color 0.3s ease;
|
||||
height: 80px;
|
||||
padding: 0;
|
||||
}
|
||||
.right-main {
|
||||
background-color: var(--el-bg-color-page);
|
||||
color: var(--el-text-color-primary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.multi-tabs-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.multi-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap) {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--el-border-color) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color);
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-border-color-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
margin-right: 8px;
|
||||
padding: 8px 16px;
|
||||
height: 36px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-lighter);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--el-color-primary);
|
||||
border-color: #4f84ff;
|
||||
background: #4f84ff;
|
||||
color: #fff;
|
||||
|
||||
.el-icon-close {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-icon-close {
|
||||
margin-left: 8px;
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tabs-extra-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.extra-btn {
|
||||
padding: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu){
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
// 回到顶部按钮样式
|
||||
.backtop-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(64, 129, 255, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-primary-light-3);
|
||||
box-shadow: 0 6px 16px rgba(64, 129, 255, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 右键菜单样式 - 全局样式,因为使用了 teleport 到 body
|
||||
.context-menu {
|
||||
position: fixed !important;
|
||||
z-index: 9999 !important;
|
||||
background: var(--el-bg-color-overlay) !important;
|
||||
border: 1px solid var(--el-border-color-lighter) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1) !important;
|
||||
min-width: 120px !important;
|
||||
padding: 4px 0 !important;
|
||||
pointer-events: auto !important; // 确保菜单本身可以接收点击
|
||||
|
||||
.context-menu-item {
|
||||
padding: 8px 16px !important;
|
||||
cursor: pointer !important;
|
||||
color: var(--el-text-color-primary) !important;
|
||||
font-size: 14px !important;
|
||||
transition: background-color 0.2s !important;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
background-color: var(--el-fill-color-light) !important;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保编辑器工具栏和下拉面板的 z-index 高于右键菜单
|
||||
:deep(.w-e-toolbar),
|
||||
:deep(.w-e-drop-panel),
|
||||
:deep(.w-e-modal),
|
||||
:deep(.w-e-toolbar-menu) {
|
||||
z-index: 10000 !important; // 高于右键菜单的 9999
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="category-manager">
|
||||
<div class="category-list" v-loading="loading">
|
||||
<div v-if="filteredCategories.length === 0" class="empty-state"></div>
|
||||
|
||||
<div v-else class="category-tree">
|
||||
<category-node
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.id"
|
||||
:item="category"
|
||||
:level="0"
|
||||
@edit="handleEdit"
|
||||
@add-child="handleAddChild"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<edit-cate
|
||||
v-model="dialogVisible"
|
||||
:category="currentEdit"
|
||||
@saved="fetchCategories"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Delete,
|
||||
Check,
|
||||
Close,
|
||||
Search,
|
||||
Refresh,
|
||||
Document,
|
||||
Picture,
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { allCategories, deleteCategory } from "@/api/article";
|
||||
import EditCate from "@/views/apps/cms/articles/components/edit-cate.vue";
|
||||
import CategoryNode from "./components/CategoryNode.vue";
|
||||
|
||||
// 颜色预设
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const currentEdit = ref(null);
|
||||
const searchText = ref("");
|
||||
const categories = ref([]);
|
||||
|
||||
// 计算属性
|
||||
const totalCount = computed(() => categories.value.length);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return categories.value;
|
||||
}
|
||||
|
||||
const search = searchText.value.toLowerCase();
|
||||
return categories.value.filter(
|
||||
(category) =>
|
||||
category.label.toLowerCase().includes(search) ||
|
||||
(category.remark && category.remark.toLowerCase().includes(search)),
|
||||
);
|
||||
});
|
||||
|
||||
// 工具函数
|
||||
function buildTree(data) {
|
||||
// 1. 标准化数据字段
|
||||
const transformed = data.map((item) => ({
|
||||
...item,
|
||||
label: item.name ?? item.label ?? "",
|
||||
remark: item.desc ?? item.remark ?? "",
|
||||
parentId: item.parentId ?? item.cid ?? 0,
|
||||
children: [],
|
||||
expanded: true, // 默认展开所有级
|
||||
}));
|
||||
|
||||
const map = new Map();
|
||||
transformed.forEach((item) => map.set(item.id, item));
|
||||
|
||||
const roots = [];
|
||||
transformed.forEach((item) => {
|
||||
const parent = map.get(item.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(item);
|
||||
} else {
|
||||
// 如果没有父节点,或者是顶级节点 (parentId 为 0)
|
||||
roots.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
function handleCreate() {
|
||||
currentEdit.value = null;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(category) {
|
||||
currentEdit.value = category;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleAddChild(parent) {
|
||||
currentEdit.value = { parentId: parent.id };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
searchText.value = "";
|
||||
fetchCategories();
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await fetchCategories();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchText.value = "";
|
||||
fetchCategories();
|
||||
}
|
||||
|
||||
function toggleExpand(category) {
|
||||
category.expanded = !category.expanded;
|
||||
}
|
||||
|
||||
function handleDelete(category) {
|
||||
ElMessageBox.confirm(`确定要删除分类\"${category.label}\"吗?`, "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteCategory(category.id)
|
||||
.then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
fetchCategories();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("删除分类失败:", error);
|
||||
ElMessage.error("删除失败");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// API调用
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await allCategories({ keyword: searchText.value });
|
||||
if (response.code === 200) {
|
||||
const categoryList = response.data || [];
|
||||
// 这里会递归生成三级、四级等
|
||||
categories.value = buildTree(categoryList);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.category-manager {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
|
||||
// 页面头部样式
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
background: var(--el-bg-color);
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.page-title {
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
.el-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索栏样式
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
background: var(--el-bg-color);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.el-input {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.search-stats {
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 分类列表容器
|
||||
.category-list {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
|
||||
.empty-state {
|
||||
padding: 80px 40px;
|
||||
.empty-icon {
|
||||
color: #c9cdd4;
|
||||
}
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 递归树结构核心样式
|
||||
.category-tree {
|
||||
.category-item {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
&.disabled {
|
||||
.category-main {
|
||||
opacity: 0.6;
|
||||
background-color: var(--el-fill-color-lightest);
|
||||
}
|
||||
}
|
||||
|
||||
// 层级背景区分 (可选:让子级背景稍微深一点点)
|
||||
&.child-item {
|
||||
.category-main {
|
||||
background-color: rgba(245, 247, 250, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.category-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 24px; // 左右 padding 保持,左侧缩进通过内联 style 控制
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light) !important;
|
||||
}
|
||||
|
||||
// 展开收起按钮占位
|
||||
.expand-btn,
|
||||
.expand-spacer {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
.expand-button {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分类具体内容
|
||||
.category-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.category-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.category-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.status-tag {
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
.category-desc {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮组
|
||||
.category-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
opacity: 0.4; // 默认低透明度,鼠标悬浮时高亮
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.el-button-group .el-button {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
&.danger:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标悬停时显示按钮
|
||||
&:hover .category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 子分类容器样式
|
||||
.category-children {
|
||||
margin-left: 0;
|
||||
|
||||
.category-node-wrapper {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Element Plus 对话框深度样式美化
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 12px;
|
||||
.el-dialog__header {
|
||||
padding: 20px 24px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding: 24px;
|
||||
}
|
||||
.el-dialog__footer {
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.el-input__inner,
|
||||
.el-textarea__inner {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.category-manager {
|
||||
padding: 12px;
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
.page-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.category-list .category-tree .category-item .category-main {
|
||||
padding: 12px !important; // 移动端取消大缩进,改用其他视觉暗示
|
||||
.category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.category-desc {
|
||||
display: none;
|
||||
} // 隐藏描述节省空间
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="category-node-wrapper">
|
||||
<div
|
||||
class="category-item"
|
||||
:class="{ 'child-item': level > 0, disabled: !item.status }"
|
||||
>
|
||||
<div
|
||||
class="category-main"
|
||||
:style="{ paddingLeft: (level * 24 + 20) + 'px' }"
|
||||
@click="handleRowClick"
|
||||
>
|
||||
<div class="expand-btn" v-if="item.children && item.children.length > 0">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="toggleExpand"
|
||||
class="expand-button"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="item.expanded ? 'ArrowDown' : 'ArrowRight'" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="expand-spacer" v-else></div>
|
||||
|
||||
<div class="category-info">
|
||||
<div class="color-dot" :style="{ backgroundColor: item.color }"></div>
|
||||
<div class="category-icon">
|
||||
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
|
||||
<el-icon v-else class="default-icon"><Document /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="category-text">
|
||||
<div class="category-title">
|
||||
<span class="name">{{ item.label }}</span>
|
||||
<el-tag
|
||||
:type="item.status ? 'success' : 'danger'"
|
||||
size="small"
|
||||
class="status-tag"
|
||||
>
|
||||
{{ item.status ? "启用" : "禁用" }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="category-desc" v-if="item.remark">{{ item.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-actions">
|
||||
<el-button-group size="small">
|
||||
<el-button type="text" title="编辑" @click.stop="$emit('edit', item)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="text" title="添加子分类" @click.stop="$emit('add-child', item)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="text" class="danger" title="删除" @click.stop="$emit('delete', item)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.children && item.children.length > 0 && item.expanded"
|
||||
class="category-children"
|
||||
>
|
||||
<category-node
|
||||
v-for="child in item.children"
|
||||
:key="child.id"
|
||||
:item="child"
|
||||
:level="level + 1"
|
||||
@edit="$emit('edit', $event)"
|
||||
@add-child="$emit('add-child', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowDown, ArrowRight, Edit, Plus, Delete, Document } from "@element-plus/icons-vue";
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
level: { type: Number, default: 0 }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'add-child', 'delete']);
|
||||
|
||||
const toggleExpand = () => {
|
||||
props.item.expanded = !props.item.expanded;
|
||||
};
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (props.item.children && props.item.children.length > 0) {
|
||||
toggleExpand();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.category-node-wrapper {
|
||||
.category-item {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.category-main {
|
||||
opacity: 0.6;
|
||||
background-color: var(--el-fill-color-lightest);
|
||||
}
|
||||
}
|
||||
|
||||
&.child-item {
|
||||
.category-main {
|
||||
background-color: rgba(245, 247, 250, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.category-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 24px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light) !important;
|
||||
}
|
||||
|
||||
.expand-btn,
|
||||
.expand-spacer {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
.expand-button {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.category-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.category-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.el-button-group .el-button {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
|
||||
&.danger:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-children {
|
||||
.category-node-wrapper {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-node-wrapper {
|
||||
.category-item {
|
||||
.category-main {
|
||||
padding: 12px !important;
|
||||
|
||||
.category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<el-dialog v-model="visibleInternal" :title="dialogTitle" width="600px" :close-on-click-modal="false"
|
||||
@close="closeDialog">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="分类名称" prop="label">
|
||||
<el-input v-model="formData.label" placeholder="请输入分类名称" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="父级分类" prop="parentId">
|
||||
<el-tree-select v-model="formData.parentId" :data="treeData"
|
||||
:props="{ label: 'label', value: 'id', children: 'children' }" placeholder="选择父级分类(可选)" clearable
|
||||
filterable check-strictly style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="999" controls-position="right"
|
||||
style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="默认图片" prop="image">
|
||||
<div class="uploads">
|
||||
<el-upload v-model:file-list="fileList" :auto-upload="false" :before-upload="beforeImgUpload" list-type="picture-card" :limit="1" :on-change="handleUploadChange">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
|
||||
<template #file="{ file }">
|
||||
<div>
|
||||
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
|
||||
<span class="el-upload-list__item-actions">
|
||||
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
|
||||
<el-icon>
|
||||
<ZoomIn />
|
||||
</el-icon>
|
||||
</span>
|
||||
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<el-dialog v-model="dialogVisible">
|
||||
<img w-full :src="dialogImageUrl" alt="Preview Image" />
|
||||
</el-dialog>
|
||||
|
||||
<div class="upload-tip">
|
||||
<span>建议尺寸:250px × 140px</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分类描述" prop="remark">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="4" placeholder="请输入分类描述..." maxlength="200"
|
||||
show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
{{ isEdit ? '更新' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Plus, ZoomIn, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElUpload } from 'element-plus'
|
||||
import { createCategory, editCategory, listCategories } from '@/api/article'
|
||||
import { uploadFile } from '@/api/file.js'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
category: { type: Object as () => any | null, default: null }, // 传入的分类对象,null 表示新增
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
// 可见性同步
|
||||
const visibleInternal = ref(props.modelValue)
|
||||
watch(() => props.modelValue, v => (visibleInternal.value = v))
|
||||
watch(visibleInternal, v => {
|
||||
emit('update:modelValue', v)
|
||||
if (v) loadTreeData()
|
||||
})
|
||||
|
||||
// 数据加载:父级分类树
|
||||
const treeData = ref<any[]>([])
|
||||
watch(treeData, () => {
|
||||
// 触发tree-select重新渲染已选 label
|
||||
formData.parentId = formData.parentId ?? 0
|
||||
})
|
||||
|
||||
async function loadTreeData() {
|
||||
const res = await listCategories({ page: 1, pageSize: 1000 })
|
||||
if (res.code === 200) {
|
||||
const list = res.data?.records || res.data || []
|
||||
treeData.value = buildTree(list)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建树形
|
||||
function buildTree(data: any[]) {
|
||||
const map = new Map()
|
||||
const roots: any[] = []
|
||||
data.forEach((item) => {
|
||||
map.set(item.id, { ...item, label: item.name ?? item.label, children: [] })
|
||||
})
|
||||
map.forEach((item: any) => {
|
||||
if (item.cid && map.has(item.cid)) {
|
||||
map.get(item.cid).children.push(item)
|
||||
} else {
|
||||
roots.push(item)
|
||||
}
|
||||
})
|
||||
return [{ id: 0, label: '顶级', children: roots }]
|
||||
}
|
||||
|
||||
// 表单
|
||||
const formRef = ref()
|
||||
const formData = reactive({
|
||||
id: null as number | null,
|
||||
label: '',
|
||||
image: '',
|
||||
remark: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
parentId: 0 as number,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
if (formRef.value) formRef.value.clearValidate()
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
label: '',
|
||||
image: '',
|
||||
remark: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
parentId: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 当传入 category 变化时同步
|
||||
watch(
|
||||
() => props.category,
|
||||
(val) => {
|
||||
if (val) {
|
||||
Object.assign(formData, {
|
||||
id: val.id,
|
||||
label: val.label,
|
||||
image: val.image,
|
||||
remark: val.remark,
|
||||
sort: val.sort,
|
||||
status: val.status,
|
||||
parentId: val.parentId ?? val.cid ?? 0,
|
||||
})
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 上传相关
|
||||
const fileList = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogImageUrl = ref('')
|
||||
|
||||
function beforeImgUpload(file: File) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isImage) ElMessage.error('仅支持图片格式')
|
||||
if (!isLt10M) ElMessage.error('图片大小不能超过10MB')
|
||||
return isImage && isLt10M
|
||||
}
|
||||
|
||||
function handleRemove(file: any) {
|
||||
fileList.value = []
|
||||
formData.image = ''
|
||||
}
|
||||
|
||||
async function handleUploadChange(file: any) {
|
||||
if (file.raw) {
|
||||
const isImage = file.raw.type.startsWith('image/')
|
||||
const isLt10M = file.raw.size / 1024 / 1024 < 10
|
||||
if (!isImage || !isLt10M) {
|
||||
fileList.value = []
|
||||
ElMessage.error(isImage ? '图片大小不能超过10MB' : '仅支持图片格式')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePictureCardPreview(file: any) {
|
||||
dialogImageUrl.value = file.url
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 校验
|
||||
const formRules = {
|
||||
label: [
|
||||
{ required: true, message: '请输入分类名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
remark: [{ max: 200, message: '描述不能超过200个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const isEdit = computed(() => !!formData.id)
|
||||
// 预览对话框
|
||||
|
||||
const dialogTitle = computed(() => (isEdit.value ? '编辑分类' : '新增分类'))
|
||||
const submitLoading = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
submitLoading.value = true
|
||||
|
||||
let imageUrl = formData.image
|
||||
|
||||
if (fileList.value.length > 0 && fileList.value[0].raw) {
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', fileList.value[0].raw)
|
||||
uploadFormData.append('cate', 'category')
|
||||
|
||||
const uploadRes = await uploadFile(uploadFormData)
|
||||
if (uploadRes?.data?.url) {
|
||||
imageUrl = uploadRes.data.url
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.label,
|
||||
image: imageUrl,
|
||||
desc: formData.remark,
|
||||
sort: formData.sort,
|
||||
status: formData.status,
|
||||
cid: formData.parentId,
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await editCategory(formData.id, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createCategory(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
emit('saved')
|
||||
closeDialog()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visibleInternal.value = false
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 如果需要额外样式,可在此处编写 */
|
||||
.avatar-uploader {
|
||||
.el-upload {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
font-size: 32px;
|
||||
color: #8c939d;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.uploads{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,477 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="isEdit ? '编辑文章' : '新增文章'" size="60%" :before-close="handleBeforeClose">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入文章标题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类" prop="cate">
|
||||
<el-cascader
|
||||
v-model="form.cate"
|
||||
:options="cateOptions"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
:props="{ expandTrigger: 'hover', emitPath: false }"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="作者" prop="author">
|
||||
<el-input v-model="form.author" placeholder="请输入作者姓名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="简介" prop="desc">
|
||||
<el-input v-model="form.desc" :rows="4" type="textarea" placeholder="请输入简介" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="封面图片" prop="image">
|
||||
<div class="uploads">
|
||||
<!-- 已有图片显示 -->
|
||||
<div v-if="form.image && fileList.length === 0" class="existing-image">
|
||||
<img :src="API_BASE_URL + form.image.replace(/\\\//g, '/')" alt="已有图片" />
|
||||
<div class="image-actions">
|
||||
<el-button type="primary" size="small" @click="previewExistingImage">预览</el-button>
|
||||
<el-button type="danger" size="small" @click="removeExistingImage">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传组件 -->
|
||||
<el-upload v-model:file-list="fileList" :auto-upload="false" :before-upload="beforeImgUpload"
|
||||
list-type="picture-card" :limit="1" :show-file-list="fileList.length > 0">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
|
||||
<template #file="{ file }">
|
||||
<div>
|
||||
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
|
||||
<span class="el-upload-list__item-actions">
|
||||
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
|
||||
<el-icon>
|
||||
<ZoomIn />
|
||||
</el-icon>
|
||||
</span>
|
||||
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<el-drawer v-model="drawerVisible" width="60%" center>
|
||||
<div style="display: flex; justify-content: center;align-items:center;">
|
||||
<img :src="drawerImageUrl" alt="Preview Image"
|
||||
style="max-width: 100%; max-height: 70vh; object-fit: contain;" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<div class="upload-tip">
|
||||
<span>建议尺寸:250px × 140px</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-form-item label="是否转载" prop="is_trans">
|
||||
<el-radio-group v-model="form.is_trans">
|
||||
<el-radio-button :value="0">否</el-radio-button>
|
||||
<el-radio-button :value="1">是</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="发布地址" prop="transurl" v-if="form.is_trans === 1">
|
||||
<el-input v-model="form.transurl" placeholder="请输入转载文章地址" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<div class="editor-container">
|
||||
<WangEditor v-model="form.content" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-divider></el-divider>
|
||||
<span class="drawer-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button v-if="!isEdit" type="warning" @click="handleConfirm(0)" :loading="submitLoading">草稿</el-button>
|
||||
<el-button type="primary" @click="handleConfirm(1)" :loading="submitLoading">提交</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch, nextTick, onMounted, computed } from 'vue';
|
||||
import { ElMessage, ElUpload } from 'element-plus'
|
||||
import WangEditor from '@/views/components/WangEditor.vue';
|
||||
import { createArticle, editArticle, listCategories } from '@/api/article.js';
|
||||
import { uploadFile } from '@/api/file.js';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const formRef = ref(null);
|
||||
|
||||
const handleBeforeClose = (done: () => void) => {
|
||||
handleCancel();
|
||||
done();
|
||||
};
|
||||
|
||||
const cateOptions = ref([]);
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
author: '美天科技',
|
||||
cate: '',
|
||||
content: '',
|
||||
image: '',
|
||||
desc: '',
|
||||
is_trans: 0,
|
||||
transurl: null
|
||||
});
|
||||
|
||||
const rules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入文章标题', trigger: 'blur' },
|
||||
{ min: 2, max: 200, message: '标题长度在 2 到 200 个字符', trigger: 'blur' }
|
||||
],
|
||||
author: [
|
||||
{ required: true, message: '请输入作者', trigger: 'blur' },
|
||||
{ max: 50, message: '作者姓名不能超过50个字符', trigger: 'blur' }
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入文章内容', trigger: 'blur' }
|
||||
],
|
||||
};
|
||||
|
||||
// 获取分类列表
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await listCategories();
|
||||
if (res.code === 200) {
|
||||
const categories = Array.isArray(res.data) ? res.data : [];
|
||||
// 将分类转换为树形结构
|
||||
cateOptions.value = buildCategoryTree(categories);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建分类树形结构
|
||||
function buildCategoryTree(categories, parentCid = 0) {
|
||||
return categories
|
||||
.filter(cate => Number(cate.cid) === Number(parentCid))
|
||||
.map(cate => {
|
||||
const children = buildCategoryTree(categories, cate.id);
|
||||
return {
|
||||
...cate,
|
||||
label: cate.name,
|
||||
value: cate.id,
|
||||
children: children.length > 0 ? children : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 上传相关
|
||||
const fileList = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
const drawerImageUrl = ref('')
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
function beforeImgUpload(file: File) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isImage) ElMessage.error('仅支持图片格式')
|
||||
if (!isLt10M) ElMessage.error('图片大小不能超过10MB')
|
||||
return isImage && isLt10M
|
||||
}
|
||||
|
||||
function handlePictureCardPreview(file: any) {
|
||||
drawerImageUrl.value = file.url
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleRemove(file: any) {
|
||||
fileList.value = []
|
||||
form.image = ''
|
||||
}
|
||||
|
||||
function removeExistingImage() {
|
||||
form.image = ''
|
||||
}
|
||||
|
||||
function previewExistingImage() {
|
||||
const imagePath = form.image.replace(/\\\//g, '/');
|
||||
drawerImageUrl.value = API_BASE_URL + imagePath;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal) {
|
||||
if (props.isEdit && props.model) {
|
||||
// 编辑模式,填充表单数据
|
||||
const modelData = props.model._raw || props.model;
|
||||
nextTick(() => {
|
||||
Object.assign(form, {
|
||||
title: modelData.title || '',
|
||||
author: modelData.author || '',
|
||||
cate: modelData.cate || '',
|
||||
content: modelData.content || '',
|
||||
desc: modelData.desc || '',
|
||||
is_trans: modelData.is_trans || 0,
|
||||
transurl: modelData.transurl || null,
|
||||
image: modelData.image || ''
|
||||
});
|
||||
fileList.value = []; // 重置文件列表
|
||||
});
|
||||
} else {
|
||||
// 新增模式,重置表单
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:modelValue', false);
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleConfirm(status = 1) {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
let imageUrl = form.image;
|
||||
|
||||
// 如果有新选择的文件,先上传图片
|
||||
if (fileList.value.length > 0 && fileList.value[0].raw) {
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', fileList.value[0].raw)
|
||||
uploadFormData.append('cate', 'article')
|
||||
|
||||
const uploadRes = await uploadFile(uploadFormData);
|
||||
// 200=新文件上传成功,201=文件已存在(使用已有文件的链接)
|
||||
if ((uploadRes.code === 200 || uploadRes.code === 201) && uploadRes.data && uploadRes.data.url) {
|
||||
imageUrl = uploadRes.data.url;
|
||||
} else {
|
||||
ElMessage.error('图片上传失败:' + (uploadRes.msg || '未知错误'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加状态参数,0为草稿,1为提交
|
||||
const submitData = {
|
||||
title: form.title,
|
||||
author: form.author,
|
||||
cate: form.cate,
|
||||
content: form.content,
|
||||
desc: form.desc,
|
||||
image: imageUrl,
|
||||
is_trans: form.is_trans,
|
||||
transurl: form.is_trans === 1 ? form.transurl : null,
|
||||
status: status // 0为草稿,1为提交
|
||||
};
|
||||
|
||||
// 根据新增/编辑模式选择不同的API调用函数
|
||||
const isCreateMode = !props.isEdit;
|
||||
let res;
|
||||
let resp;
|
||||
|
||||
if (isCreateMode) {
|
||||
// 新增模式,使用createArticle接口
|
||||
const createArticleWithCheck = async (ignoreSimilarity = false) => {
|
||||
const data = {
|
||||
...submitData,
|
||||
...(ignoreSimilarity && { ignore_similarity: 1 })
|
||||
};
|
||||
return await createArticle(data);
|
||||
};
|
||||
|
||||
res = await createArticleWithCheck();
|
||||
resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||
|
||||
// 检测到相似标题,显示确认对话框
|
||||
if (resp && resp.code === 409) {
|
||||
const similarArticles = resp.data?.similar_articles || [];
|
||||
// 构建带样式的确认消息
|
||||
const similarArticlesList = similarArticles.map((article: any) => `\n<div style="margin: 8px 0; padding: 8px; background: #f5f7fa; border-radius: 4px;">\n<div style="font-weight: bold; color: #303133;">${article.title}</div>\n<div style="color: #606266; font-size: 14px; margin-top: 4px;">相似度:${article.similarity}%</div>\n</div>\n`).join('');
|
||||
|
||||
const confirmMessage = `\n<div>\n\n${similarArticlesList} \n<p style="margin-top: 16px; color: #303133;">是否继续创建?</p>\n</div>\n`;
|
||||
|
||||
// 使用Element Plus的confirm对话框
|
||||
const { ElMessageBox } = await import('element-plus');
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
confirmMessage,
|
||||
'检测到相似标题',
|
||||
{
|
||||
confirmButtonText: '继续创建',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
dangerouslyUseHTMLString: true,
|
||||
}
|
||||
);
|
||||
// 用户确认继续,再次调用接口并忽略相似度检测
|
||||
res = await createArticleWithCheck(true);
|
||||
resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||
} catch (error) {
|
||||
// 用户取消创建
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 编辑模式,使用editArticle接口
|
||||
// 获取文章ID
|
||||
const modelData = props.model._raw || props.model;
|
||||
const articleId = modelData.id;
|
||||
|
||||
if (!articleId) {
|
||||
ElMessage.error('文章ID不存在,无法更新');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建编辑请求数据,不包含文章ID(ID通过URL参数传递)
|
||||
const editData = {
|
||||
...submitData
|
||||
};
|
||||
|
||||
res = await editArticle(articleId, editData);
|
||||
resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||
}
|
||||
|
||||
if (resp && resp.code === 200 && (resp.message === 'success' || resp.msg === 'success')) {
|
||||
ElMessage.success(status === 0 ? '保存草稿成功' : (props.isEdit ? '更新成功' : '创建成功'));
|
||||
emit('update:modelValue', false);
|
||||
emit('saved');
|
||||
} else {
|
||||
ElMessage.error((resp && resp.message) || (resp && resp.msg) || (status === 0 ? '保存草稿失败' : (props.isEdit ? '更新失败' : '创建失败')));
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(status === 0 ? '保存草稿失败' : (props.isEdit ? '更新失败' : '创建失败'));
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
// 重置表单
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
// 重置文件列表
|
||||
fileList.value = [];
|
||||
// 重置表单数据
|
||||
Object.assign(form, {
|
||||
title: '',
|
||||
author: '美天科技',
|
||||
cate: '',
|
||||
content: '',
|
||||
image: '',
|
||||
desc: '',
|
||||
is_trans: 0,
|
||||
transurl: null
|
||||
});
|
||||
}
|
||||
|
||||
// 暴露重置方法(如果需要的话)
|
||||
defineExpose({
|
||||
resetForm: () => {
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-container {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.uploads {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.existing-image {
|
||||
position: relative;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.existing-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.existing-image:hover .image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::deep(.el-message-box) {
|
||||
width: 800px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="文章预览" size="60%">
|
||||
<div class="article-preview">
|
||||
<div class="article-header">
|
||||
<h1 class="article-title">{{ model?.title || "无标题" }}</h1>
|
||||
<div class="article-meta">
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-price-tag"></i>
|
||||
<el-tag type="primary">{{ getCategoryName(model?.cate) }}</el-tag>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-user"></i>
|
||||
作者:{{ model?.author }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
发布人:{{ model?.publisher || "暂未发布" }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
阅读量:{{ model?.views || 0 }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
收藏量:{{ model?.likes || 0 }}
|
||||
</span>
|
||||
<span class="meta-item" v-if="model?.publish_time">
|
||||
<i class="el-icon-time"></i>
|
||||
{{ formatDate(model.publish_time) }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
创建日期:{{ model?.create_time }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-content">
|
||||
<div
|
||||
v-if="model?.content"
|
||||
v-html="model.content"
|
||||
class="content-html"
|
||||
></div>
|
||||
<div v-else class="no-content">
|
||||
<el-empty description="暂无内容"></el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { listCategories } from "@/api/article";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await listCategories({ page: 1, limit: 1000 });
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = Array.isArray(res.data)
|
||||
? res.data
|
||||
: Array.isArray(res.data?.list)
|
||||
? res.data.list
|
||||
: [];
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "获取分类列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取分类列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 根据分类ID获取分类名称
|
||||
const getCategoryName = (cate) => {
|
||||
if (!cate) return "无分类";
|
||||
// 确保比较时类型一致
|
||||
const cateId = Number(cate);
|
||||
const category = categoryOptions.value.find(
|
||||
(item) => Number(item.id) === cateId,
|
||||
);
|
||||
return category?.name || "无分类";
|
||||
};
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const visible = ref(false);
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
},
|
||||
);
|
||||
|
||||
// 监听visible变化,同步给父组件
|
||||
watch(visible, (newVal) => {
|
||||
emit("update:modelValue", newVal);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-preview {
|
||||
padding: 20px;
|
||||
|
||||
.article-header {
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.article-title {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-content {
|
||||
/* 确保article-content直接子元素的样式 */
|
||||
& > div {
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 支持所有子元素的内联样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 段落样式 */
|
||||
p {
|
||||
margin: 10px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 20px 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
}
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
ul,
|
||||
ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
/* 引用样式 */
|
||||
blockquote {
|
||||
border-left: 4px solid #ebeef5;
|
||||
padding-left: 15px;
|
||||
margin: 15px 0;
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 代码样式 */
|
||||
:deep(code) {
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
color: #f06b6b;
|
||||
}
|
||||
|
||||
/* 代码块样式 */
|
||||
:deep(pre) {
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d3f 100%);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 图片样式 */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ebeef5;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保内联样式能够生效 */
|
||||
[style] {
|
||||
/* 允许内联样式正常工作 */
|
||||
all: unset;
|
||||
/* 重新应用基本样式 */
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
/* 允许特定内联样式覆盖 */
|
||||
&[style*="text-align"] {
|
||||
text-align: var(--text-align, inherit) !important;
|
||||
}
|
||||
&[style*="color"] {
|
||||
color: var(--color, inherit) !important;
|
||||
}
|
||||
&[style*="font-size"] {
|
||||
font-size: var(--font-size, inherit) !important;
|
||||
}
|
||||
&[style*="font-weight"] {
|
||||
font-weight: var(--font-weight, inherit) !important;
|
||||
}
|
||||
&[style*="font-style"] {
|
||||
font-style: var(--font-style, inherit) !important;
|
||||
}
|
||||
&[style*="text-decoration"] {
|
||||
text-decoration: var(--text-decoration, inherit) !important;
|
||||
}
|
||||
&[style*="margin"] {
|
||||
margin: var(--margin, inherit) !important;
|
||||
}
|
||||
&[style*="padding"] {
|
||||
padding: var(--padding, inherit) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 直接支持text-align属性 */
|
||||
[align] {
|
||||
text-align: attr(align) !important;
|
||||
}
|
||||
|
||||
/* 确保content-html类的样式 */
|
||||
.content-html {
|
||||
& > * {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
& > h1,
|
||||
& > h2,
|
||||
& > h3,
|
||||
& > h4,
|
||||
& > h5,
|
||||
& > h6 {
|
||||
margin: 20px 0 10px 0;
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* 支持居中样式 */
|
||||
& > p[style*="text-align: center"],
|
||||
& > p[align="center"] {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* 图片居中 */
|
||||
& > p[style*="text-align: center"] img,
|
||||
& > p[align="center"] img {
|
||||
display: inline-block;
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
|
||||
.no-content {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,668 @@
|
||||
<template>
|
||||
<div class="cms-articles">
|
||||
<div class="articles-container">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">新增文章</el-button>
|
||||
<el-button @click="handleRefresh">刷新</el-button>
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索文章标题/作者"
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" @click="handleSearch" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="filters">
|
||||
<!-- 根据分类筛选 -->
|
||||
<el-select
|
||||
v-model="categoryFilter"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 150px; margin-right: 10px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 根据日期筛选 -->
|
||||
<el-date-picker
|
||||
v-model="dateFilter"
|
||||
type="daterange"
|
||||
value-format="yyyy-MM-dd"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 240px; margin-right: 10px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<el-table
|
||||
:data="articleList"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column
|
||||
prop="title"
|
||||
label="标题"
|
||||
min-width="400"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center; gap: 4px">
|
||||
<!-- 置顶标签 -->
|
||||
<el-tag
|
||||
v-if="row.top === 1"
|
||||
type="danger"
|
||||
size="small"
|
||||
effect="dark"
|
||||
>
|
||||
置顶
|
||||
</el-tag>
|
||||
<!-- 推荐标签 -->
|
||||
<el-tag
|
||||
v-if="row.recommend === 1"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="dark"
|
||||
>
|
||||
推荐
|
||||
</el-tag>
|
||||
<!-- 标题链接 -->
|
||||
<el-link
|
||||
type="primary"
|
||||
@click="handleView(row)"
|
||||
underline="never"
|
||||
>
|
||||
{{ row.title }}
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="cate" label="文章分类" width="120" />
|
||||
<el-table-column prop="author" label="作者" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="views"
|
||||
label="浏览量"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="likes"
|
||||
label="点赞量"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="publishdate" label="发布时间" width="160" />
|
||||
<el-table-column prop="update_time" label="更新时间" width="160" />
|
||||
<el-table-column prop="publisher" label="发布人" width="120" />
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status != 2 && row.status != 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handlePulish(row)"
|
||||
>发布</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status != 2"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status != 2"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnPulish(row)"
|
||||
>下架</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.recommend === 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handleRecommend(row)"
|
||||
>推荐</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.recommend === 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnRecommend(row)"
|
||||
>取消推荐</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.top === 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handleTop(row)"
|
||||
>置顶</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.top === 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnTop(row)"
|
||||
>取消置顶</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<Edit
|
||||
v-model="dialogVisible"
|
||||
:is-edit="isEdit"
|
||||
:model="currentRow"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<!-- 预览抽屉 -->
|
||||
<Preview v-model="previewVisible" :model="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import Edit from "./components/edit.vue";
|
||||
import Preview from "./components/preview.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
listArticles,
|
||||
deleteArticle,
|
||||
listCategories,
|
||||
publishArticle,
|
||||
unPublishArticle,
|
||||
getArticle,
|
||||
articleRecommend,
|
||||
articleTop,
|
||||
unArticleRecommend,
|
||||
unArticleTop,
|
||||
} from "@/api/article.js";
|
||||
|
||||
const loading = ref(false);
|
||||
const articleList = ref([]);
|
||||
const searchQuery = ref("");
|
||||
const categoryFilter = ref("");
|
||||
const categoryOptions = ref([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const dialogVisible = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const currentRow = ref(null);
|
||||
|
||||
// 使用 auth store 获取用户信息
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = authStore.user;
|
||||
if (userInfo && userInfo.id) {
|
||||
// console.log('用户名:', userInfo.account || userInfo.name);
|
||||
// console.log('用户ID:', userInfo.id);
|
||||
// console.log('角色:', userInfo.role);
|
||||
} else {
|
||||
console.log("未找到用户信息或用户未登录");
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
0: "草稿",
|
||||
1: "待审核",
|
||||
2: "已发布",
|
||||
3: "已隐藏",
|
||||
};
|
||||
return statusMap[status] || "未知";
|
||||
}
|
||||
|
||||
// 获取状态对应的标签类型
|
||||
function getStatusType(status) {
|
||||
const typeMap = {
|
||||
0: "info",
|
||||
1: "warning",
|
||||
2: "success",
|
||||
3: "danger",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
}
|
||||
|
||||
// 获取推荐文本
|
||||
function getRecommendText(status) {
|
||||
const statusMap = {
|
||||
0: "未推荐",
|
||||
1: "推荐",
|
||||
};
|
||||
return statusMap[status] || "未知";
|
||||
}
|
||||
|
||||
// 获取推荐对应的标签类型
|
||||
function getRecommendType(status) {
|
||||
const typeMap = {
|
||||
0: "info",
|
||||
1: "success",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
}
|
||||
|
||||
// 获取文章列表
|
||||
async function fetchArticleList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
keyword: searchQuery.value,
|
||||
cate: categoryFilter.value,
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
|
||||
const res = await listArticles(params);
|
||||
|
||||
if (res.code === 200) {
|
||||
articleList.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} else {
|
||||
console.error("获取文章列表失败:", res.msg);
|
||||
ElMessage.error(res.msg || "获取文章列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("请求异常:", error);
|
||||
ElMessage.error("网络请求失败,请稍后重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
// 处理筛选变化
|
||||
function handleFilterChange() {
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理每页条数变化
|
||||
function handleSizeChange(val) {
|
||||
pageSize.value = val;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理当前页变化
|
||||
function handleCurrentChange(val) {
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
currentRow.value = null;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
isEdit.value = true;
|
||||
// 获取详情
|
||||
getArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200 && resp.data) {
|
||||
const m = resp.data;
|
||||
currentRow.value = {
|
||||
id: m.id,
|
||||
title: m.title || "",
|
||||
author: m.author || "",
|
||||
cate: m.cate || "",
|
||||
content: m.content || "",
|
||||
desc: m.desc || "",
|
||||
publish_time: m.publish_time || null,
|
||||
_raw: m,
|
||||
};
|
||||
} else {
|
||||
currentRow.value = { ...row };
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function handleView(row) {
|
||||
// 获取最新详情再预览
|
||||
getArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200 && resp.data) {
|
||||
const m = resp.data;
|
||||
currentRow.value = {
|
||||
id: m.id,
|
||||
title: m.title || "",
|
||||
author: m.author || "",
|
||||
cate: m.cate || "",
|
||||
content: m.content || "",
|
||||
desc: m.desc || "",
|
||||
view_count: m.view_count || 0,
|
||||
publisher: m.publisher || "",
|
||||
create_time: m.create_time || null,
|
||||
publish_time: m.publish_time || null,
|
||||
update_time: m.update_time || null,
|
||||
_raw: m,
|
||||
};
|
||||
previewVisible.value = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm("确定要删除这篇文章吗?此操作不可恢复。", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error((resp && resp.msg) || "删除失败");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const uid = userInfo.id;
|
||||
|
||||
//发布文章
|
||||
function handlePulish(row) {
|
||||
ElMessageBox.confirm("确认发布该文章吗?发布后将在前台显示。", "确认发布", {
|
||||
confirmButtonText: "确认发布",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
publishArticle(row.id, uid)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("发布成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "发布失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("发布失败:", error);
|
||||
ElMessage.error(error.msg || "发布失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 下架文章
|
||||
function handleUnPulish(row) {
|
||||
ElMessageBox.confirm("确认下架该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unPublishArticle(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("下架成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "下架失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("下架失败:", error);
|
||||
ElMessage.error(error.msg || "下架失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
//推荐文章
|
||||
function handleRecommend(row) {
|
||||
ElMessageBox.confirm("确认推荐该文章吗?推荐后将在前台显示。", "确认推荐", {
|
||||
confirmButtonText: "确认推荐",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
articleRecommend(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("推荐成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "推荐失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("推荐失败:", error);
|
||||
ElMessage.error(error.msg || "推荐失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 取消推荐文章
|
||||
function handleUnRecommend(row) {
|
||||
ElMessageBox.confirm("确认取消推荐该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unArticleRecommend(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("取消推荐成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "取消推荐失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("取消推荐失败:", error);
|
||||
ElMessage.error(error.msg || "取消推荐失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
//置顶文章
|
||||
function handleTop(row) {
|
||||
ElMessageBox.confirm("确认置顶该文章吗?置顶后将在前台显示。", "确认置顶", {
|
||||
confirmButtonText: "确认置顶",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
articleTop(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("置顶成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "置顶失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("置顶失败:", error);
|
||||
ElMessage.error(error.msg || "置顶失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 取消置顶文章
|
||||
function handleUnTop(row) {
|
||||
ElMessageBox.confirm("确认取消置顶该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unArticleTop(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("取消置顶成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "取消置顶失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("取消置顶失败:", error);
|
||||
ElMessage.error(error.msg || "取消置顶失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
searchQuery.value = "";
|
||||
categoryFilter.value = "";
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理保存成功回调
|
||||
function onSaved() {
|
||||
dialogVisible.value = false;
|
||||
fetchArticleList();
|
||||
ElMessage.success("保存成功");
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await listCategories({ page: 1, limit: 1000 });
|
||||
|
||||
if (res && res.code === 200) {
|
||||
if (res.data && Array.isArray(res.data.list)) {
|
||||
categoryOptions.value = res.data.list;
|
||||
} else if (Array.isArray(res.data)) {
|
||||
categoryOptions.value = res.data;
|
||||
} else if (Array.isArray(res.list)) {
|
||||
categoryOptions.value = res.list;
|
||||
}
|
||||
|
||||
if (categoryOptions.value.length === 0) {
|
||||
ElMessage.warning("未获取到分类数据");
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "获取分类列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取分类列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchArticleList(),
|
||||
fetchCategories(), // 获取分类数据
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cms-articles {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.articles-container {
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-bar {
|
||||
margin-left: auto;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<!-- 添加/编辑单页对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="80%"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentOnePage"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="onePageFormRef"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="currentOnePage.title"
|
||||
placeholder="请输入单页标题"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="路由" prop="path">
|
||||
<el-input
|
||||
v-model="currentOnePage.path"
|
||||
placeholder="例如:/about、/contact、/privacy"
|
||||
maxlength="200"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
路由地址,必须以 / 开头,例如:/about
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="currentOnePage.content"
|
||||
type="textarea"
|
||||
:rows="20"
|
||||
placeholder="请输入单页内容(支持代码)"
|
||||
style="font-family: 'Courier New', monospace;"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
支持代码和文本内容,使用等宽字体显示
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentOnePage.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
数字越小,排序越靠前
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="currentOnePage.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
启用后,前端可以访问该单页
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
|
||||
// 定义单页数据类型
|
||||
interface OnePage {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
delete_time?: string;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onepage: Partial<OnePage> | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
onepage: null,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", onepage: Partial<OnePage>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const onePageFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的单页
|
||||
const currentOnePage = ref<Partial<OnePage>>({
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
return props.onepage?.id && props.onepage.id > 0 ? "编辑单页" : "添加单页";
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入单页标题", trigger: "blur" }],
|
||||
path: [
|
||||
{ required: true, message: "请输入路由", trigger: "blur" },
|
||||
{
|
||||
pattern: /^\/[a-zA-Z0-9\/_-]*$/,
|
||||
message: "路由必须以 / 开头,只能包含字母、数字、下划线、横线和斜线",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
content: [{ required: true, message: "请输入单页内容", trigger: "blur" }],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// 监听props变化,更新当前单页
|
||||
watch(
|
||||
() => props.onepage,
|
||||
(newOnePage) => {
|
||||
if (newOnePage) {
|
||||
currentOnePage.value = {
|
||||
...newOnePage,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && (!props.onepage || !props.onepage.id)) {
|
||||
// 新增时重置表单
|
||||
currentOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 保存单页
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!onePageFormRef.value) return;
|
||||
const valid = await onePageFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 准备提交数据
|
||||
const payload = { ...currentOnePage.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 代码编辑器样式 */
|
||||
:deep(.el-textarea__inner) {
|
||||
font-family: 'Courier New', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>单页管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddOnePage">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加单页
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="onePageList"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
|
||||
<el-table-column prop="title" label="标题" min-width="150" />
|
||||
|
||||
<el-table-column prop="path" label="路由" width="200">
|
||||
<template #default="scope">
|
||||
<el-tag type="info">{{ scope.row.path }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div style="font-family: 'Courier New', monospace; white-space: pre-wrap;">{{ getContentPreview(scope.row.content) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
|
||||
{{ scope.row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="100"
|
||||
align="center"
|
||||
sortable
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEditOnePage(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteOnePage(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<OnePageEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:onepage="dialogOnePage"
|
||||
@save="handleOnePageSave"
|
||||
@cancel="handleOnePageCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getOnePages,
|
||||
createOnePage,
|
||||
editOnePage,
|
||||
deleteOnePage,
|
||||
} from "@/api/onepage";
|
||||
import OnePageEdit from "./components/edit.vue";
|
||||
|
||||
// 定义单页数据类型
|
||||
interface OnePage {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
delete_time?: string;
|
||||
}
|
||||
|
||||
// 单页列表
|
||||
const onePageList = ref<OnePage[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogOnePage = ref<Partial<OnePage> | null>(null);
|
||||
|
||||
// 获取单页列表
|
||||
const fetchOnePages = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await getOnePages();
|
||||
if (result.code === 200) {
|
||||
onePageList.value = result.data || [];
|
||||
} else {
|
||||
ElMessage.error("获取单页列表失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取单页列表失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchOnePages();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取内容预览
|
||||
const getContentPreview = (content: string) => {
|
||||
if (!content) return '-';
|
||||
// 限制长度,保留原始格式
|
||||
return content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||
};
|
||||
|
||||
// 添加单页
|
||||
const handleAddOnePage = () => {
|
||||
dialogOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑单页
|
||||
const handleEditOnePage = (onePage: OnePage) => {
|
||||
dialogOnePage.value = { ...onePage };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除单页
|
||||
const handleDeleteOnePage = (onePage: OnePage) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除单页 "${onePage.title}" 吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteOnePage(onePage.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理单页保存
|
||||
const handleOnePageSave = async (onePage: Partial<OnePage>) => {
|
||||
try {
|
||||
const payload = { ...onePage };
|
||||
|
||||
// 判断是新增还是编辑
|
||||
if (!onePage.id || onePage.id === 0) {
|
||||
// 新增单页
|
||||
const result = await createOnePage(payload as OnePage);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "单页添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的单页
|
||||
const result = await editOnePage(onePage.id!, payload as OnePage);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单页取消
|
||||
const handleOnePageCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 组件挂载时加载单页列表
|
||||
onMounted(() => {
|
||||
fetchOnePages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="exam-workbench">
|
||||
<!-- 数据统计 -->
|
||||
<div class="statistics-section">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-file-lines"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.examCount }}</div>
|
||||
<div class="stat-label">考试总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.practiceCount }}</div>
|
||||
<div class="stat-label">练习总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-book"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.courseCount }}</div>
|
||||
<div class="stat-label">课程总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.studentCount }}</div>
|
||||
<div class="stat-label">考生总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 快捷功能 -->
|
||||
<div class="quick-actions">
|
||||
<h3 class="section-title">快捷功能</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreateExam">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-file-circle-plus"></i>
|
||||
</div>
|
||||
<div class="action-label">创建考试</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreatePractice">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</div>
|
||||
<div class="action-label">创建练习</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreateCourse">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-book-open"></i>
|
||||
</div>
|
||||
<div class="action-label">创建课程</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleBatchImport">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-file-import"></i>
|
||||
</div>
|
||||
<div class="action-label">批量导题</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleStudentManage">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-user-group"></i>
|
||||
</div>
|
||||
<div class="action-label">考生管理</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleQuestionBank">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-database"></i>
|
||||
</div>
|
||||
<div class="action-label">试题库</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const statistics = ref({
|
||||
examCount: 0,
|
||||
practiceCount: 0,
|
||||
courseCount: 0,
|
||||
studentCount: 0
|
||||
});
|
||||
|
||||
const handleCreateExam = () => {
|
||||
router.push('/apps/exams/exam');
|
||||
};
|
||||
|
||||
const handleCreatePractice = () => {
|
||||
ElMessage.info('创建练习功能开发中');
|
||||
};
|
||||
|
||||
const handleCreateCourse = () => {
|
||||
ElMessage.info('创建课程功能开发中');
|
||||
};
|
||||
|
||||
const handleBatchImport = () => {
|
||||
ElMessage.info('批量导题功能开发中');
|
||||
};
|
||||
|
||||
const handleStudentManage = () => {
|
||||
ElMessage.info('考生管理功能开发中');
|
||||
};
|
||||
|
||||
const handleQuestionBank = () => {
|
||||
ElMessage.info('试题库功能开发中');
|
||||
};
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
// TODO: 调用API获取统计数据
|
||||
statistics.value = {
|
||||
examCount: 0,
|
||||
practiceCount: 0,
|
||||
courseCount: 0,
|
||||
studentCount: 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exam-workbench {
|
||||
padding: 20px;
|
||||
|
||||
.statistics-section {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.action-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
i {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
transition: all 0.3s;
|
||||
|
||||
i {
|
||||
font-size: 32px;
|
||||
color: #667eea;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
// Apps 父路由组件,用于显示子路由
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Apps 父路由容器 */
|
||||
</style>
|
||||
@@ -0,0 +1,704 @@
|
||||
<template>
|
||||
<div class="wang-editor-wrapper" :class="{ focused: isFocused }">
|
||||
<div ref="toolbarRef" class="toolbar-container"></div>
|
||||
<div ref="editorRef" class="editor-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { uploadFile } from "@/api/file";
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: "",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const toolbarRef = ref<HTMLDivElement>();
|
||||
const editorRef = ref<HTMLDivElement>();
|
||||
const isFocused = ref(false);
|
||||
let editorInstance: any = null;
|
||||
let isDestroyed = false;
|
||||
|
||||
// 获取上传文件的 URL
|
||||
const getUploadUrl = (): string => {
|
||||
return import.meta.env.VITE_API_BASE_URL;
|
||||
};
|
||||
|
||||
// 获取 Authorization Header
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem("token");
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
// 上传图片处理函数
|
||||
const handleUploadImage = async (
|
||||
file: File,
|
||||
insertFn: (url: string, alt?: string, href?: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("cate", "article");
|
||||
|
||||
const response: any = await uploadFile(formData);
|
||||
|
||||
// 200=新文件上传成功,201=文件已存在(使用已有文件的链接)
|
||||
if (
|
||||
response &&
|
||||
(response.code === 200 || response.code === 201) &&
|
||||
response.data &&
|
||||
response.data.url
|
||||
) {
|
||||
const fileUrl = response.data.url;
|
||||
const baseUrl = getUploadUrl() || window.location.origin;
|
||||
|
||||
let fullUrl = fileUrl;
|
||||
if (!fileUrl.startsWith("http")) {
|
||||
const base = baseUrl.replace(/\/$/, "");
|
||||
const url = fileUrl.startsWith("/") ? fileUrl : "/" + fileUrl;
|
||||
fullUrl = `${base}${url}`;
|
||||
}
|
||||
|
||||
insertFn(fullUrl, file.name, fullUrl);
|
||||
ElMessage.success(
|
||||
response.code === 201 ? "使用已有图片" : "图片上传成功",
|
||||
);
|
||||
} else {
|
||||
ElMessage.error("上传失败:" + (response?.msg || "未知错误"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Upload error:", error);
|
||||
ElMessage.error("上传失败:" + (error.message || "未知错误"));
|
||||
}
|
||||
};
|
||||
|
||||
// 上传视频处理函数
|
||||
const handleUploadVideo = async (
|
||||
file: File,
|
||||
insertFn: (url: string, poster?: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("cate", "article");
|
||||
|
||||
const response: any = await uploadFile(formData);
|
||||
|
||||
// 200=新文件上传成功,201=文件已存在(使用已有文件的链接)
|
||||
if (
|
||||
response &&
|
||||
(response.code === 200 || response.code === 201) &&
|
||||
response.data &&
|
||||
response.data.url
|
||||
) {
|
||||
const fileUrl = response.data.url;
|
||||
const baseUrl = getUploadUrl() || window.location.origin;
|
||||
|
||||
let fullUrl = fileUrl;
|
||||
if (!fileUrl.startsWith("http")) {
|
||||
const base = baseUrl.replace(/\/$/, "");
|
||||
const url = fileUrl.startsWith("/") ? fileUrl : "/" + fileUrl;
|
||||
fullUrl = `${base}${url}`;
|
||||
}
|
||||
|
||||
insertFn(fullUrl, "");
|
||||
ElMessage.success(
|
||||
response.code === 201 ? "使用已有视频" : "视频上传成功",
|
||||
);
|
||||
} else {
|
||||
ElMessage.error("上传失败:" + (response?.msg || "未知错误"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Upload error:", error);
|
||||
ElMessage.error("上传失败:" + (error.message || "未知错误"));
|
||||
}
|
||||
};
|
||||
|
||||
// 上传附件处理函数
|
||||
const handleUploadAttachment = async (
|
||||
file: File,
|
||||
insertFn: (url: string, text?: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("cate", "article");
|
||||
|
||||
const response: any = await uploadFile(formData);
|
||||
|
||||
// 200=新文件上传成功,201=文件已存在(使用已有文件的链接)
|
||||
if (
|
||||
response &&
|
||||
(response.code === 200 || response.code === 201) &&
|
||||
response.data &&
|
||||
response.data.url
|
||||
) {
|
||||
const fileUrl = response.data.url;
|
||||
const baseUrl = getUploadUrl() || window.location.origin;
|
||||
|
||||
let fullUrl = fileUrl;
|
||||
if (!fileUrl.startsWith("http")) {
|
||||
const base = baseUrl.replace(/\/$/, "");
|
||||
const url = fileUrl.startsWith("/") ? fileUrl : "/" + fileUrl;
|
||||
fullUrl = `${base}${url}`;
|
||||
}
|
||||
|
||||
insertFn(fullUrl, file.name);
|
||||
ElMessage.success(
|
||||
response.code === 201 ? "使用已有附件" : "附件上传成功",
|
||||
);
|
||||
} else {
|
||||
ElMessage.error("上传失败:" + (response?.msg || "未知错误"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Upload error:", error);
|
||||
ElMessage.error("上传失败:" + (error.message || "未知错误"));
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化编辑器
|
||||
const initEditor = async () => {
|
||||
if (!editorRef.value || !toolbarRef.value || isDestroyed) return;
|
||||
|
||||
try {
|
||||
// 动态导入 wangEditor
|
||||
const { createEditor, createToolbar } = await import("@wangeditor/editor");
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: "请输入内容...",
|
||||
onChange: (editor: any) => {
|
||||
if (!isDestroyed) {
|
||||
const html = editor.getHtml();
|
||||
emit("update:modelValue", html);
|
||||
}
|
||||
},
|
||||
// 自定义上传配置
|
||||
MENU_CONF: {
|
||||
// 图片上传配置
|
||||
uploadImage: {
|
||||
server: "/api/files",
|
||||
fieldName: "file",
|
||||
headers: getAuthHeaders(),
|
||||
customUpload: async (
|
||||
file: File,
|
||||
insertFn: (url: string, alt?: string, href?: string) => void,
|
||||
) => {
|
||||
await handleUploadImage(file, insertFn);
|
||||
},
|
||||
allowedFileTypes: ["image/*"],
|
||||
maxFileSize: 5 * 1024 * 1024, // 5MB
|
||||
},
|
||||
// 视频上传配置
|
||||
uploadVideo: {
|
||||
server: "/api/files",
|
||||
fieldName: "file",
|
||||
headers: getAuthHeaders(),
|
||||
customUpload: async (
|
||||
file: File,
|
||||
insertFn: (url: string, poster?: string) => void,
|
||||
) => {
|
||||
await handleUploadVideo(file, insertFn);
|
||||
},
|
||||
allowedFileTypes: ["video/*"],
|
||||
maxFileSize: 100 * 1024 * 1024, // 100MB
|
||||
},
|
||||
// 附件上传配置
|
||||
uploadAttachment: {
|
||||
server: "/api/files",
|
||||
fieldName: "file",
|
||||
headers: getAuthHeaders(),
|
||||
customUpload: async (
|
||||
file: File,
|
||||
insertFn: (url: string, text?: string) => void,
|
||||
) => {
|
||||
await handleUploadAttachment(file, insertFn);
|
||||
},
|
||||
allowedFileTypes: ["*"],
|
||||
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 创建编辑器
|
||||
editorInstance = createEditor({
|
||||
selector: editorRef.value,
|
||||
html: props.modelValue || "",
|
||||
config: editorConfig,
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
// 创建工具栏
|
||||
createToolbar({
|
||||
editor: editorInstance,
|
||||
selector: toolbarRef.value,
|
||||
config: {},
|
||||
});
|
||||
|
||||
// 监听编辑器焦点事件(兼容不同版本的 API)
|
||||
nextTick(() => {
|
||||
if (editorInstance) {
|
||||
// 方法1: 使用 on 方法(如果存在)
|
||||
if (typeof editorInstance.on === "function") {
|
||||
editorInstance.on("focus", () => {
|
||||
isFocused.value = true;
|
||||
});
|
||||
|
||||
editorInstance.on("blur", () => {
|
||||
isFocused.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 方法2: 直接在 DOM 元素上监听
|
||||
const editorDom = editorRef.value;
|
||||
if (editorDom) {
|
||||
const textDom = editorDom.querySelector(".w-e-text");
|
||||
if (textDom) {
|
||||
textDom.addEventListener("focus", () => {
|
||||
isFocused.value = true;
|
||||
});
|
||||
textDom.addEventListener("blur", () => {
|
||||
isFocused.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize editor:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (editorInstance && newVal !== editorInstance.getHtml()) {
|
||||
editorInstance.setHtml(newVal || "");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
clear: () => {
|
||||
if (editorInstance) {
|
||||
editorInstance.clear();
|
||||
}
|
||||
},
|
||||
getContent: () => {
|
||||
if (editorInstance) {
|
||||
return editorInstance.getHtml();
|
||||
}
|
||||
return props.modelValue;
|
||||
},
|
||||
setContent: (content: string) => {
|
||||
if (editorInstance) {
|
||||
editorInstance.setHtml(content || "");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
initEditor();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isDestroyed = true;
|
||||
if (editorInstance) {
|
||||
editorInstance.destroy();
|
||||
editorInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.wang-editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 650px; // 设置固定高度
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: var(--fill-color-blank);
|
||||
|
||||
.toolbar-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--fill-color-light);
|
||||
border-bottom: 1px solid var(--border-color-lighter);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
border-bottom: 1px solid var(--border-color-lighter);
|
||||
background-color: var(--fill-color-light);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
min-height: 400px;
|
||||
background-color: var(--fill-color-blank);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 全局样式:WangEditor 编辑器主题适配
|
||||
.wang-editor-wrapper {
|
||||
// 工具栏样式
|
||||
:deep(.w-e-toolbar) {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
border-bottom-color: var(--border-color-lighter) !important;
|
||||
border-bottom: 1px solid var(--border-color-lighter) !important;
|
||||
|
||||
// 工具栏按钮项
|
||||
.w-e-bar-item {
|
||||
button {
|
||||
color: var(--text-color-primary) !important;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
transition: all 0.2s ease !important;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: var(--fill-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
background-color: var(--fill-color-dark) !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--fill-color-dark) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--text-color-disabled) !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮组分隔线
|
||||
&::after {
|
||||
background-color: var(--border-color-lighter) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 工具栏分割线
|
||||
.w-e-bar-divider {
|
||||
background-color: var(--border-color-lighter) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器文本容器
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: var(--el-bg-color) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
border: none !important;
|
||||
|
||||
// 编辑器内容区域
|
||||
.w-e-text {
|
||||
color: var(--text-color-primary) !important;
|
||||
background-color: transparent !important;
|
||||
min-height: 400px !important;
|
||||
|
||||
// 编辑器焦点状态
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// 编辑器内的段落
|
||||
p {
|
||||
color: var(--text-color-primary) !important;
|
||||
margin: 0.5em 0 !important;
|
||||
}
|
||||
|
||||
// 编辑器内的标题
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--text-color-primary) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
// 编辑器内的链接
|
||||
a {
|
||||
color: var(--primary-color) !important;
|
||||
text-decoration: underline !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color) !important;
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器内的代码
|
||||
code {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
border: 1px solid var(--border-color-lighter) !important;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
// 编辑器内的代码块
|
||||
pre {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
border: 1px solid var(--border-color-lighter) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
border-radius: 4px !important;
|
||||
|
||||
code {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器内的引用
|
||||
blockquote {
|
||||
border-left: 4px solid var(--border-color) !important;
|
||||
background-color: var(--fill-color-light) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
padding: 0.6em 1.2em !important;
|
||||
margin: 1em 0 !important;
|
||||
}
|
||||
|
||||
// 编辑器内的表格
|
||||
table {
|
||||
border-collapse: collapse !important;
|
||||
border: 1px solid var(--border-color-lighter) !important;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--border-color-lighter) !important;
|
||||
background-color: var(--fill-color-blank) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器内的列表
|
||||
ul,
|
||||
ol {
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
li {
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 占位符样式
|
||||
.placeholder {
|
||||
color: var(--text-color-placeholder) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单下拉框
|
||||
:deep(.w-e-drop-panel) {
|
||||
background-color: var(--bg-color-overlay) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
box-shadow: var(--box-shadow) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
|
||||
.w-e-list-item {
|
||||
color: var(--text-color-primary) !important;
|
||||
transition: all 0.2s ease !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--text-color-disabled) !important;
|
||||
cursor: not-allowed !important;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分隔线
|
||||
.w-e-drop-panel-divider {
|
||||
background-color: var(--border-color-lighter) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 工具栏下拉菜单
|
||||
:deep(.w-e-toolbar-menu) {
|
||||
background-color: var(--bg-color-overlay) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
box-shadow: var(--box-shadow) !important;
|
||||
|
||||
.w-e-menu-item {
|
||||
color: var(--text-color-primary) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框
|
||||
:deep(.w-e-modal) {
|
||||
background-color: var(--bg-color-overlay) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
box-shadow: var(--box-shadow) !important;
|
||||
|
||||
.w-e-modal-header {
|
||||
border-bottom: 1px solid var(--border-color-lighter) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
.w-e-modal-body {
|
||||
background-color: var(--bg-color-overlay) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background-color: var(--fill-color-blank) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-e-modal-footer {
|
||||
border-top: 1px solid var(--border-color-lighter) !important;
|
||||
|
||||
button {
|
||||
background-color: var(--fill-color-blank) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--fill-color-light) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具栏图标颜色
|
||||
:deep(.w-e-bar-item svg),
|
||||
:deep(.w-e-bar-item .w-e-icon) {
|
||||
fill: var(--text-color-primary) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
transition:
|
||||
fill 0.2s ease,
|
||||
color 0.2s ease !important;
|
||||
}
|
||||
|
||||
:deep(.w-e-bar-item:hover:not(.disabled) svg),
|
||||
:deep(.w-e-bar-item:hover:not(.disabled) .w-e-icon),
|
||||
:deep(.w-e-bar-item.active svg),
|
||||
:deep(.w-e-bar-item.active .w-e-icon) {
|
||||
fill: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
// 工具栏按钮组
|
||||
:deep(.w-e-bar-divider) {
|
||||
background-color: var(--border-color-lighter) !important;
|
||||
}
|
||||
|
||||
// 编辑区域边框
|
||||
&.focused {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 1px var(--primary-color) inset !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: var(--el-bg-color) !important;
|
||||
}
|
||||
|
||||
// 深色主题额外适配
|
||||
[data-theme="dark"] {
|
||||
.wang-editor-wrapper {
|
||||
background-color: var(--fill-color-darker) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: var(--fill-color-darker) !important;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container .w-e-text) {
|
||||
background-color: var(--fill-color-darker) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
|
||||
// 编辑器内的图片边框
|
||||
img {
|
||||
border: 1px solid var(--border-color-lighter) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
// 编辑器内的水平线
|
||||
hr {
|
||||
border-top-color: var(--border-color-lighter) !important;
|
||||
}
|
||||
|
||||
// 编辑器内的表格行交替颜色
|
||||
table tr:nth-child(even) {
|
||||
background-color: var(--fill-color-extra-light) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix z-index for full-screen/maximize mode
|
||||
:deep(.w-e-fullscreen),
|
||||
:deep(.w-e-fullscreen *),
|
||||
:deep(.w-e-modal),
|
||||
:deep(.w-e-drop-panel) {
|
||||
z-index: 99999 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,789 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 欢迎区域 -->
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">欢迎回来!</h1>
|
||||
<p class="welcome-subtitle">今天是 {{ currentDate }},祝您工作愉快</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
v-for="(stat, index) in stats"
|
||||
:key="index"
|
||||
class="stat-card"
|
||||
:class="stat.type"
|
||||
>
|
||||
<div class="stat-icon-wrapper">
|
||||
<el-icon :size="28">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-trend"
|
||||
:class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'"
|
||||
>
|
||||
<el-icon v-if="stat.change > 0" :size="14">
|
||||
<ArrowUp />
|
||||
</el-icon>
|
||||
<el-icon v-else-if="stat.change < 0" :size="14">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
<span>{{ stat.change > 0 ? "+" : "" }}{{ stat.change }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<!-- <div class="charts-section">
|
||||
<div class="chart-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">月收入走势</h3>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link>
|
||||
<el-icon>
|
||||
<MoreFilled />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>查看详情</el-dropdown-item>
|
||||
<el-dropdown-item>导出数据</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="lineChart" height="160"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">用户活跃分布</h3>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link>
|
||||
<el-icon>
|
||||
<MoreFilled />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>查看详情</el-dropdown-item>
|
||||
<el-dropdown-item>导出数据</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="barChart" height="160"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<div class="lists-section">
|
||||
<div class="list-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">待办任务</h3>
|
||||
<el-button type="primary" link size="small">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加任务
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="list-content">
|
||||
<div
|
||||
v-for="(task, idx) in paginatedTasks"
|
||||
:key="idx"
|
||||
class="task-item"
|
||||
:class="{ done: task.completed }"
|
||||
>
|
||||
<!-- <el-checkbox v-model="task.completed" @change="handleTaskChange(task)" /> -->
|
||||
<div class="task-info">
|
||||
<div class="task-title">
|
||||
{{ task.title }}
|
||||
<el-tag
|
||||
:type="task.priorityTagType || getPriorityType(task.priority)"
|
||||
size="small"
|
||||
effect="plain"
|
||||
:class="'priority-' + task.priority"
|
||||
>
|
||||
{{ task.priorityLabel || task.priority }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span class="task-date">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ formatDate(task.date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
description="暂无待办任务"
|
||||
:image-size="80"
|
||||
/>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="taskCurrentPage"
|
||||
:page-size="taskPageSize"
|
||||
layout="prev, pager, next"
|
||||
size="small"
|
||||
@current-change="handleTaskPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">最新动态</h3>
|
||||
<!-- <el-button type="primary" link size="small" @click="goToActivityLogs">
|
||||
查看全部
|
||||
</el-button> -->
|
||||
</div>
|
||||
<div class="list-content">
|
||||
<div
|
||||
v-for="(activity, idx) in paginatedActivityLogs"
|
||||
:key="idx"
|
||||
class="activity-item"
|
||||
>
|
||||
<div class="activity-icon" :class="activity.type">
|
||||
<el-icon>
|
||||
<component :is="getActivityIcon(activity.type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-text">
|
||||
<span class="activity-module">{{ activity.operation }}</span>
|
||||
<span class="activity-action">{{ activity.description }}</span>
|
||||
</div>
|
||||
<div class="activity-time">
|
||||
{{ formatTime(activity.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
description="暂无动态"
|
||||
:image-size="80"
|
||||
/>
|
||||
<div v-if="totalActivityLogs > pageSize" class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="totalActivityLogs"
|
||||
layout="prev, pager, next"
|
||||
size="small"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, markRaw } from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import {
|
||||
Money,
|
||||
User,
|
||||
ShoppingCart,
|
||||
TrendCharts,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Document,
|
||||
Edit,
|
||||
View,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { getMenus } from "@/api/menu";
|
||||
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useDictStore } from "@/stores/dict";
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
|
||||
// 当前日期
|
||||
const currentDate = computed(() => {
|
||||
const date = new Date();
|
||||
const weekdays = [
|
||||
"星期日",
|
||||
"星期一",
|
||||
"星期二",
|
||||
"星期三",
|
||||
"星期四",
|
||||
"星期五",
|
||||
"星期六",
|
||||
];
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const weekday = weekdays[date.getDay()];
|
||||
return `${month}月${day}日 ${weekday}`;
|
||||
});
|
||||
|
||||
//格式化时间
|
||||
const formatDate = (date: string) => {
|
||||
const d = new Date(date);
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
return `${month}月${day}日`;
|
||||
};
|
||||
|
||||
// 统计数据(使用 markRaw 避免图标组件被响应式化)
|
||||
const stats = ref([
|
||||
{
|
||||
label: "知识库",
|
||||
value: "0",
|
||||
change: 0,
|
||||
icon: markRaw(Document),
|
||||
type: "knowledge",
|
||||
},
|
||||
{
|
||||
label: "新用户",
|
||||
value: "0",
|
||||
change: 0,
|
||||
icon: markRaw(User),
|
||||
type: "users",
|
||||
},
|
||||
{
|
||||
label: "员工数",
|
||||
value: "0",
|
||||
change: 0,
|
||||
icon: markRaw(ShoppingCart),
|
||||
type: "employees",
|
||||
},
|
||||
{
|
||||
label: "租户数",
|
||||
value: "0",
|
||||
change: 0,
|
||||
icon: markRaw(TrendCharts),
|
||||
type: "tenants",
|
||||
},
|
||||
]);
|
||||
|
||||
// 任务相关数据
|
||||
const tasks = ref([]);
|
||||
const taskCurrentPage = ref(1);
|
||||
const taskPageSize = ref(5);
|
||||
|
||||
// 活动日志相关数据
|
||||
const activityLogs = ref([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(5);
|
||||
const totalActivityLogs = ref(0);
|
||||
|
||||
// 计算属性
|
||||
const paginatedTasks = computed(() => {
|
||||
const start = (taskCurrentPage.value - 1) * taskPageSize.value;
|
||||
const end = start + taskPageSize.value;
|
||||
return tasks.value.slice(start, end);
|
||||
});
|
||||
|
||||
const paginatedActivityLogs = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value;
|
||||
const end = start + pageSize.value;
|
||||
return activityLogs.value.slice(start, end);
|
||||
});
|
||||
|
||||
// 事件处理函数
|
||||
const handleTaskPageChange = (page) => {
|
||||
taskCurrentPage.value = page;
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
const getPriorityType = (priority) => {
|
||||
const types = {
|
||||
high: 'danger',
|
||||
medium: 'warning',
|
||||
low: 'info'
|
||||
};
|
||||
return types[priority] || 'info';
|
||||
};
|
||||
|
||||
const getActivityIcon = (type) => {
|
||||
const icons = {
|
||||
operation: Edit,
|
||||
access: View
|
||||
};
|
||||
return icons[type] || Document;
|
||||
};
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: string | Date) => {
|
||||
if (!timestamp) return "-";
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return String(timestamp);
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 1) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours < 1) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
return `${minutes}分钟前`;
|
||||
}
|
||||
return `${hours}小时前`;
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 从auth store获取用户信息
|
||||
const id = authStore.user.id;
|
||||
if (!id) {
|
||||
console.error('用户ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getMenus(id);
|
||||
} catch (error) {
|
||||
console.error('获取菜单数据失败:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dashboard {
|
||||
min-height: 100%;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
// 欢迎区域
|
||||
.welcome-section {
|
||||
margin-bottom: 16px;
|
||||
padding: 32px;
|
||||
background: linear-gradient(135deg, #062da3 0%, #4f84ff 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(6, 45, 163, 0.2);
|
||||
|
||||
.welcome-content {
|
||||
.welcome-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.stat-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.el-icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.income .stat-icon-wrapper {
|
||||
background: linear-gradient(135deg, #062da3 0%, #4f84ff 100%);
|
||||
}
|
||||
|
||||
&.users .stat-icon-wrapper {
|
||||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||
}
|
||||
|
||||
&.orders .stat-icon-wrapper {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
|
||||
&.active .stat-icon-wrapper {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
&.knowledge .stat-icon-wrapper {
|
||||
background: linear-gradient(135deg, #062da3 0%, #4f84ff 100%);
|
||||
}
|
||||
|
||||
&.employees .stat-icon-wrapper {
|
||||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||
}
|
||||
|
||||
&.tenants .stat-icon-wrapper {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
&.up {
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
&.down {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
&.flat {
|
||||
color: var(--el-text-color-placeholder);
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图表区域
|
||||
.charts-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.chart-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 280px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 列表区域
|
||||
.lists-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.list-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-lighter);
|
||||
margin: 0 -24px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&.done {
|
||||
.task-info {
|
||||
margin-left: 20px;
|
||||
.task-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--el-text-color-placeholder);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.task-title {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.task-date {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-lighter);
|
||||
margin: 0 -24px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
|
||||
&.operation {
|
||||
background-color: rgba(79, 132, 255, 0.2);
|
||||
color: #4f84ff;
|
||||
}
|
||||
|
||||
&.access {
|
||||
background-color: rgba(85, 190, 130, 0.2);
|
||||
color: #55be82;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.activity-text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
|
||||
.activity-module {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.activity-action {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 1200px) {
|
||||
.charts-section,
|
||||
.lists-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-section {
|
||||
padding: 24px 16px;
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.priority-0 {
|
||||
color: #e6a23c;
|
||||
background-color: #fdf6ec;
|
||||
border-color: #f5dab1;
|
||||
}
|
||||
|
||||
.priority-1 {
|
||||
color: #67c23a;
|
||||
background-color: rgb(240, 249, 235);
|
||||
border-color: rgb(225, 243, 216);
|
||||
}
|
||||
|
||||
.priority-2 {
|
||||
color: #f56c6c;
|
||||
background-color: #fef0f0;
|
||||
border-color: #fbc4c4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
this is home
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,584 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { login } from "@/api/login";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const account = ref("");
|
||||
const password = ref("");
|
||||
const passwordVisible = ref(false);
|
||||
const rememberMe = ref(false);
|
||||
const loading = ref(false);
|
||||
const errorMsg = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
const savedUser = localStorage.getItem("loginAccount");
|
||||
const savedRemember = localStorage.getItem("loginRememberMe");
|
||||
|
||||
if (savedRemember === "true") {
|
||||
account.value = savedUser;
|
||||
rememberMe.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
errorMsg.value = "";
|
||||
if (!account.value || !password.value) {
|
||||
errorMsg.value = "请输入用户名和密码";
|
||||
return;
|
||||
}
|
||||
|
||||
// 记住我本地存储
|
||||
if (rememberMe.value) {
|
||||
localStorage.setItem("loginAccount", account.value);
|
||||
localStorage.setItem("loginRememberMe", "true");
|
||||
} else {
|
||||
localStorage.removeItem("loginAccount");
|
||||
localStorage.setItem("loginRememberMe", "false");
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await login(account.value, password.value);
|
||||
if (res && res.code === 200) {
|
||||
authStore.setLoginInfo(res.data);
|
||||
|
||||
// 登录成功后重置 tabs store 为初始状态
|
||||
const { useTabsStore } = await import("@/stores");
|
||||
const tabsStore = useTabsStore();
|
||||
tabsStore.resetTabs();
|
||||
|
||||
// 登录成功后缓存菜单
|
||||
try {
|
||||
} catch (menuError) {
|
||||
console.error("Failed to process login", menuError);
|
||||
// 菜单加载失败不影响登录流程
|
||||
}
|
||||
|
||||
router.push({ path: "/dashboard" });
|
||||
} else {
|
||||
errorMsg.value = res.msg || "登录失败";
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.value =
|
||||
err?.response?.data?.msg || err?.message || "登录失败,请重试";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转注册、忘记密码页面
|
||||
const goRegister = () => {
|
||||
router.push({ path: "/register" });
|
||||
};
|
||||
const goForget = () => {
|
||||
router.push({ path: "/forget" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-bg">
|
||||
<div class="login-card">
|
||||
<div class="login-side">
|
||||
<div class="brand">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect width="48" height="48" rx="18" fill="#eef8fc" />
|
||||
<circle cx="24" cy="24" r="14" fill="#52a8ff" opacity="0.15" />
|
||||
<circle cx="24" cy="24" r="9" fill="#3b7ddd" opacity="0.12" />
|
||||
<text
|
||||
x="24"
|
||||
y="30"
|
||||
text-anchor="middle"
|
||||
fill="#2d5fa7"
|
||||
font-size="16"
|
||||
font-family="Arial"
|
||||
font-weight="bold"
|
||||
>
|
||||
Mete
|
||||
</text>
|
||||
</svg>
|
||||
<span class="brand-title">后台管理系统</span>
|
||||
</div>
|
||||
<div class="illus">
|
||||
<svg viewBox="0 0 300 160" style="max-width: 100%" fill="none">
|
||||
<ellipse cx="150" cy="140" rx="120" ry="16" fill="#edf4fd" />
|
||||
<rect x="57" y="58" width="60" height="40" rx="12" fill="#64b6f7" />
|
||||
<rect
|
||||
x="125"
|
||||
y="46"
|
||||
width="110"
|
||||
height="64"
|
||||
rx="14"
|
||||
fill="#389bf7"
|
||||
opacity="0.11"
|
||||
/>
|
||||
<rect
|
||||
x="136"
|
||||
y="60"
|
||||
width="60"
|
||||
height="41"
|
||||
rx="10"
|
||||
fill="#b8e1ff"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 版权信息 -->
|
||||
<div class="copyright">© 2026 Mete 管理系统</div>
|
||||
</div>
|
||||
<div class="login-panel">
|
||||
<h2 class="login-title">欢迎登录</h2>
|
||||
<div class="login-desc">请填写您的账号信息</div>
|
||||
<div class="form-group icon-input-group">
|
||||
<span class="input-icon">
|
||||
<!-- 用户图标 -->
|
||||
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
|
||||
<circle
|
||||
cx="10"
|
||||
cy="7"
|
||||
r="3.2"
|
||||
stroke="#4da1ff"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<ellipse
|
||||
cx="10"
|
||||
cy="14.1"
|
||||
rx="5.5"
|
||||
ry="3.3"
|
||||
stroke="#4da1ff"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="account"
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
autocomplete="account"
|
||||
class="input input-with-icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group icon-input-group">
|
||||
<span class="input-icon">
|
||||
<!-- 密码图标 -->
|
||||
<svg width="19" height="19" viewBox="0 0 20 20" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="8"
|
||||
width="14"
|
||||
height="7"
|
||||
rx="2"
|
||||
stroke="#4da1ff"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<circle
|
||||
cx="10"
|
||||
cy="11.5"
|
||||
r="1.5"
|
||||
stroke="#4da1ff"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<rect
|
||||
x="7"
|
||||
y="5"
|
||||
width="6"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
stroke="#4da1ff"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="password"
|
||||
:type="passwordVisible ? 'text' : 'password'"
|
||||
placeholder="密码"
|
||||
autocomplete="current-password"
|
||||
class="input input-with-icon"
|
||||
/>
|
||||
<span
|
||||
class="visible-btn"
|
||||
@click="passwordVisible = !passwordVisible"
|
||||
:title="passwordVisible ? '隐藏密码' : '显示密码'"
|
||||
>
|
||||
<svg
|
||||
v-if="passwordVisible"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<!-- 可见(eye)图标 -->
|
||||
<path
|
||||
d="M2 10c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z"
|
||||
stroke="#7bb7fa"
|
||||
stroke-width="1.4"
|
||||
fill="#eef5ff"
|
||||
/>
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="2.5"
|
||||
stroke="#3794f7"
|
||||
stroke-width="1.4"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else width="20" height="20" fill="none" viewBox="0 0 20 20">
|
||||
<!-- 不可见(eye-off)图标 -->
|
||||
<path
|
||||
d="M2 10c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z"
|
||||
stroke="#b7c7db"
|
||||
stroke-width="1.3"
|
||||
fill="#f2f6fd"
|
||||
/>
|
||||
<path d="M5 15L15 5" stroke="#b7c7db" stroke-width="1.2" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="remember-me-row">
|
||||
<label class="remember-me-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="rememberMe"
|
||||
class="remember-me-checkbox"
|
||||
/>
|
||||
<span>记住我</span>
|
||||
</label>
|
||||
<div class="action-links">
|
||||
<a class="register-link" @click.prevent="goRegister">注册账号</a>
|
||||
<span class="divider">|</span>
|
||||
<a class="forget-link" @click.prevent="goForget">忘记密码?</a>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
|
||||
</transition>
|
||||
<button class="login-btn" @click="handleLogin" :disabled="loading">
|
||||
{{ loading ? "登录中..." : "登 录" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 背景光效 -->
|
||||
<div class="login-light light1"></div>
|
||||
<div class="login-light light2"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-bg {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
background: linear-gradient(120deg, #e6f0ff 0%, #f5fcff 55%, #eaf6ff 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-card {
|
||||
display: flex;
|
||||
min-width: 770px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 8px 36px 0 rgba(73, 150, 255, 0.14),
|
||||
0 1.5px 4px 0 rgba(30, 42, 79, 0.05);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
.login-side {
|
||||
width: 320px;
|
||||
background: #52a8ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 44px 16px 32px 16px;
|
||||
box-shadow: 4px 0 32px 0 rgba(189, 231, 255, 0.13) inset;
|
||||
position: relative;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 42px;
|
||||
user-select: none;
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 25px;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
/* text-shadow: 0 0 6px #d4ecfc; */
|
||||
}
|
||||
.illus {
|
||||
margin-top: 30px;
|
||||
user-select: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
/* 版权信息样式 */
|
||||
.copyright {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
margin-top: auto;
|
||||
margin-bottom: 2px;
|
||||
letter-spacing: 0.2px;
|
||||
padding-top: 25px;
|
||||
user-select: none;
|
||||
}
|
||||
.login-panel {
|
||||
flex: 1;
|
||||
padding: 52px 54px 48px 54px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
min-width: 320px;
|
||||
}
|
||||
.login-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #2560a9;
|
||||
text-align: left;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.login-desc {
|
||||
color: #7391c4;
|
||||
font-size: 15px;
|
||||
margin-bottom: 28px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 输入框前置图标样式 */
|
||||
.icon-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.input-with-icon {
|
||||
padding-left: 36px !important;
|
||||
}
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #4da1ff;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.visible-btn {
|
||||
position: absolute;
|
||||
right: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
padding: 2px 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.82;
|
||||
user-select: none;
|
||||
}
|
||||
.visible-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
/* 防止密码输入框可见按钮和输入内容重叠 */
|
||||
.icon-input-group .input-with-icon {
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
font-size: 16px;
|
||||
border: 1.3px solid #d6e6fa;
|
||||
border-radius: 7px;
|
||||
box-sizing: border-box;
|
||||
transition: border 0.2s, box-shadow 0.2s;
|
||||
background: #f7fbfe;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #4da1ff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #e3f2ffb1;
|
||||
}
|
||||
|
||||
/* 记住我单选框 */
|
||||
.remember-me-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
margin-top: 3px;
|
||||
min-height: 32px;
|
||||
font-size: 15px;
|
||||
color: #6d8eb8;
|
||||
user-select: none;
|
||||
}
|
||||
.remember-me-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.remember-me-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #4da1ff;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 13px 0;
|
||||
font-size: 17px;
|
||||
background: linear-gradient(90deg, #3494e6 0%, #52a8ff 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 2px 12px 0 rgba(81, 173, 255, 0.13);
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.13s;
|
||||
}
|
||||
.login-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.login-btn:disabled {
|
||||
background: #b6dafc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-msg {
|
||||
color: #e4574a;
|
||||
background: #fdeceb;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
padding: 7px 4px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14.5px;
|
||||
letter-spacing: 0.3px;
|
||||
animation: shake 0.28s;
|
||||
}
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 注册、忘记密码链接 */
|
||||
.action-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
font-size: 14.1px;
|
||||
color: #6592c3;
|
||||
margin-bottom: 0;
|
||||
min-height: 22px;
|
||||
}
|
||||
.action-links a {
|
||||
cursor: pointer;
|
||||
color: #407ad6;
|
||||
text-decoration: none;
|
||||
transition: color 0.16s;
|
||||
}
|
||||
.action-links a:hover {
|
||||
color: #165eec;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.action-links .divider {
|
||||
color: #bbd3ee;
|
||||
margin: 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.register-link {
|
||||
margin-right: 0px;
|
||||
}
|
||||
.forget-link {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* 渐隐提示 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.24s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 炫彩光斑装饰 */
|
||||
.login-light {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(45px);
|
||||
opacity: 0.4;
|
||||
z-index: 1;
|
||||
}
|
||||
.light1 {
|
||||
width: 340px;
|
||||
height: 340px;
|
||||
top: -90px;
|
||||
left: -60px;
|
||||
background: radial-gradient(circle at 60% 50%, #55b7f988 0%, #e1e8fa11 95%);
|
||||
}
|
||||
.light2 {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
right: -60px;
|
||||
bottom: -90px;
|
||||
background: radial-gradient(circle at 55% 60%, #f3e7ff99 0%, #daf3ff10 100%);
|
||||
}
|
||||
@media (max-width: 940px) {
|
||||
.login-card {
|
||||
min-width: 330px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.login-side {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 0 0 18px 18px;
|
||||
}
|
||||
.login-panel {
|
||||
padding: 30px 22px 34px 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
.copyright {
|
||||
padding-top: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="onepage-container">
|
||||
<div v-if="loading" class="loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="error" class="error">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<p>{{ error }}</p>
|
||||
<el-button @click="loadOnePage">重试</el-button>
|
||||
</div>
|
||||
<div v-else-if="onePage" class="content">
|
||||
<h1 class="title">{{ onePage.title }}</h1>
|
||||
<div class="page-content" v-html="onePage.content"></div>
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<el-icon><DocumentDelete /></el-icon>
|
||||
<p>单页内容不存在</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Loading, WarningFilled, DocumentDelete } from '@element-plus/icons-vue';
|
||||
import { getOnePageByPath } from '@/api/onepage';
|
||||
|
||||
interface OnePage {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const onePage = ref<OnePage | null>(null);
|
||||
|
||||
// 根据路由路径获取单页内容
|
||||
const loadOnePage = async () => {
|
||||
const path = route.path;
|
||||
if (!path) {
|
||||
error.value = '路由路径不存在';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await getOnePageByPath(path);
|
||||
if (result.code === 200 && result.data) {
|
||||
onePage.value = result.data;
|
||||
} else {
|
||||
error.value = result.msg || '单页内容不存在';
|
||||
onePage.value = null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '加载单页内容失败';
|
||||
onePage.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOnePage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.onepage-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.not-found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
gap: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.error,
|
||||
.not-found {
|
||||
.el-icon {
|
||||
font-size: 48px;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
line-height: 1.8;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
|
||||
:deep(p) {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
background-color: var(--el-fill-color-light);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background-color: var(--el-fill-color-light);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
// Settings 父路由组件,用于显示子路由
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Settings 父路由容器 */
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div class="system-info-container">
|
||||
<el-card class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><InfoFilled /></el-icon>
|
||||
<span class="header-title">系统信息</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="info-content">
|
||||
<!-- 系统基本信息 -->
|
||||
<el-descriptions title="系统基本信息" :column="2" border class="info-section">
|
||||
<el-descriptions-item label="系统名称">
|
||||
<el-tag type="primary">{{ systemInfo.name || '云泽管理系统' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="系统版本">
|
||||
<el-tag>{{ systemInfo.version || '1.0.0' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="运行环境">
|
||||
<el-tag type="success">{{ systemInfo.environment || 'Production' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="系统时间">
|
||||
{{ currentTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="运行时间">
|
||||
{{ systemInfo.uptime || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="时区">
|
||||
{{ systemInfo.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 服务器信息 -->
|
||||
<el-descriptions title="服务器信息" :column="2" border class="info-section">
|
||||
<el-descriptions-item label="服务器地址">
|
||||
{{ systemInfo.serverHost || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="API地址">
|
||||
{{ apiBaseUrl }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作系统">
|
||||
{{ systemInfo.os || clientInfo.os }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="CPU核心数">
|
||||
{{ systemInfo.cpuCores || clientInfo.cpuCores }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="内存使用">
|
||||
<el-progress
|
||||
:percentage="systemInfo.memoryUsage || 0"
|
||||
:color="getMemoryColor(systemInfo.memoryUsage)"
|
||||
:format="(percentage) => `${percentage}%`"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="磁盘使用">
|
||||
<el-progress
|
||||
:percentage="systemInfo.diskUsage || 0"
|
||||
:color="getDiskColor(systemInfo.diskUsage)"
|
||||
:format="(percentage) => `${percentage}%`"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 数据库信息 -->
|
||||
<el-descriptions title="数据库信息" :column="2" border class="info-section">
|
||||
<el-descriptions-item label="数据库类型">
|
||||
<el-tag type="info">{{ systemInfo.dbType || 'MySQL' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库版本">
|
||||
{{ systemInfo.dbVersion || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库状态">
|
||||
<el-tag :type="systemInfo.dbStatus === 'connected' ? 'success' : 'danger'">
|
||||
{{ systemInfo.dbStatus === 'connected' ? '已连接' : '未连接' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="连接数">
|
||||
{{ systemInfo.dbConnections || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 客户端信息 -->
|
||||
<el-descriptions title="客户端信息" :column="2" border class="info-section">
|
||||
<el-descriptions-item label="浏览器">
|
||||
{{ clientInfo.browser }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="浏览器版本">
|
||||
{{ clientInfo.browserVersion }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作系统">
|
||||
{{ clientInfo.os }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="屏幕分辨率">
|
||||
{{ clientInfo.screenResolution }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="设备类型">
|
||||
<el-tag :type="clientInfo.isMobile ? 'warning' : 'success'">
|
||||
{{ clientInfo.isMobile ? '移动设备' : '桌面设备' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户代理">
|
||||
<span class="user-agent">{{ clientInfo.userAgent }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 应用信息 -->
|
||||
<el-descriptions title="应用信息" :column="2" border class="info-section">
|
||||
<el-descriptions-item label="前端框架">
|
||||
<el-tag type="success">Vue 3</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="UI框架">
|
||||
<el-tag type="primary">Element Plus</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="构建工具">
|
||||
<el-tag type="info">Vite</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Node版本">
|
||||
{{ clientInfo.nodeVersion || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" @click="refreshInfo" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新信息
|
||||
</el-button>
|
||||
<el-button @click="copySystemInfo">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
复制信息
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { InfoFilled, Refresh, DocumentCopy } from '@element-plus/icons-vue';
|
||||
|
||||
// 系统信息数据
|
||||
const loading = ref(false);
|
||||
const systemInfo = ref<any>({});
|
||||
const currentTime = ref(new Date().toLocaleString('zh-CN'));
|
||||
|
||||
// API基础地址
|
||||
const apiBaseUrl = computed(() => {
|
||||
return import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
});
|
||||
|
||||
// 客户端信息
|
||||
const clientInfo = computed(() => {
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
// 检测浏览器
|
||||
let browser = 'Unknown';
|
||||
let browserVersion = '';
|
||||
if (ua.includes('Chrome') && !ua.includes('Edg')) {
|
||||
browser = 'Chrome';
|
||||
const match = ua.match(/Chrome\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('Firefox')) {
|
||||
browser = 'Firefox';
|
||||
const match = ua.match(/Firefox\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
||||
browser = 'Safari';
|
||||
const match = ua.match(/Version\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('Edg')) {
|
||||
browser = 'Edge';
|
||||
const match = ua.match(/Edg\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
}
|
||||
|
||||
// 检测操作系统
|
||||
let os = 'Unknown';
|
||||
if (ua.includes('Win')) os = 'Windows';
|
||||
else if (ua.includes('Mac')) os = 'macOS';
|
||||
else if (ua.includes('Linux')) os = 'Linux';
|
||||
else if (ua.includes('Android')) os = 'Android';
|
||||
else if (ua.includes('iOS')) os = 'iOS';
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
||||
|
||||
return {
|
||||
browser,
|
||||
browserVersion,
|
||||
os,
|
||||
screenResolution: `${screen.width}x${screen.height}`,
|
||||
isMobile,
|
||||
userAgent: ua,
|
||||
cpuCores: navigator.hardwareConcurrency || '-',
|
||||
nodeVersion: '-', // Node.js 版本在浏览器环境中不可用
|
||||
};
|
||||
});
|
||||
|
||||
// 获取内存使用颜色
|
||||
const getMemoryColor = (usage: number | undefined) => {
|
||||
if (!usage) return '#409eff';
|
||||
if (usage >= 90) return '#f56c6c';
|
||||
if (usage >= 70) return '#e6a23c';
|
||||
return '#67c23a';
|
||||
};
|
||||
|
||||
// 获取磁盘使用颜色
|
||||
const getDiskColor = (usage: number | undefined) => {
|
||||
if (!usage) return '#409eff';
|
||||
if (usage >= 90) return '#f56c6c';
|
||||
if (usage >= 70) return '#e6a23c';
|
||||
return '#67c23a';
|
||||
};
|
||||
|
||||
// 获取系统信息
|
||||
async function fetchSystemInfo() {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 如果有后端API,可以在这里调用
|
||||
// const res = await getSystemInfo();
|
||||
// if (res.success && res.data) {
|
||||
// systemInfo.value = res.data;
|
||||
// }
|
||||
|
||||
// 模拟数据(如果有API会替换)
|
||||
systemInfo.value = {
|
||||
name: '云泽管理系统',
|
||||
version: '1.0.0',
|
||||
environment: 'Production',
|
||||
uptime: '24天 5小时 30分钟',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
serverHost: window.location.hostname,
|
||||
os: 'Linux',
|
||||
cpuCores: '4',
|
||||
memoryUsage: 65,
|
||||
diskUsage: 42,
|
||||
dbType: 'MySQL',
|
||||
dbVersion: '8.0',
|
||||
dbStatus: 'connected',
|
||||
dbConnections: 15,
|
||||
};
|
||||
} catch (err: any) {
|
||||
ElMessage.error('获取系统信息失败:' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新信息
|
||||
function refreshInfo() {
|
||||
fetchSystemInfo();
|
||||
currentTime.value = new Date().toLocaleString('zh-CN');
|
||||
ElMessage.success('信息已刷新');
|
||||
}
|
||||
|
||||
// 复制系统信息
|
||||
function copySystemInfo() {
|
||||
const infoText = `系统信息报告
|
||||
==================
|
||||
系统名称:${systemInfo.value.name || '-'}
|
||||
系统版本:${systemInfo.value.version || '-'}
|
||||
运行环境:${systemInfo.value.environment || '-'}
|
||||
系统时间:${currentTime.value}
|
||||
运行时间:${systemInfo.value.uptime || '-'}
|
||||
|
||||
服务器信息:
|
||||
- 服务器地址:${systemInfo.value.serverHost || '-'}
|
||||
- API地址:${apiBaseUrl.value}
|
||||
- 操作系统:${systemInfo.value.os || '-'}
|
||||
- CPU核心数:${systemInfo.value.cpuCores || '-'}
|
||||
- 内存使用:${systemInfo.value.memoryUsage || 0}%
|
||||
- 磁盘使用:${systemInfo.value.diskUsage || 0}%
|
||||
|
||||
数据库信息:
|
||||
- 数据库类型:${systemInfo.value.dbType || '-'}
|
||||
- 数据库版本:${systemInfo.value.dbVersion || '-'}
|
||||
- 数据库状态:${systemInfo.value.dbStatus === 'connected' ? '已连接' : '未连接'}
|
||||
- 连接数:${systemInfo.value.dbConnections || '-'}
|
||||
|
||||
客户端信息:
|
||||
- 浏览器:${clientInfo.value.browser} ${clientInfo.value.browserVersion}
|
||||
- 操作系统:${clientInfo.value.os}
|
||||
- 屏幕分辨率:${clientInfo.value.screenResolution}
|
||||
- 设备类型:${clientInfo.value.isMobile ? '移动设备' : '桌面设备'}
|
||||
|
||||
生成时间:${new Date().toLocaleString('zh-CN')}`;
|
||||
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(infoText).then(() => {
|
||||
ElMessage.success('系统信息已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败,请手动复制');
|
||||
});
|
||||
} else {
|
||||
// 降级方案
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = infoText;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
ElMessage.success('系统信息已复制到剪贴板');
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制');
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新时间
|
||||
let timeInterval: NodeJS.Timeout | null = null;
|
||||
onMounted(() => {
|
||||
fetchSystemInfo();
|
||||
// 每秒更新时间
|
||||
timeInterval = setInterval(() => {
|
||||
currentTime.value = new Date().toLocaleString('zh-CN');
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) {
|
||||
clearInterval(timeInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.system-info-container {
|
||||
padding: 24px;
|
||||
background-color: var(--el-bg-color-page);
|
||||
min-height: 100%;
|
||||
|
||||
.info-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.info-content {
|
||||
.info-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
:deep(.el-descriptions__title) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #4f84ff;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__label) {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__content) {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.user-agent {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.system-info-container {
|
||||
padding: 12px;
|
||||
|
||||
.info-card {
|
||||
.info-content {
|
||||
.info-section {
|
||||
:deep(.el-descriptions) {
|
||||
:deep(.el-descriptions__table) {
|
||||
.el-descriptions__cell {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<!-- 添加/编辑Banner对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentBanner"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="bannerFormRef"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="currentBanner.title"
|
||||
placeholder="请输入Banner标题"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="简介" prop="desc">
|
||||
<el-input
|
||||
v-model="currentBanner.desc"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入Banner简介"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="跳转地址" prop="url">
|
||||
<el-input
|
||||
v-model="currentBanner.url"
|
||||
placeholder="例如:https://www.example.com 或 /page/detail"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
支持外部链接(http://)和内部路由(/)
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Banner图片" prop="image">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="*"
|
||||
>
|
||||
<img
|
||||
v-if="currentBanner.image"
|
||||
:src="getImageUrl(currentBanner.image)"
|
||||
class="image-preview"
|
||||
/>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传图片</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
建议尺寸:1920x600,支持 jpg、png、gif 格式,大小不超过 5MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="currentBanner.image"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
删除图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentBanner.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
数字越小,排序越靠前
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
|
||||
// 定义Banner数据类型
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
image: string;
|
||||
sort: number;
|
||||
create_time?: number;
|
||||
update_time?: number;
|
||||
delete_time?: number;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
banner: Partial<Banner> | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
banner: null,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", banner: Partial<Banner>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const bannerFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的Banner
|
||||
const currentBanner = ref<Partial<Banner>>({
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
// 上传配置
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
const uploadUrl = ref(API_BASE_URL + "/admin/uploadfiles");
|
||||
const uploadHeaders = ref({
|
||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||
});
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
return props.banner?.id && props.banner.id > 0 ? "编辑Banner" : "添加Banner";
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入Banner标题", trigger: "blur" }],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// 监听props变化,更新当前Banner
|
||||
watch(
|
||||
() => props.banner,
|
||||
(newBanner) => {
|
||||
if (newBanner) {
|
||||
currentBanner.value = {
|
||||
...newBanner,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && (!props.banner || !props.banner.id)) {
|
||||
// 新增时重置表单
|
||||
currentBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file: any) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
// 直接保存相对路径,不拼接API_BASE_URL
|
||||
currentBanner.value.image = response.data.url || response.data.path;
|
||||
ElMessage.success("图片上传成功");
|
||||
} else {
|
||||
ElMessage.error(response.msg || "图片上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error("图片上传失败,请重试");
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
currentBanner.value.image = "";
|
||||
ElMessage.success("图片已删除");
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return "";
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 保存Banner
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!bannerFormRef.value) return;
|
||||
const valid = await bannerFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 准备提交数据
|
||||
const payload = { ...currentBanner.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>Banner管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddBanner">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加Banner
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="bannerList"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
|
||||
<el-table-column prop="title" label="标题" min-width="150" />
|
||||
|
||||
<el-table-column label="图片" width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-image
|
||||
v-if="scope.row.image"
|
||||
:src="getImageUrl(scope.row.image)"
|
||||
:preview-src-list="[getImageUrl(scope.row.image)]"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 60px; border-radius: 4px; cursor: pointer;"
|
||||
/>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="desc" label="简介" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.desc">{{ scope.row.desc }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="url" label="跳转地址" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-link v-if="scope.row.url" :href="scope.row.url" target="_blank" type="primary">
|
||||
{{ scope.row.url }}
|
||||
</el-link>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="100"
|
||||
align="center"
|
||||
sortable
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEditBanner(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteBanner(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<BannerEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:banner="dialogBanner"
|
||||
@save="handleBannerSave"
|
||||
@cancel="handleBannerCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getBanners,
|
||||
createBanner,
|
||||
editBanner,
|
||||
deleteBanner,
|
||||
} from "@/api/banner";
|
||||
import BannerEdit from "./components/edit.vue";
|
||||
|
||||
// 定义Banner数据类型
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
image: string;
|
||||
sort: number;
|
||||
create_time?: number;
|
||||
update_time?: number;
|
||||
delete_time?: number;
|
||||
}
|
||||
|
||||
// Banner列表
|
||||
const bannerList = ref<Banner[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogBanner = ref<Partial<Banner> | null>(null);
|
||||
|
||||
// 获取Banner列表
|
||||
const fetchBanners = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await getBanners();
|
||||
if (result.code === 200) {
|
||||
bannerList.value = result.data || [];
|
||||
} else {
|
||||
ElMessage.error("获取Banner列表失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取Banner列表失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchBanners();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加Banner
|
||||
const handleAddBanner = () => {
|
||||
dialogBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑Banner
|
||||
const handleEditBanner = (banner: Banner) => {
|
||||
dialogBanner.value = { ...banner };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除Banner
|
||||
const handleDeleteBanner = (banner: Banner) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除Banner "${banner.title}" 吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteBanner(banner.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchBanners();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理Banner保存
|
||||
const handleBannerSave = async (banner: Partial<Banner>) => {
|
||||
try {
|
||||
const payload = { ...banner };
|
||||
|
||||
// 判断是新增还是编辑
|
||||
if (!banner.id || banner.id === 0) {
|
||||
// 新增Banner
|
||||
const result = await createBanner(payload as Banner);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "Banner添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchBanners();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的Banner
|
||||
const result = await editBanner(banner.id!, payload as Banner);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchBanners();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理Banner取消
|
||||
const handleBannerCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于显示)
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 组件挂载时加载Banner列表
|
||||
onMounted(() => {
|
||||
fetchBanners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:title="isEditing ? '编辑字典项' : '添加字典项'"
|
||||
v-model="visible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
:model="dictItemForm"
|
||||
:rules="formRules"
|
||||
ref="dictItemFormRef"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="字典类型" prop="dict_type_id">
|
||||
<el-input
|
||||
:value="dictTypeDisplayName"
|
||||
disabled
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">当前字典类型:{{ dictTypeDisplayName }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="字典标签" prop="dict_label">
|
||||
<el-input
|
||||
v-model="dictItemForm.dict_label"
|
||||
placeholder="请输入字典标签(显示值)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="字典值" prop="dict_value">
|
||||
<el-input
|
||||
v-model="dictItemForm.dict_value"
|
||||
placeholder="请输入字典值(存储值)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="父级字典项" prop="parent_id">
|
||||
<el-select
|
||||
v-model="dictItemForm.parent_id"
|
||||
placeholder="选择父级字典项(可选)"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in parentDictItems"
|
||||
:key="item.id"
|
||||
:label="item.dict_label"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="dictItemForm.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="dictItemForm.sort"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色" prop="color">
|
||||
<el-input
|
||||
v-model="dictItemForm.color"
|
||||
placeholder="请输入颜色值(如:#FF0000 或 red)"
|
||||
>
|
||||
<template #append>
|
||||
<el-color-picker
|
||||
v-model="dictItemForm.color"
|
||||
:predefine="predefineColors"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="form-tip">支持十六进制颜色值(如:#FF0000)或颜色名称(如:red)</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标" prop="icon">
|
||||
<el-input
|
||||
v-model="dictItemForm.icon"
|
||||
placeholder="请输入图标(如:✓、✗、⭐)"
|
||||
/>
|
||||
<div class="form-tip">支持emoji或图标字符</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="dictItemForm.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { addDictItem, updateDictItem, getDictItems } from '@/api/dict';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
dictItem?: any;
|
||||
dictTypeId?: number;
|
||||
dictTypeName?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
dictItem: null,
|
||||
dictTypeId: undefined,
|
||||
dictTypeName: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const dictItemFormRef = ref<FormInstance>();
|
||||
const parentDictItems = ref<any[]>([]);
|
||||
|
||||
// 预定义颜色
|
||||
const predefineColors = [
|
||||
'#ff4500',
|
||||
'#ff8c00',
|
||||
'#ffd700',
|
||||
'#90ee90',
|
||||
'#00ced1',
|
||||
'#1e90ff',
|
||||
'#c71585',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#0000ff',
|
||||
];
|
||||
|
||||
// 判断是否为编辑模式
|
||||
const isEditing = computed(() => {
|
||||
return !!(props.dictItem && props.dictItem.id);
|
||||
});
|
||||
|
||||
// 表单数据
|
||||
const dictItemForm = reactive({
|
||||
id: null as number | null,
|
||||
dict_type_id: 0,
|
||||
dict_label: '',
|
||||
dict_value: '',
|
||||
parent_id: 0,
|
||||
status: 1,
|
||||
sort: 0,
|
||||
color: '',
|
||||
icon: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules: FormRules = {
|
||||
dict_label: [
|
||||
{ required: true, message: '请输入字典标签', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
|
||||
],
|
||||
dict_value: [
|
||||
{ required: true, message: '请输入字典值', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
|
||||
],
|
||||
sort: [
|
||||
{ required: true, message: '请输入排序', trigger: 'blur' },
|
||||
{ type: 'number', min: 0, message: '排序必须大于等于 0', trigger: 'blur' },
|
||||
],
|
||||
color: [
|
||||
{ max: 20, message: '颜色值长度不能超过 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
icon: [
|
||||
{ max: 50, message: '图标长度不能超过 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
remark: [
|
||||
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
// 获取父级字典项列表(排除自己)
|
||||
async function fetchParentDictItems() {
|
||||
if (!props.dictTypeId) return;
|
||||
try {
|
||||
const res = await getDictItems({ dict_type_id: props.dictTypeId });
|
||||
if (res.success && res.data) {
|
||||
// 如果是编辑模式,排除当前字典项
|
||||
if (isEditing.value && props.dictItem) {
|
||||
parentDictItems.value = res.data.filter(
|
||||
(item: any) => item.id !== props.dictItem.id
|
||||
);
|
||||
} else {
|
||||
parentDictItems.value = res.data;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取父级字典项失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算字典类型显示名称
|
||||
const dictTypeDisplayName = computed(() => {
|
||||
if (props.dictTypeName) {
|
||||
return props.dictTypeName;
|
||||
}
|
||||
if (props.dictTypeId) {
|
||||
return `字典类型 ID: ${props.dictTypeId}`;
|
||||
}
|
||||
return '未知字典类型';
|
||||
});
|
||||
|
||||
// 监听 dictItem 变化,填充表单数据
|
||||
watch(
|
||||
() => props.dictItem,
|
||||
(newDictItem) => {
|
||||
if (newDictItem) {
|
||||
dictItemForm.id = newDictItem.id || null;
|
||||
dictItemForm.dict_type_id = newDictItem.dict_type_id || props.dictTypeId || 0;
|
||||
dictItemForm.dict_label = newDictItem.dict_label || '';
|
||||
dictItemForm.dict_value = newDictItem.dict_value || '';
|
||||
dictItemForm.parent_id = newDictItem.parent_id || 0;
|
||||
dictItemForm.status = newDictItem.status !== undefined ? newDictItem.status : 1;
|
||||
dictItemForm.sort = newDictItem.sort || 0;
|
||||
dictItemForm.color = newDictItem.color || '';
|
||||
dictItemForm.icon = newDictItem.icon || '';
|
||||
dictItemForm.remark = newDictItem.remark || '';
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听弹窗打开,获取父级字典项
|
||||
watch(visible, (val) => {
|
||||
if (val) {
|
||||
if (props.dictTypeId) {
|
||||
dictItemForm.dict_type_id = props.dictTypeId;
|
||||
}
|
||||
fetchParentDictItems();
|
||||
}
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
dictItemForm.id = null;
|
||||
dictItemForm.dict_type_id = props.dictTypeId || 0;
|
||||
dictItemForm.dict_label = '';
|
||||
dictItemForm.dict_value = '';
|
||||
dictItemForm.parent_id = 0;
|
||||
dictItemForm.status = 1;
|
||||
dictItemForm.sort = 0;
|
||||
dictItemForm.color = '';
|
||||
dictItemForm.icon = '';
|
||||
dictItemForm.remark = '';
|
||||
dictItemFormRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!dictItemFormRef.value) return;
|
||||
|
||||
try {
|
||||
await dictItemFormRef.value.validate();
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dictItemForm.dict_type_id) {
|
||||
ElMessage.error('字典类型ID不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const submitData: any = {
|
||||
dict_type_id: dictItemForm.dict_type_id,
|
||||
dict_label: dictItemForm.dict_label,
|
||||
dict_value: dictItemForm.dict_value,
|
||||
parent_id: dictItemForm.parent_id || 0,
|
||||
status: dictItemForm.status,
|
||||
sort: dictItemForm.sort,
|
||||
color: dictItemForm.color || '',
|
||||
icon: dictItemForm.icon || '',
|
||||
remark: dictItemForm.remark || '',
|
||||
};
|
||||
|
||||
if (isEditing.value) {
|
||||
await updateDictItem(dictItemForm.id!, submitData);
|
||||
ElMessage.success('字典项更新成功');
|
||||
} else {
|
||||
await addDictItem(submitData);
|
||||
ElMessage.success('字典项添加成功');
|
||||
}
|
||||
|
||||
emit('success');
|
||||
handleClose();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败,请重试');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:title="isEditing ? '编辑字典项' : '添加字典项'"
|
||||
v-model="visible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
:model="dictItemForm"
|
||||
:rules="formRules"
|
||||
ref="dictItemFormRef"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="字典类型" prop="dict_type_id">
|
||||
<el-select
|
||||
v-model="dictItemForm.dict_type_id"
|
||||
placeholder="请选择字典类型"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
:disabled="isEditing || !!dictTypeId"
|
||||
>
|
||||
<el-option
|
||||
v-for="type in dictTypes"
|
||||
:key="type.id"
|
||||
:label="`${type.dict_name} (${type.dict_code})`"
|
||||
:value="type.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="字典标签" prop="dict_label">
|
||||
<el-input
|
||||
v-model="dictItemForm.dict_label"
|
||||
placeholder="请输入字典标签(显示值)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="字典值" prop="dict_value">
|
||||
<el-input
|
||||
v-model="dictItemForm.dict_value"
|
||||
placeholder="请输入字典值(存储值)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<!-- <el-form-item label="父级ID" prop="parent_id">
|
||||
<el-input-number
|
||||
v-model="dictItemForm.parent_id"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="0表示顶级"
|
||||
/>
|
||||
<div class="form-tip">0表示顶级字典项</div>
|
||||
</el-form-item> -->
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="dictItemForm.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序序号" prop="sort">
|
||||
<el-input-number
|
||||
v-model="dictItemForm.sort"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色标记" prop="color">
|
||||
<el-input
|
||||
v-model="dictItemForm.color"
|
||||
placeholder="请输入颜色值(如:#FF0000 或 red)"
|
||||
maxlength="20"
|
||||
/>
|
||||
<div class="form-tip">用于前端显示时的颜色标记</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标" prop="icon">
|
||||
<el-input
|
||||
v-model="dictItemForm.icon"
|
||||
placeholder="请输入图标名称或类名"
|
||||
maxlength="50"
|
||||
/>
|
||||
<div class="form-tip">用于前端显示时的图标</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="dictItemForm.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注信息"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed, onMounted } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { getDictTypes, addDictItem, updateDictItem } from '@/api/dict'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
dictItem?: any
|
||||
dictTypeId?: number | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
dictItem: null,
|
||||
dictTypeId: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const dictItemFormRef = ref<FormInstance>()
|
||||
const dictTypes = ref<any[]>([])
|
||||
|
||||
// 判断是否为编辑模式
|
||||
const isEditing = computed(() => {
|
||||
return !!(props.dictItem && props.dictItem.id)
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const dictItemForm = reactive({
|
||||
id: null as number | null,
|
||||
dict_type_id: null as number | null,
|
||||
dict_label: '',
|
||||
dict_value: '',
|
||||
parent_id: 0,
|
||||
status: 1,
|
||||
sort: 0,
|
||||
color: '',
|
||||
icon: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules: FormRules = {
|
||||
dict_type_id: [
|
||||
{ required: true, message: '请选择字典类型', trigger: 'change' },
|
||||
],
|
||||
dict_label: [
|
||||
{ required: true, message: '请输入字典标签', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
|
||||
],
|
||||
dict_value: [
|
||||
{ required: true, message: '请输入字典值', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
|
||||
],
|
||||
parent_id: [
|
||||
{ type: 'number', min: 0, message: '父级ID必须大于等于 0', trigger: 'blur' },
|
||||
],
|
||||
sort: [
|
||||
{ type: 'number', min: 0, message: '排序序号必须大于等于 0', trigger: 'blur' },
|
||||
],
|
||||
color: [
|
||||
{ max: 20, message: '颜色值长度不能超过 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
icon: [
|
||||
{ max: 50, message: '图标长度不能超过 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
remark: [
|
||||
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取字典类型列表
|
||||
async function fetchDictTypes() {
|
||||
try {
|
||||
const res = await getDictTypes()
|
||||
if (res.success === true && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data) ? res.data : []
|
||||
} else if (Array.isArray(res)) {
|
||||
dictTypes.value = res
|
||||
} else if (res && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data) ? res.data : []
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('获取字典类型列表失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 dictItem 变化,填充表单数据
|
||||
watch(
|
||||
() => props.dictItem,
|
||||
(newDictItem) => {
|
||||
if (newDictItem) {
|
||||
dictItemForm.id = newDictItem.id || null
|
||||
dictItemForm.dict_type_id = newDictItem.dict_type_id || null
|
||||
dictItemForm.dict_label = newDictItem.dict_label || ''
|
||||
dictItemForm.dict_value = newDictItem.dict_value || ''
|
||||
dictItemForm.parent_id = newDictItem.parent_id || 0
|
||||
dictItemForm.status = newDictItem.status !== undefined ? newDictItem.status : 1
|
||||
dictItemForm.sort = newDictItem.sort || 0
|
||||
dictItemForm.color = newDictItem.color || ''
|
||||
dictItemForm.icon = newDictItem.icon || ''
|
||||
dictItemForm.remark = newDictItem.remark || ''
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听 dictTypeId 变化
|
||||
watch(
|
||||
() => props.dictTypeId,
|
||||
(newTypeId) => {
|
||||
if (newTypeId && !isEditing.value) {
|
||||
dictItemForm.dict_type_id = newTypeId
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
dictItemForm.id = null
|
||||
dictItemForm.dict_type_id = props.dictTypeId || null
|
||||
dictItemForm.dict_label = ''
|
||||
dictItemForm.dict_value = ''
|
||||
dictItemForm.parent_id = 0
|
||||
dictItemForm.status = 1
|
||||
dictItemForm.sort = 0
|
||||
dictItemForm.color = ''
|
||||
dictItemForm.icon = ''
|
||||
dictItemForm.remark = ''
|
||||
dictItemFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!dictItemFormRef.value) return
|
||||
|
||||
try {
|
||||
await dictItemFormRef.value.validate()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const submitData: any = {
|
||||
dict_type_id: dictItemForm.dict_type_id,
|
||||
dict_label: dictItemForm.dict_label,
|
||||
dict_value: dictItemForm.dict_value,
|
||||
parent_id: dictItemForm.parent_id,
|
||||
status: dictItemForm.status,
|
||||
sort: dictItemForm.sort,
|
||||
color: dictItemForm.color || '',
|
||||
icon: dictItemForm.icon || '',
|
||||
remark: dictItemForm.remark || '',
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
const res = await updateDictItem(dictItemForm.id!, submitData)
|
||||
if (res.success === true || res.code === 0) {
|
||||
ElMessage.success('字典项更新成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} else {
|
||||
ElMessage.error(res.message || '更新失败,请重试')
|
||||
}
|
||||
} else {
|
||||
const res = await addDictItem(submitData)
|
||||
if (res.success === true || res.code === 0) {
|
||||
ElMessage.success('字典项添加成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} else {
|
||||
ElMessage.error(res.message || '添加失败,请重试')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败,请重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDictTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="dict-item-list">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-select
|
||||
v-model="selectedDictTypeId"
|
||||
placeholder="选择字典类型"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 250px"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="type in dictTypes"
|
||||
:key="type.id"
|
||||
:label="`${type.dict_name} (${type.dict_code})`"
|
||||
:value="type.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索字典项标签或值"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="statusFilter"
|
||||
placeholder="状态筛选"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!selectedDictTypeId"
|
||||
@click="handleAdd"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加字典项
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<el-alert
|
||||
v-if="!selectedDictTypeId"
|
||||
title="请先选择字典类型"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="error" class="error-state">
|
||||
<el-alert title="加载失败" :message="error" type="error" show-icon />
|
||||
<el-button type="primary" @click="fetchDictItems">重试</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 字典项列表 -->
|
||||
<div v-else-if="selectedDictTypeId">
|
||||
<el-table
|
||||
:data="filteredDictItems"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="dict_label" label="字典标签" min-width="150" align="center" />
|
||||
<el-table-column prop="dict_value" label="字典值" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.dict_value }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.parent_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="color" label="颜色" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.color" class="color-display">
|
||||
<span
|
||||
class="color-dot"
|
||||
:style="{ backgroundColor: row.color }"
|
||||
></span>
|
||||
<span>{{ row.color }}</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="icon" label="图标" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.icon">{{ row.icon }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="200" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<DictItemEditDialog
|
||||
v-model="editDialogVisible"
|
||||
:dict-item="currentDictItem"
|
||||
:dict-type-id="selectedDictTypeId"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
} from 'element-plus'
|
||||
import { Plus, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDictTypes,
|
||||
getDictItems,
|
||||
deleteDictItem,
|
||||
} from '@/api/dict'
|
||||
import DictItemEditDialog from './DictItemEditDialog.vue'
|
||||
|
||||
interface Props {
|
||||
selectedTypeId?: number | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedTypeId: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
const dictTypes = ref<any[]>([])
|
||||
const dictItems = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const statusFilter = ref<number | ''>('')
|
||||
const selectedDictTypeId = ref<number | null>(null)
|
||||
|
||||
const editDialogVisible = ref(false)
|
||||
const currentDictItem = ref<any>(null)
|
||||
|
||||
// 过滤后的字典项
|
||||
const filteredDictItems = computed(() => {
|
||||
let result = dictItems.value
|
||||
|
||||
// 搜索过滤
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
(item.dict_label && item.dict_label.toLowerCase().includes(keyword)) ||
|
||||
(item.dict_value && item.dict_value.toLowerCase().includes(keyword)) ||
|
||||
(item.remark && item.remark.toLowerCase().includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (statusFilter.value !== '') {
|
||||
result = result.filter((item) => item.status === statusFilter.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 监听外部传入的selectedTypeId
|
||||
watch(
|
||||
() => props.selectedTypeId,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
selectedDictTypeId.value = newId
|
||||
fetchDictItems()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return dateStr as string
|
||||
}
|
||||
}
|
||||
|
||||
// 获取字典类型列表(用于下拉选择)
|
||||
async function fetchDictTypes() {
|
||||
try {
|
||||
const res = await getDictTypes()
|
||||
if (res.success === true && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data) ? res.data : []
|
||||
} else if (Array.isArray(res)) {
|
||||
dictTypes.value = res
|
||||
} else if (res && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data) ? res.data : []
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('获取字典类型列表失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取字典项列表
|
||||
async function fetchDictItems() {
|
||||
if (!selectedDictTypeId.value) {
|
||||
dictItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await getDictItems({ dict_type_id: selectedDictTypeId.value })
|
||||
if (res.success === true && res.data) {
|
||||
dictItems.value = Array.isArray(res.data) ? res.data : []
|
||||
} else if (Array.isArray(res)) {
|
||||
dictItems.value = res
|
||||
} else if (res && res.data) {
|
||||
dictItems.value = Array.isArray(res.data) ? res.data : []
|
||||
} else {
|
||||
error.value = res.message || '获取字典项列表失败'
|
||||
dictItems.value = []
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取字典项列表失败'
|
||||
dictItems.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 字典类型改变
|
||||
const handleTypeChange = () => {
|
||||
fetchDictItems()
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑在computed中处理
|
||||
}
|
||||
|
||||
// 筛选
|
||||
const handleFilter = () => {
|
||||
// 筛选逻辑在computed中处理
|
||||
}
|
||||
|
||||
// 设置字典类型ID(供外部调用)
|
||||
function setTypeId(typeId: number) {
|
||||
selectedDictTypeId.value = typeId
|
||||
}
|
||||
|
||||
// 添加字典项
|
||||
function handleAdd() {
|
||||
if (!selectedDictTypeId.value) {
|
||||
ElMessage.warning('请先选择字典类型')
|
||||
return
|
||||
}
|
||||
currentDictItem.value = null
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑字典项
|
||||
function handleEdit(row: any) {
|
||||
currentDictItem.value = { ...row }
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除字典项
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除字典项「${row.dict_label}」吗?删除后不可恢复。`,
|
||||
'警告',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await deleteDictItem(row.id)
|
||||
if (res.success === true || res.code === 0) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchDictItems()
|
||||
emit('refresh')
|
||||
} else {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '删除失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
function handleEditSuccess() {
|
||||
fetchDictItems()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 刷新
|
||||
function refresh() {
|
||||
fetchDictTypes()
|
||||
if (selectedDictTypeId.value) {
|
||||
fetchDictItems()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
setTypeId,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchDictTypes()
|
||||
if (selectedDictTypeId.value) {
|
||||
fetchDictItems()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dict-item-list {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
|
||||
.color-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
padding: 32px 0 16px 0;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 5px;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:title="isEditing ? '编辑字典类型' : '添加字典类型'"
|
||||
v-model="visible"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
:model="dictTypeForm"
|
||||
:rules="formRules"
|
||||
ref="dictTypeFormRef"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="字典编码" prop="dict_code">
|
||||
<el-input
|
||||
v-model="dictTypeForm.dict_code"
|
||||
placeholder="请输入字典编码(如:user_status)"
|
||||
:disabled="isEditing"
|
||||
/>
|
||||
<div class="form-tip">只能包含字母、数字或下划线,且必须唯一</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="字典名称" prop="dict_name">
|
||||
<el-input
|
||||
v-model="dictTypeForm.dict_name"
|
||||
placeholder="请输入字典名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="dictTypeForm.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="shouldShowGlobalOption" label="全局展示" prop="is_global">
|
||||
<el-radio-group v-model="dictTypeForm.is_global">
|
||||
<el-radio :value="1">是</el-radio>
|
||||
<el-radio :value="0">否</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-tip">是:所有租户可见;否:仅租户内部可见</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序序号" prop="sort">
|
||||
<el-input-number
|
||||
v-model="dictTypeForm.sort"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="dictTypeForm.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注信息"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed, onMounted } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { addDictType, updateDictType, getDictTypes } from '@/api/dict';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
dictType?: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
dictType: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const dictTypeFormRef = ref<FormInstance>();
|
||||
const authStore = useAuthStore();
|
||||
// 已移除父级选择
|
||||
|
||||
// 判断是否为编辑模式
|
||||
const isEditing = computed(() => {
|
||||
return !!(props.dictType && props.dictType.id);
|
||||
});
|
||||
|
||||
// 判断是否应该显示全局展示选项(只有 system_admin 和 admin 角色才能看到)
|
||||
const shouldShowGlobalOption = computed(() => {
|
||||
const roleCode = authStore.user.role_code;
|
||||
return roleCode === 'system_admin' || roleCode === 'admin';
|
||||
});
|
||||
|
||||
// 表单数据
|
||||
const dictTypeForm = reactive({
|
||||
id: null as number | null,
|
||||
dict_code: '',
|
||||
dict_name: '',
|
||||
status: 1,
|
||||
is_global: 0,
|
||||
sort: 0,
|
||||
remark: '',
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules: FormRules = {
|
||||
dict_code: [
|
||||
{ required: true, message: '请输入字典编码', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_]+$/,
|
||||
message: '只能包含字母、数字或下划线',
|
||||
trigger: 'blur',
|
||||
},
|
||||
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
dict_name: [
|
||||
{ required: true, message: '请输入字典名称', trigger: 'blur' },
|
||||
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' },
|
||||
],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
sort: [
|
||||
{ required: true, message: '请输入排序序号', trigger: 'blur' },
|
||||
{ type: 'number', min: 0, message: '排序序号必须大于等于 0', trigger: 'blur' },
|
||||
],
|
||||
remark: [
|
||||
{ max: 500, message: '备注长度不能超过 500 个字符', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
// 已移除父级选择相关数据获取
|
||||
|
||||
// 监听 dictType 变化,填充表单数据
|
||||
watch(
|
||||
() => props.dictType,
|
||||
(newDictType) => {
|
||||
if (newDictType) {
|
||||
dictTypeForm.id = newDictType.id || null;
|
||||
dictTypeForm.dict_code = newDictType.dict_code || '';
|
||||
dictTypeForm.dict_name = newDictType.dict_name || '';
|
||||
dictTypeForm.status = newDictType.status !== undefined ? newDictType.status : 1;
|
||||
dictTypeForm.is_global = newDictType.is_global !== undefined ? newDictType.is_global : 0;
|
||||
dictTypeForm.sort = newDictType.sort || 0;
|
||||
dictTypeForm.remark = newDictType.remark || '';
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 已移除父级选择监听
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
dictTypeForm.id = null;
|
||||
dictTypeForm.dict_code = '';
|
||||
dictTypeForm.dict_name = '';
|
||||
dictTypeForm.status = 1;
|
||||
dictTypeForm.is_global = 0;
|
||||
dictTypeForm.sort = 0;
|
||||
dictTypeForm.remark = '';
|
||||
dictTypeFormRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (!dictTypeFormRef.value) return;
|
||||
|
||||
try {
|
||||
await dictTypeFormRef.value.validate();
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const submitData: any = {
|
||||
dict_code: dictTypeForm.dict_code,
|
||||
dict_name: dictTypeForm.dict_name,
|
||||
status: dictTypeForm.status,
|
||||
is_global: dictTypeForm.is_global,
|
||||
sort: dictTypeForm.sort,
|
||||
remark: dictTypeForm.remark || '',
|
||||
};
|
||||
|
||||
if (isEditing.value) {
|
||||
await updateDictType(dictTypeForm.id!, submitData);
|
||||
ElMessage.success('字典类型更新成功');
|
||||
} else {
|
||||
await addDictType(submitData);
|
||||
ElMessage.success('字典类型添加成功');
|
||||
}
|
||||
|
||||
emit('success');
|
||||
handleClose();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败,请重试');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 无需加载父级数据
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="dict-type-list">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索字典类型名称或编码"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="statusFilter"
|
||||
placeholder="状态筛选"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加字典类型
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="error" class="error-state">
|
||||
<el-alert title="加载失败" :message="error" type="error" show-icon />
|
||||
<el-button type="primary" @click="fetchDictTypes">重试</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 字典类型列表 -->
|
||||
<div v-else>
|
||||
<el-table
|
||||
:data="filteredDictTypes"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="dict_name" label="字典名称" min-width="150" align="center" />
|
||||
<el-table-column prop="dict_code" label="字典编码" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.dict_code }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tenant_id" label="租户ID" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.tenant_id || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.parent_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="remark" label="备注" min-width="200" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click.stop="handleViewItems(row)">
|
||||
<el-icon><Menu /></el-icon>
|
||||
查看字典项
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" link @click.stop="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click.stop="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<DictTypeEdit
|
||||
v-model="editDialogVisible"
|
||||
:dict-type="currentDictType"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
} from 'element-plus'
|
||||
import { Plus, Search, Edit, Delete, Menu } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDictTypes,
|
||||
deleteDictType,
|
||||
} from '@/api/dict'
|
||||
import DictTypeEdit from './DictTypeEdit.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-type': [typeId: number]
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
const dictTypes = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const statusFilter = ref<number | ''>('')
|
||||
|
||||
const editDialogVisible = ref(false)
|
||||
const currentDictType = ref<any>(null)
|
||||
|
||||
// 过滤后的字典类型
|
||||
const filteredDictTypes = computed(() => {
|
||||
let result = dictTypes.value
|
||||
|
||||
// 搜索过滤
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(type) =>
|
||||
(type.dict_name && type.dict_name.toLowerCase().includes(keyword)) ||
|
||||
(type.dict_code && type.dict_code.toLowerCase().includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (statusFilter.value !== '') {
|
||||
result = result.filter((type) => type.status === statusFilter.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return dateStr as string
|
||||
}
|
||||
}
|
||||
|
||||
// 获取字典类型列表
|
||||
async function fetchDictTypes() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await getDictTypes()
|
||||
if (res.success === true && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data) ? res.data : []
|
||||
} else if (Array.isArray(res)) {
|
||||
dictTypes.value = res
|
||||
} else if (res && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data) ? res.data : []
|
||||
} else {
|
||||
error.value = res.message || '获取字典类型列表失败'
|
||||
dictTypes.value = []
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取字典类型列表失败'
|
||||
dictTypes.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑在computed中处理
|
||||
}
|
||||
|
||||
// 筛选
|
||||
const handleFilter = () => {
|
||||
// 筛选逻辑在computed中处理
|
||||
}
|
||||
|
||||
// 添加字典类型
|
||||
function handleAdd() {
|
||||
currentDictType.value = null
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑字典类型
|
||||
function handleEdit(row: any) {
|
||||
currentDictType.value = { ...row }
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 查看字典项
|
||||
function handleViewItems(row: any) {
|
||||
emit('select-type', row.id)
|
||||
}
|
||||
|
||||
// 行点击
|
||||
function handleRowClick(row: any) {
|
||||
// 可以在这里处理行点击事件
|
||||
}
|
||||
|
||||
// 删除字典类型
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除字典类型「${row.dict_name}」吗?删除后不可恢复。`,
|
||||
'警告',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await deleteDictType(row.id)
|
||||
if (res.success === true || res.code === 0) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchDictTypes()
|
||||
emit('refresh')
|
||||
} else {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '删除失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
function handleEditSuccess() {
|
||||
fetchDictTypes()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 刷新
|
||||
function refresh() {
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchDictTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dict-type-list {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
padding: 32px 0 16px 0;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 5px;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>字典管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 内容区域:根据当前视图切换 -->
|
||||
<div v-if="currentView === 'types'">
|
||||
<div class="tab-content">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAddType">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加字典类型
|
||||
</el-button>
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="typeSearchKeyword"
|
||||
placeholder="搜索字典类型名称或编码"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@input="handleTypeSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="dictTypes"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
v-loading="typeLoading"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="dict_name" label="字典名称" min-width="150" align="center" />
|
||||
<el-table-column prop="dict_code" label="字典编码" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.dict_code }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="parent_id" label="父级ID" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.parent_id > 0">{{ row.parent_id }}</span>
|
||||
<span v-else style="color: #999">-</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_global" label="全局展示" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_global === 1 ? 'success' : 'warning'" size="small">
|
||||
{{ row.is_global === 1 ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="sort" label="排序" width="80" align="center" /> -->
|
||||
<!-- <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip /> -->
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEditType(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" link @click="handleViewItems(row)">
|
||||
<el-icon><List /></el-icon>
|
||||
查看字典项
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDeleteType(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="totalCount"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentView === 'items'">
|
||||
<div class="tab-content">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<el-button @click="goBackToTypes">
|
||||
返回
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<span v-if="selectedDictType">
|
||||
当前字典类型:{{ selectedDictType.dict_name }}({{ selectedDictType.dict_code }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DictItemList :selected-type-id="selectedDictTypeId || undefined" @refresh="refreshTypesSilently" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字典类型编辑对话框 -->
|
||||
<DictTypeEdit
|
||||
v-model="typeDialogVisible"
|
||||
:dict-type="currentDictType"
|
||||
@success="handleTypeSuccess"
|
||||
/>
|
||||
|
||||
<!-- 字典项独立页面内操作通过 DictItemList 完成 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Delete,
|
||||
Refresh,
|
||||
Search,
|
||||
List,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDictTypes,
|
||||
deleteDictType,
|
||||
} from '@/api/dict'
|
||||
import DictTypeEdit from './components/DictTypeEdit.vue'
|
||||
import DictItemList from './components/DictItemList.vue'
|
||||
|
||||
const currentView = ref<'types' | 'items'>('types')
|
||||
const typeLoading = ref(false)
|
||||
|
||||
// 字典类型相关
|
||||
const dictTypes = ref<any[]>([])
|
||||
const typeSearchKeyword = ref('')
|
||||
const typeDialogVisible = ref(false)
|
||||
const currentDictType = ref<any>(null)
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalCount = ref(0)
|
||||
|
||||
const selectedDictTypeId = ref<number | null>(null)
|
||||
const selectedDictType = computed(() => {
|
||||
if (!selectedDictTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return dictTypes.value.find((type) => type.id === selectedDictTypeId.value) || null
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return dateStr as string
|
||||
}
|
||||
}
|
||||
|
||||
// 获取字典类型列表
|
||||
async function fetchDictTypes() {
|
||||
typeLoading.value = true
|
||||
try {
|
||||
const res = await getDictTypes({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: typeSearchKeyword.value
|
||||
})
|
||||
if (res.success === true && res.data) {
|
||||
dictTypes.value = Array.isArray(res.data.list) ? res.data.list : (Array.isArray(res.data) ? res.data : [])
|
||||
totalCount.value = res.data.total || res.data.length || 0
|
||||
} else if (Array.isArray(res)) {
|
||||
dictTypes.value = res
|
||||
totalCount.value = res.length
|
||||
} else {
|
||||
dictTypes.value = []
|
||||
totalCount.value = 0
|
||||
ElMessage.error(res.message || '获取字典类型失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '获取字典类型失败')
|
||||
dictTypes.value = []
|
||||
totalCount.value = 0
|
||||
} finally {
|
||||
typeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 字典类型搜索
|
||||
function handleTypeSearch() {
|
||||
currentPage.value = 1
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 分页切换
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 添加字典类型
|
||||
function handleAddType() {
|
||||
currentDictType.value = null
|
||||
typeDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑字典类型
|
||||
function handleEditType(row: any) {
|
||||
currentDictType.value = { ...row }
|
||||
typeDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 查看字典项
|
||||
function handleViewItems(row: any) {
|
||||
selectedDictTypeId.value = row.id
|
||||
currentView.value = 'items'
|
||||
}
|
||||
|
||||
// 删除字典类型
|
||||
async function handleDeleteType(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除字典类型「${row.dict_name}」吗?删除后不可恢复。`,
|
||||
'警告',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
|
||||
typeLoading.value = true
|
||||
try {
|
||||
const res = await deleteDictType(row.id)
|
||||
if (res.success === true) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchDictTypes()
|
||||
} else {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
} finally {
|
||||
typeLoading.value = false
|
||||
}
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 字典类型操作成功回调
|
||||
function handleTypeSuccess() {
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 从字典项页返回
|
||||
function goBackToTypes() {
|
||||
currentView.value = 'types'
|
||||
}
|
||||
|
||||
// 子页面刷新后,静默刷新类型列表
|
||||
function refreshTypesSilently() {
|
||||
fetchDictTypes()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
async function refresh() {
|
||||
await fetchDictTypes()
|
||||
ElMessage.success('刷新成功')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDictTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.color-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.color-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e5e5e5;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="创建文件分组"
|
||||
width="400px"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<el-form
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="分组名称" prop="name">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="请输入文件分组名称"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="loading">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createFileCate } from "@/api/file";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "success", "close"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const formRef = ref<any>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: "请输入文件分组名称", trigger: "blur" },
|
||||
{ min: 1, max: 50, message: "名称长度在 1 到 50 个字符", trigger: "blur" },
|
||||
],
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal) {
|
||||
// 打开对话框时,清空表单数据
|
||||
form.value.name = "";
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
});
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
form.value.name = "";
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
emit("close");
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (error) {
|
||||
ElMessage.warning("请检查表单填写是否正确");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证名称是否为空
|
||||
if (!form.value.name || form.value.name.trim() === "") {
|
||||
ElMessage.error("文件分组名称不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await createFileCate({
|
||||
name: form.value.name.trim(),
|
||||
});
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("创建成功");
|
||||
visible.value = false;
|
||||
form.value.name = "";
|
||||
emit("success");
|
||||
emit("close");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "创建失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg =
|
||||
error?.response?.data?.message || error?.message || "创建失败";
|
||||
ElMessage.error(errorMsg);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open: () => {
|
||||
form.value.name = "";
|
||||
visible.value = true;
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-category-dialog {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="移动文件"
|
||||
width="400px"
|
||||
@close="handleClose"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<el-form>
|
||||
<el-form-item label="目标分组">
|
||||
<el-select
|
||||
v-model="targetCate"
|
||||
placeholder="请选择分组"
|
||||
style="width: 100%"
|
||||
@change="handleCateChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="cate in filteredCateList"
|
||||
:key="cate.id"
|
||||
:label="cate.name"
|
||||
:value="cate.id"
|
||||
:disabled="cate.id === props.currentCateId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleMove">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { moveFile } from '@/api/file'
|
||||
|
||||
interface Cate {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
fileId: number | null
|
||||
currentCateId?: number | null
|
||||
cateList: Cate[]
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:visible', 'moved'])
|
||||
|
||||
// Data
|
||||
const targetCate = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 未分类分组
|
||||
const uncategorizedOption: Cate = {
|
||||
id: 0,
|
||||
name: '未分类'
|
||||
}
|
||||
|
||||
// 计算属性:添加未分类选项,并标记当前分组为禁用
|
||||
const filteredCateList = computed(() => {
|
||||
const list = [uncategorizedOption, ...(props.cateList || [])]
|
||||
return list
|
||||
})
|
||||
|
||||
// 重置
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
targetCate.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
targetCate.value = null
|
||||
}
|
||||
|
||||
// 选择分组
|
||||
function handleCateChange(value: number) {
|
||||
targetCate.value = value
|
||||
}
|
||||
|
||||
// 移动文件
|
||||
async function handleMove() {
|
||||
// 校验文件ID和目标分组ID的有效性
|
||||
if (!props.fileId) {
|
||||
ElMessage.error('请选择要移动的文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 修复:0 是有效的分组ID(未分类),需要检查 null 或 undefined
|
||||
if (targetCate.value === null || targetCate.value === undefined) {
|
||||
ElMessage.error('请选择目标分组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await moveFile(props.fileId, targetCate.value)
|
||||
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('文件移动成功')
|
||||
handleClose() // 先关闭对话框
|
||||
emit('moved') // 然后触发移动成功事件,刷新列表
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '文件移动失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('移动文件失败:', error)
|
||||
ElMessage.error(error?.message || '文件移动失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="重命名文件分组"
|
||||
width="400px"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<el-form
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="分组名称" prop="name">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="请输入文件分组名称"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="loading">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { renameFileCate } from "@/api/file";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
categoryId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
categoryName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "success", "close"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const formRef = ref<any>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: "请输入文件分组名称", trigger: "blur" },
|
||||
{ min: 1, max: 50, message: "名称长度在 1 到 50 个字符", trigger: "blur" },
|
||||
],
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal) {
|
||||
// 打开对话框时,初始化表单数据
|
||||
form.value.name = props.categoryName || "";
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
});
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
form.value.name = "";
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
emit("close");
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (error) {
|
||||
ElMessage.warning("请检查表单填写是否正确");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证名称是否为空
|
||||
if (!form.value.name || form.value.name.trim() === "") {
|
||||
ElMessage.error("文件分组名称不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证名称是否与原来相同
|
||||
if (form.value.name.trim() === props.categoryName) {
|
||||
ElMessage.warning("名称未发生变化");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 categoryId
|
||||
if (!props.categoryId) {
|
||||
ElMessage.error("分组ID不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await renameFileCate(props.categoryId, {
|
||||
name: form.value.name.trim(),
|
||||
});
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("重命名成功");
|
||||
visible.value = false;
|
||||
form.value.name = "";
|
||||
emit("success");
|
||||
emit("close");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "重命名失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg =
|
||||
error?.response?.data?.message || error?.message || "重命名失败";
|
||||
ElMessage.error(errorMsg);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open: (categoryId: number, categoryName: string) => {
|
||||
form.value.name = categoryName || "";
|
||||
visible.value = true;
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rename-category-dialog {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="上传文件"
|
||||
width="600px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:data="uploadData"
|
||||
:file-list="fileList"
|
||||
:on-change="handleChange"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:on-progress="handleProgress"
|
||||
:on-remove="handleRemove"
|
||||
:before-upload="beforeUpload"
|
||||
:auto-upload="false"
|
||||
drag
|
||||
multiple
|
||||
class="upload-container"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持多文件上传,单个文件大小不超过50MB
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="submitUpload" :loading="uploading">
|
||||
{{ uploading ? "上传中..." : "开始上传" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { uploadFile } from "@/api/file";
|
||||
import type { UploadInstance, UploadFile, UploadFiles } from "element-plus";
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
categoryId?: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "success"): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
categoryId: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const uploading = ref(false);
|
||||
const uploadAction = ref(""); // 不使用action,手动上传
|
||||
|
||||
// 上传请求头
|
||||
const uploadHeaders = computed(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
return {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
};
|
||||
});
|
||||
|
||||
// 上传额外数据
|
||||
const uploadData = computed(() => ({
|
||||
cate: props.categoryId || 0,
|
||||
}));
|
||||
|
||||
// 文件变化监听
|
||||
const handleChange = (file: UploadFile, files: UploadFiles) => {
|
||||
// 更新文件列表
|
||||
fileList.value = files;
|
||||
};
|
||||
|
||||
// 文件上传前的验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const maxSize = 50 * 1024 * 1024; // 50MB
|
||||
const isValidType = true; // 可以根据需要限制文件类型
|
||||
|
||||
if (file.size > maxSize) {
|
||||
ElMessage.error(`文件 ${file.name} 大小超过50MB限制`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 移除文件
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
const index = fileList.value.findIndex((item) => item.uid === file.uid);
|
||||
if (index > -1) {
|
||||
fileList.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传进度
|
||||
const handleProgress = (event: any, file: UploadFile) => {
|
||||
file.status = "uploading";
|
||||
file.percentage = Math.round(event.percent);
|
||||
};
|
||||
|
||||
// 上传成功
|
||||
const handleSuccess = (response: any, file: UploadFile) => {
|
||||
// 只要 code === 200 或 201 都视作已成功(存在或新上传)
|
||||
if (response.code === 200) {
|
||||
file.status = "success";
|
||||
ElMessage.success(`文件 ${file.name} 上传成功`);
|
||||
} else if (response.code === 201) {
|
||||
file.status = "success";
|
||||
ElMessage.info(response.msg || `文件 ${file.name} 已存在`);
|
||||
} else {
|
||||
file.status = "fail";
|
||||
ElMessage.error(response.msg || response.message || `文件 ${file.name} 上传失败`);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传失败
|
||||
const handleError = (error: any, file: UploadFile) => {
|
||||
file.status = "fail";
|
||||
let msg = error?.message || error?.msg || '';
|
||||
if (!msg && error?.response && error.response.data) {
|
||||
msg = error.response.data.msg || error.response.data.message;
|
||||
}
|
||||
console.error("上传失败:", error);
|
||||
ElMessage.error(msg ? `文件 ${file.name} 上传失败:${msg}` : `文件 ${file.name} 上传失败`);
|
||||
};
|
||||
|
||||
// 手动提交上传
|
||||
const submitUpload = async () => {
|
||||
if (!uploadRef.value) return;
|
||||
|
||||
// 从 upload 组件实例获取文件列表
|
||||
const uploadFiles = uploadRef.value.uploadFiles || fileList.value;
|
||||
|
||||
const filesToUpload = uploadFiles.filter(
|
||||
(file: UploadFile) => file.status !== "success" && file.raw
|
||||
);
|
||||
|
||||
if (filesToUpload.length === 0) {
|
||||
ElMessage.warning("请先选择要上传的文件");
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
|
||||
try {
|
||||
// 逐个上传文件,显示进度
|
||||
const uploadPromises = filesToUpload.map(async (file) => {
|
||||
file.status = "uploading";
|
||||
file.percentage = 0;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file.raw as File);
|
||||
if (props.categoryId) {
|
||||
formData.append("cate", props.categoryId.toString());
|
||||
}
|
||||
|
||||
// 模拟上传进度
|
||||
const progressInterval = setInterval(() => {
|
||||
if (file.percentage < 90) {
|
||||
file.percentage = Math.min(file.percentage + 10, 90);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
const res = await uploadFile(formData, { cate: props.categoryId });
|
||||
|
||||
clearInterval(progressInterval);
|
||||
file.percentage = 100;
|
||||
|
||||
// code 200 表示新上传成功,code 201 表示文件已存在(也视为成功)
|
||||
if (res.code === 200 || res.code === 201) {
|
||||
file.status = "success";
|
||||
return { success: true, file, res };
|
||||
} else {
|
||||
file.status = "fail";
|
||||
throw new Error(res.msg || "上传失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
file.status = "fail";
|
||||
file.percentage = 0;
|
||||
throw { file, error };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(uploadPromises);
|
||||
|
||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failCount = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
// 统计新上传和已存在的文件数量
|
||||
let newUploadCount = 0;
|
||||
let existCount = 0;
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value?.res) {
|
||||
if (result.value.res.code === 200) {
|
||||
newUploadCount++;
|
||||
} else if (result.value.res.code === 201) {
|
||||
existCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
let message = "";
|
||||
if (newUploadCount > 0 && existCount > 0) {
|
||||
message = `成功处理 ${successCount} 个文件(${newUploadCount} 个新上传,${existCount} 个已存在)`;
|
||||
} else if (newUploadCount > 0) {
|
||||
message = `成功上传 ${newUploadCount} 个文件`;
|
||||
} else if (existCount > 0) {
|
||||
message = `${existCount} 个文件已存在`;
|
||||
} else {
|
||||
message = `成功处理 ${successCount} 个文件`;
|
||||
}
|
||||
ElMessage.success(message);
|
||||
}
|
||||
if (failCount > 0) {
|
||||
ElMessage.warning(`${failCount} 个文件上传失败`);
|
||||
}
|
||||
|
||||
// 如果所有文件都上传成功,关闭对话框并刷新列表
|
||||
if (failCount === 0) {
|
||||
emit("success");
|
||||
handleClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("上传失败:", error);
|
||||
ElMessage.error("上传过程中发生错误");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
fileList.value = [];
|
||||
uploading.value = false;
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger) {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.el-icon--upload {
|
||||
font-size: 67px;
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
|
||||
em {
|
||||
color: #409eff;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.el-upload__tip {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<!-- 添加/编辑菜单对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentMenu"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="menuFormRef"
|
||||
>
|
||||
<el-form-item label="父级菜单" prop="pid">
|
||||
<el-tree-select
|
||||
v-model="currentMenu.pid"
|
||||
:data="parentMenuOptions"
|
||||
:props="{ value: 'id', label: 'title', children: 'children' }"
|
||||
placeholder="请选择父级菜单"
|
||||
clearable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
@change="handleParentChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单名称" prop="title">
|
||||
<el-input v-model="currentMenu.title" placeholder="请输入菜单名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单类型" prop="type">
|
||||
<el-radio-group v-model="currentMenu.type" style="width: 100%">
|
||||
<el-radio-button :value="1">目录</el-radio-button>
|
||||
<el-radio-button :value="2">页面</el-radio-button>
|
||||
<el-radio-button :value="3">外链</el-radio-button>
|
||||
<el-radio-button :value="4">单页</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div
|
||||
style="
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
"
|
||||
>
|
||||
<div>
|
||||
• 目录:只有路由地址,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>目录管理</span
|
||||
>和<span style="color: var(--el-color-primary)">菜单分组</span>
|
||||
</div>
|
||||
<div>
|
||||
• 页面:有路由和组件地址,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>页面管理</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
• 外链:无路由和组件,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>外链管理</span
|
||||
>和<span style="color: var(--el-color-primary)">权限控制</span>
|
||||
</div>
|
||||
<div>
|
||||
• 单页:根据路由从单页表获取内容显示,无需填写组件路径
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="路由地址" prop="path" v-if="currentMenu.type !== 3">
|
||||
<el-input v-model="currentMenu.path" placeholder="例如:/system" />
|
||||
<div v-if="currentMenu.type === 4" style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
单页类型:路由需与单页管理中的路由一致,系统会自动从单页表获取内容
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="组件路径"
|
||||
prop="component_path"
|
||||
v-if="currentMenu.type === 2"
|
||||
>
|
||||
<el-input
|
||||
v-model="currentMenu.component_path"
|
||||
placeholder="例如:/apps/knowledge/index.vue"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="外链地址"
|
||||
prop="link_url"
|
||||
v-if="currentMenu.type === 3"
|
||||
>
|
||||
<el-input
|
||||
v-model="currentMenu.link_url"
|
||||
required
|
||||
placeholder="例如:https://www.baidu.com"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单图片" prop="image">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="currentMenu.image" :src="getImageUrl(currentMenu.image)" class="image-preview" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传图片</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
建议尺寸:400x300,支持 jpg、png、gif 格式,大小不超过 2MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="currentMenu.image"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
删除图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="desc">
|
||||
<el-input
|
||||
v-model="currentMenu.desc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="例如:系统管理"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentMenu.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
type: number;
|
||||
path: string;
|
||||
component_path: string;
|
||||
link_url?: string;
|
||||
image?: string;
|
||||
sort: number;
|
||||
desc: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
menu: Partial<Menu> | null;
|
||||
parentMenuOptions: Menu[];
|
||||
dialogType: "add" | "edit" | "addSub";
|
||||
parentTitle?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
menu: null,
|
||||
parentMenuOptions: () => [],
|
||||
dialogType: "add",
|
||||
parentTitle: "",
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", menu: Partial<Menu>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const menuFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的菜单
|
||||
const currentMenu = ref<Partial<Menu>>({
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
});
|
||||
|
||||
// 上传配置
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
const uploadUrl = ref(API_BASE_URL + '/admin/uploadfiles');
|
||||
const uploadHeaders = ref({
|
||||
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
||||
});
|
||||
|
||||
// 查找父级菜单路径的递归函数
|
||||
const findMenuPath = (menuList: Menu[], targetId: number): string => {
|
||||
for (const menu of menuList) {
|
||||
if (menu.id === targetId) {
|
||||
return menu.path || "";
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const childPath = findMenuPath(menu.children, targetId);
|
||||
if (childPath) return childPath;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// 处理父级菜单变化 - 自动填充父级路径到路由地址
|
||||
const handleParentChange = (value: number) => {
|
||||
if (value === 0) {
|
||||
// 选择顶级菜单,清空路径
|
||||
currentMenu.value.path = "";
|
||||
} else {
|
||||
// 选择子菜单,自动填充父级路径
|
||||
const parentPath = findMenuPath(props.parentMenuOptions, value);
|
||||
if (parentPath) {
|
||||
currentMenu.value.path = parentPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听props变化,更新当前菜单
|
||||
watch(
|
||||
() => props.menu,
|
||||
(newMenu) => {
|
||||
if (newMenu) {
|
||||
currentMenu.value = {
|
||||
...newMenu,
|
||||
// 确保 pid 有默认值
|
||||
pid: newMenu.pid ?? 0,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
type: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && props.dialogType === "add") {
|
||||
// 新增时重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
path: "",
|
||||
component_path: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
type: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
switch (props.dialogType) {
|
||||
case "add":
|
||||
return "添加菜单";
|
||||
case "edit":
|
||||
return "编辑菜单";
|
||||
case "addSub":
|
||||
return `添加子菜单 (父菜单: ${props.parentTitle || "顶级菜单"})`;
|
||||
default:
|
||||
return "操作菜单";
|
||||
}
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||||
path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 3) {
|
||||
// 外链类型不需要路径
|
||||
callback();
|
||||
} else if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入路由地址"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
component_path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 2) {
|
||||
// 页面类型需要组件路径
|
||||
if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入组件路径"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
// 其他类型(目录、外链、单页)不需要组件路径
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
|
||||
// 监听菜单类型变化,自动清空不相关的字段
|
||||
watch(
|
||||
() => currentMenu.value.type,
|
||||
(newType, oldType) => {
|
||||
if (newType === oldType) return; // 避免初始化时的触发
|
||||
|
||||
if (newType === 1) {
|
||||
// 目录:清空组件路径,保留路径
|
||||
currentMenu.value.component_path = "";
|
||||
} else if (newType === 2) {
|
||||
// 页面:保留路径和组件路径
|
||||
// 不清空,保持现有值
|
||||
} else if (newType === 3) {
|
||||
// 外链:清空路径和组件路径
|
||||
currentMenu.value.path = "";
|
||||
currentMenu.value.component_path = "";
|
||||
} else if (newType === 4) {
|
||||
// 单页:清空组件路径,保留路径(路径用于匹配单页表)
|
||||
currentMenu.value.component_path = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file: any) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
// 直接保存相对路径,不拼接API_BASE_URL
|
||||
currentMenu.value.image = response.data.url || response.data.path;
|
||||
ElMessage.success('图片上传成功');
|
||||
} else {
|
||||
ElMessage.error(response.msg || '图片上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error('图片上传失败,请重试');
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
currentMenu.value.image = '';
|
||||
ElMessage.success('图片已删除');
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 保存菜单
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!menuFormRef.value) return;
|
||||
const valid = await menuFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...currentMenu.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>前端导航管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="expandAll">
|
||||
<el-icon>
|
||||
<FolderOpened />
|
||||
</el-icon>
|
||||
全部展开
|
||||
</el-button>
|
||||
<el-button @click="collapseAll">
|
||||
<el-icon>
|
||||
<Folder />
|
||||
</el-icon>
|
||||
全部折叠
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAddMenu">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加菜单
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 树形表格 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="menuTree"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
:tree-props="{
|
||||
children: 'children',
|
||||
hasChildren: 'hasChildren',
|
||||
}"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="title" label="菜单名称" width="200">
|
||||
<template #default="scope">
|
||||
<div class="menu-item">
|
||||
<i
|
||||
v-if="scope.row.icon"
|
||||
:class="scope.row.icon"
|
||||
class="menu-icon"
|
||||
></i>
|
||||
<span>{{ scope.row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="path" label="路由地址"></el-table-column>
|
||||
|
||||
<el-table-column label="图片" width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-image
|
||||
v-if="scope.row.image"
|
||||
:src="getImageUrl(scope.row.image)"
|
||||
:preview-src-list="[getImageUrl(scope.row.image)]"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
style="width: 50px; height: 50px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="MenuType"
|
||||
label="菜单类型"
|
||||
width="120"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getMenuTypeTagType(scope.row.type)">
|
||||
{{ getMenuTypeTitle(scope.row.type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="80"
|
||||
align="center"
|
||||
></el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
@click="handleAddSubMenu(scope.row)"
|
||||
:disabled="scope.row.type === 3"
|
||||
>
|
||||
<el-icon>
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
<span>子菜单</span>
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text @click="handleEditMenu(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteMenu(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<MenuEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:menu="dialogMenu"
|
||||
:parent-menu-options="parentMenuOptions"
|
||||
:dialog-type="dialogType"
|
||||
:parent-title="dialogParentTitle"
|
||||
@save="handleMenuSave"
|
||||
@cancel="handleMenuCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
|
||||
import {
|
||||
Plus,
|
||||
CirclePlus,
|
||||
Edit,
|
||||
Delete,
|
||||
Refresh,
|
||||
FolderOpened,
|
||||
Folder,
|
||||
} from "@element-plus/icons-vue";
|
||||
import {
|
||||
getFrontMenus,
|
||||
createFrontMenu,
|
||||
editFrontMenu,
|
||||
deleteFrontMenu,
|
||||
} from "@/api/frontMenu";
|
||||
import MenuEdit from "./components/edit.vue";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
type: number;
|
||||
path: string;
|
||||
component_path: string;
|
||||
image?: string;
|
||||
sort: number;
|
||||
desc: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// 菜单树形数据
|
||||
const menuTree = ref<Menu[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 表格引用
|
||||
const tableRef = ref<any>(null);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMenu = ref<Partial<Menu> | null>(null);
|
||||
const dialogType = ref<"add" | "edit" | "addSub">("add");
|
||||
const dialogParentTitle = ref("");
|
||||
|
||||
// 父级菜单选项
|
||||
const parentMenuOptions = ref<Menu[]>([]);
|
||||
|
||||
let fetchMenusPromise: Promise<any> | null = null;
|
||||
|
||||
const fetchMenus = async () => {
|
||||
if (fetchMenusPromise) {
|
||||
return fetchMenusPromise;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
fetchMenusPromise = (async () => {
|
||||
try {
|
||||
const result = await getFrontMenus();
|
||||
if (result.code === 200) {
|
||||
menuTree.value = result.data;
|
||||
parentMenuOptions.value = [
|
||||
{
|
||||
id: 0,
|
||||
pid: -1,
|
||||
title: "顶级菜单",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
children: result.data,
|
||||
} as Menu,
|
||||
];
|
||||
} else {
|
||||
ElMessage.error("获取前端导航失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取前端导航数据失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
fetchMenusPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchMenusPromise;
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchMenus();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有前端导航行数据(包括子节点)
|
||||
function getAllMenuRows(menuList: Menu[]): Menu[] {
|
||||
const rows: Menu[] = [];
|
||||
menuList.forEach((frontMenu) => {
|
||||
rows.push(frontMenu);
|
||||
if (frontMenu.children && frontMenu.children.length > 0) {
|
||||
rows.push(...getAllMenuRows(frontMenu.children));
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 全部展开
|
||||
function expandAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 全部折叠
|
||||
function collapseAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理行点击事件 - 展开/收缩
|
||||
function handleRowClick(row: Menu, column: any, event: Event) {
|
||||
// 如果点击的是操作列,不触发展开/收缩
|
||||
if (column && column.label === '操作') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果该行有子菜单,切换展开/收缩状态
|
||||
if (row.children && row.children.length > 0) {
|
||||
if (tableRef.value) {
|
||||
tableRef.value.toggleRowExpansion(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单树(处理父子关系)
|
||||
const buildMenuTree = (menuList: Menu[]): Menu[] => {
|
||||
return menuList;
|
||||
};
|
||||
|
||||
// 获取菜单类型名称
|
||||
const getMenuTypeTitle = (type: number) => {
|
||||
const typeMap = { 1: "目录", 2: "页面", 3: "外链", 4: "单页" };
|
||||
return typeMap[type as keyof typeof typeMap] || "未知类型";
|
||||
};
|
||||
|
||||
// 获取菜单类型标签样式
|
||||
const getMenuTypeTagType = (type: number) => {
|
||||
const typeMap = { 1: "primary", 2: "success", 3: "info", 4: "warning" };
|
||||
return typeMap[type as keyof typeof typeMap] || "default";
|
||||
};
|
||||
|
||||
// 添加子菜单
|
||||
const handleAddSubMenu = (parentMenu: Menu) => {
|
||||
dialogType.value = "addSub";
|
||||
dialogParentTitle.value = parentMenu.title;
|
||||
dialogMenu.value = {
|
||||
id: 0, // 明确设置为 0,表示是新增
|
||||
pid: parentMenu.id,
|
||||
title: "",
|
||||
path: parentMenu.path || "", // 自动填充父级路径
|
||||
component_path: "",
|
||||
desc: "",
|
||||
sort: 0,
|
||||
type: parentMenu.type === 1 ? 2 : parentMenu.type, // 如果父菜单是目录,子菜单默认为页面
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑菜单
|
||||
const handleEditMenu = (menu: Menu) => {
|
||||
dialogType.value = "edit";
|
||||
dialogMenu.value = { ...menu };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除菜单
|
||||
const handleDeleteMenu = (menu: Menu) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除菜单 "${menu.title}" 吗?${
|
||||
menu.hasChildren ? "其下所有子菜单也将被删除。" : ""
|
||||
}`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteFrontMenu(menu.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchMenus();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 添加菜单
|
||||
const handleAddMenu = () => {
|
||||
dialogType.value = "add";
|
||||
dialogMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 处理菜单保存
|
||||
const handleMenuSave = async (menu: Partial<Menu>) => {
|
||||
try {
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...menu };
|
||||
|
||||
// 确保 pid 是整数类型(后端要求必须是整数)
|
||||
// 处理数组情况:如果 pid 是数组,取第一个元素
|
||||
let pidValue: any = payload.pid;
|
||||
if (Array.isArray(pidValue)) {
|
||||
pidValue = pidValue.length > 0 ? pidValue[0] : null;
|
||||
}
|
||||
|
||||
// 强制转换为整数
|
||||
if (pidValue === null || pidValue === undefined || pidValue === '') {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
const parsedPid = parseInt(String(pidValue), 10);
|
||||
if (isNaN(parsedPid)) {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
payload.pid = parsedPid;
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是新增还是编辑:没有 id 或 id 为 0 或 dialogType 为 add/addSub 时为新增
|
||||
if (!menu.id || menu.id === 0 || dialogType.value === 'add' || dialogType.value === 'addSub') {
|
||||
// 新增菜单(包括添加顶级菜单和添加子菜单)
|
||||
const result = await createFrontMenu(payload as Menu);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "菜单添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的菜单
|
||||
const result = await editFrontMenu(menu.id!, payload as Menu);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单取消
|
||||
const handleMenuCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于显示)
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 组件挂载时加载菜单
|
||||
onMounted(() => {
|
||||
fetchMenus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.card-header span {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表格核心样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 有子菜单的行显示手型光标 */
|
||||
:deep(.el-table__body tr.el-table__row--level-0) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr) {
|
||||
&:has(.el-table__expand-icon:not(.el-table__expand-icon--hidden)) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* 展开图标与菜单内容对齐 */
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin: 0 !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon-cell) {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
|
||||
/* 菜单项样式 */
|
||||
.menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 隐藏无子女菜单的展开图标 */
|
||||
:deep(.el-table__expand-icon--hidden) {
|
||||
visibility: hidden;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin-right: 8px !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<!-- 添加/编辑菜单对话框 -->
|
||||
<el-dialog :model-value="visible" :title="dialogTitle" width="500px" :close-on-click-modal="false" @update:model-value="handleDialogClose">
|
||||
<el-form
|
||||
:model="currentMenu"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="menuFormRef"
|
||||
>
|
||||
<el-form-item label="父级菜单" prop="pid">
|
||||
<el-cascader
|
||||
:model-value="pidValue"
|
||||
@update:model-value="handlePidUpdate"
|
||||
:options="parentMenuOptions"
|
||||
:props="cascaderProps"
|
||||
placeholder="请选择父级菜单"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单名称" prop="title">
|
||||
<el-input v-model="currentMenu.title" placeholder="请输入菜单名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单类型" prop="type">
|
||||
<el-radio-group v-model="currentMenu.type" style="width: 100%">
|
||||
<el-radio-button :value="1">目录</el-radio-button>
|
||||
<el-radio-button :value="2">页面</el-radio-button>
|
||||
<el-radio-button :value="3">接口</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
<div>• 目录:只有路由地址,用于<span style="color: var(--el-color-primary);">目录管理</span>和<span style="color: var(--el-color-primary);">菜单分组</span></div>
|
||||
<div>• 页面:有路由和组件地址,用于<span style="color: var(--el-color-primary);">页面管理</span></div>
|
||||
<div>• 接口:无路由和组件,用于<span style="color: var(--el-color-primary);">接口管理</span>和<span style="color: var(--el-color-primary);">权限控制</span></div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="路由地址"
|
||||
prop="path"
|
||||
v-if="currentMenu.type !== 3"
|
||||
>
|
||||
<el-input v-model="currentMenu.path" placeholder="例如:/system" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="组件路径"
|
||||
prop="component_path"
|
||||
v-if="currentMenu.type === 2"
|
||||
>
|
||||
<el-input
|
||||
v-model="currentMenu.component_path"
|
||||
placeholder="例如:/apps/knowledge/index.vue"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="图标" prop="icon">
|
||||
<el-input
|
||||
v-model="currentMenu.icon"
|
||||
placeholder="例如:fas fa-tachometer-alt"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentMenu.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="currentMenu.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="权限标识" prop="permission">
|
||||
<el-input
|
||||
v-model="currentMenu.permission"
|
||||
placeholder="请输入权限标识"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
path: string;
|
||||
component_path: string;
|
||||
icon: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
type: 1 | 2 | 3;
|
||||
permission: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
menu: Partial<Menu> | null;
|
||||
parentMenuOptions: Menu[];
|
||||
dialogType: 'add' | 'edit' | 'addSub';
|
||||
parentTitle?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
menu: null,
|
||||
parentMenuOptions: () => [],
|
||||
dialogType: 'add',
|
||||
parentTitle: ''
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'save', menu: Partial<Menu>): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const menuFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的菜单
|
||||
const currentMenu = ref<Partial<Menu>>({
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: '',
|
||||
path: '',
|
||||
component_path: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
type: 1,
|
||||
permission: '',
|
||||
});
|
||||
|
||||
// 使用计算属性处理 pid,确保始终是单个整数
|
||||
const pidValue = computed({
|
||||
get: () => {
|
||||
const pid = currentMenu.value.pid;
|
||||
// 如果已经是数组,取最后一个元素(el-cascader 返回的是路径数组)
|
||||
if (Array.isArray(pid)) {
|
||||
const normalized = pid.length > 0 ? (parseInt(String(pid[pid.length - 1]), 10) || 0) : 0;
|
||||
// 立即修正 currentMenu.value.pid
|
||||
currentMenu.value.pid = normalized;
|
||||
return normalized;
|
||||
}
|
||||
// 确保是整数
|
||||
const normalized = typeof pid === 'number' ? pid : (pid ? parseInt(String(pid), 10) || 0 : 0);
|
||||
// 如果值被修改了,更新回去
|
||||
if (normalized !== pid && pid !== null && pid !== undefined) {
|
||||
currentMenu.value.pid = normalized;
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
set: (value: number | number[] | null | undefined) => {
|
||||
// 处理数组情况:el-cascader 可能返回路径数组,取最后一个元素
|
||||
let pid: number = 0;
|
||||
if (Array.isArray(value)) {
|
||||
pid = value.length > 0 ? (parseInt(String(value[value.length - 1]), 10) || 0) : 0;
|
||||
} else if (value !== null && value !== undefined) {
|
||||
pid = parseInt(String(value), 10) || 0;
|
||||
}
|
||||
currentMenu.value.pid = pid;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理父级菜单更新
|
||||
const handlePidUpdate = (value: number | number[] | null | undefined) => {
|
||||
pidValue.value = value;
|
||||
};
|
||||
|
||||
// 监听props变化,更新当前菜单
|
||||
watch(() => props.menu, (newMenu) => {
|
||||
if (newMenu) {
|
||||
// 确保 pid 是整数,处理数组情况
|
||||
let normalizedPid = 0;
|
||||
if (newMenu.pid !== null && newMenu.pid !== undefined) {
|
||||
if (Array.isArray(newMenu.pid)) {
|
||||
normalizedPid = newMenu.pid.length > 0 ? (parseInt(String(newMenu.pid[newMenu.pid.length - 1]), 10) || 0) : 0;
|
||||
} else {
|
||||
normalizedPid = parseInt(String(newMenu.pid), 10) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
currentMenu.value = {
|
||||
...newMenu,
|
||||
pid: normalizedPid,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: props.dialogType === 'addSub' ? props.parentMenuOptions[0]?.id || 0 : 0,
|
||||
title: '',
|
||||
path: '',
|
||||
component_path: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
type: 1,
|
||||
permission: '',
|
||||
};
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 监听 currentMenu.value.pid 的变化,确保始终是整数(防止被直接修改为数组)
|
||||
watch(
|
||||
() => currentMenu.value.pid,
|
||||
(newPid) => {
|
||||
if (Array.isArray(newPid)) {
|
||||
// 如果变成了数组,立即转换为整数(取最后一个元素,因为 el-cascader 返回的是路径数组)
|
||||
const normalizedPid = newPid.length > 0 ? (parseInt(String(newPid[newPid.length - 1]), 10) || 0) : 0;
|
||||
currentMenu.value.pid = normalizedPid;
|
||||
} else if (typeof newPid !== 'number' && newPid !== null && newPid !== undefined) {
|
||||
// 如果不是数字,转换为整数
|
||||
const normalizedPid = parseInt(String(newPid), 10) || 0;
|
||||
currentMenu.value.pid = normalizedPid;
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(() => props.visible, (newVisible) => {
|
||||
if (newVisible && props.dialogType === 'add') {
|
||||
// 新增时重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: '',
|
||||
path: '',
|
||||
component_path: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
type: 1,
|
||||
permission: '',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
switch (props.dialogType) {
|
||||
case 'add':
|
||||
return '添加菜单';
|
||||
case 'edit':
|
||||
return '编辑菜单';
|
||||
case 'addSub':
|
||||
return `添加子菜单 (父菜单: ${props.parentTitle || '顶级菜单'})`;
|
||||
default:
|
||||
return '操作菜单';
|
||||
}
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||||
path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 3) {
|
||||
// 接口类型不需要路径
|
||||
callback();
|
||||
} else if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入路由地址"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur"
|
||||
}
|
||||
],
|
||||
component_path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 2) {
|
||||
// 页面类型需要组件路径
|
||||
if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入组件路径"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
// 目录和接口类型不需要组件路径
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur"
|
||||
}
|
||||
],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// 级联选择器配置
|
||||
const cascaderProps = ref({
|
||||
value: "id",
|
||||
label: "title",
|
||||
children: "children",
|
||||
checkStrictly: true,
|
||||
emitpath: false,
|
||||
});
|
||||
|
||||
// 监听菜单类型变化,自动清空不相关的字段
|
||||
watch(() => currentMenu.value.type, (newType, oldType) => {
|
||||
if (newType === oldType) return; // 避免初始化时的触发
|
||||
|
||||
if (newType === 1) {
|
||||
// 目录:清空组件路径,保留路径
|
||||
currentMenu.value.component_path = "";
|
||||
} else if (newType === 2) {
|
||||
// 页面:保留路径和组件路径
|
||||
// 不清空,保持现有值
|
||||
} else if (newType === 3) {
|
||||
// 接口:清空路径和组件路径
|
||||
currentMenu.value.path = "";
|
||||
currentMenu.value.component_path = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit('update:visible', false);
|
||||
emit('cancel');
|
||||
}
|
||||
};
|
||||
|
||||
// 保存菜单
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!menuFormRef.value) return;
|
||||
const valid = await menuFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 先确保 currentMenu.value.pid 是整数(防止 el-cascader 直接修改为数组)
|
||||
let rawPid = currentMenu.value.pid;
|
||||
if (Array.isArray(rawPid)) {
|
||||
// el-cascader 返回的是路径数组,取最后一个元素
|
||||
rawPid = rawPid.length > 0 ? rawPid[rawPid.length - 1] : 0;
|
||||
}
|
||||
const normalizedPid = typeof rawPid === 'number' ? rawPid : (rawPid ? parseInt(String(rawPid), 10) || 0 : 0);
|
||||
currentMenu.value.pid = normalizedPid;
|
||||
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...currentMenu.value };
|
||||
|
||||
// 再次确保 pid 是整数类型(双重保险)
|
||||
// 处理数组情况:如果 pid 是数组,取最后一个元素(el-cascader 返回的是路径数组)
|
||||
let pidValue: any = payload.pid;
|
||||
|
||||
// 如果是数组,取最后一个元素
|
||||
if (Array.isArray(pidValue)) {
|
||||
pidValue = pidValue.length > 0 ? pidValue[pidValue.length - 1] : null;
|
||||
}
|
||||
|
||||
// 强制转换为整数
|
||||
if (pidValue === null || pidValue === undefined || pidValue === '') {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
const parsedPid = parseInt(String(pidValue), 10);
|
||||
// 如果转换失败(NaN),设置为 0
|
||||
if (isNaN(parsedPid)) {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
payload.pid = parsedPid;
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证:确保 payload.pid 是数字类型,不是数组
|
||||
if (Array.isArray(payload.pid)) {
|
||||
payload.pid = Array.isArray(payload.pid) && payload.pid.length > 0
|
||||
? parseInt(String(payload.pid[payload.pid.length - 1]), 10) || 0
|
||||
: 0;
|
||||
}
|
||||
|
||||
// 确保是数字类型
|
||||
if (typeof payload.pid !== 'number') {
|
||||
payload.pid = parseInt(String(payload.pid), 10) || 0;
|
||||
}
|
||||
|
||||
// 触发保存事件
|
||||
emit('save', payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>菜单管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="expandAll">
|
||||
<el-icon>
|
||||
<FolderOpened />
|
||||
</el-icon>
|
||||
全部展开
|
||||
</el-button>
|
||||
<el-button @click="collapseAll">
|
||||
<el-icon>
|
||||
<Folder />
|
||||
</el-icon>
|
||||
全部折叠
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAddMenu">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加菜单
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 树形表格 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="menuTree"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
:tree-props="{
|
||||
children: 'children',
|
||||
hasChildren: 'hasChildren'
|
||||
}"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="title" label="菜单名称" width="200">
|
||||
<template #default="scope">
|
||||
<div class="menu-item">
|
||||
<i v-if="scope.row.icon" :class="scope.row.icon" class="menu-icon"></i>
|
||||
<span>{{ scope.row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="path" label="路由地址"></el-table-column>
|
||||
|
||||
<el-table-column prop="MenuType" label="菜单类型" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getMenuTypeTagType(scope.row.type)">
|
||||
{{ getMenuTypeTitle(scope.row.type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center"></el-table-column>
|
||||
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="handleStatusChange(scope.row)"
|
||||
@click.stop
|
||||
:disabled="!scope.row.id"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click.stop="handleAddSubMenu(scope.row)" :disabled="scope.row.type === 3">
|
||||
<el-icon>
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
<span>子菜单</span>
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text @click.stop="handleEditMenu(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text type="danger" @click.stop="handleDeleteMenu(scope.row)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<MenuEdit v-model:visible="dialogVisible" :menu="dialogMenu" :parent-menu-options="parentMenuOptions"
|
||||
:dialog-type="dialogType" :parent-title="dialogParentTitle" @save="handleMenuSave" @cancel="handleMenuCancel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
|
||||
import { Plus, CirclePlus, Edit, Delete, Refresh, FolderOpened, Folder } from "@element-plus/icons-vue";
|
||||
import { getAllMenus, updateMenuStatus, createMenu, updateMenu, deleteMenu } from "@/api/menu";
|
||||
import MenuEdit from "./components/edit.vue";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
path: string;
|
||||
component_path: string;
|
||||
icon: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
type: 1 | 2 | 3; // 1:目录 2:页面 3:接口
|
||||
permission: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// 菜单树形数据
|
||||
const menuTree = ref<Menu[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 表格引用
|
||||
const tableRef = ref<any>(null);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMenu = ref<Partial<Menu> | null>(null);
|
||||
const dialogType = ref<'add' | 'edit' | 'addSub'>('add');
|
||||
const dialogParentTitle = ref('');
|
||||
|
||||
|
||||
|
||||
// 父级菜单选项
|
||||
const parentMenuOptions = ref<Menu[]>([]);
|
||||
|
||||
let fetchMenusPromise: Promise<any> | null = null;
|
||||
|
||||
const fetchMenus = async () => {
|
||||
if (fetchMenusPromise) {
|
||||
return fetchMenusPromise;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
fetchMenusPromise = (async () => {
|
||||
try {
|
||||
const result = await getAllMenus();
|
||||
if (result.code === 200) {
|
||||
menuTree.value = result.data;
|
||||
parentMenuOptions.value = [
|
||||
{
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "顶级菜单",
|
||||
children: [],
|
||||
} as Menu,
|
||||
...result.data,
|
||||
];
|
||||
} else {
|
||||
ElMessage.error("获取菜单失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取菜单数据失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
fetchMenusPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchMenusPromise;
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchMenus();
|
||||
ElMessage.success('刷新成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('刷新失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有菜单行数据(包括子节点)
|
||||
function getAllMenuRows(menuList: Menu[]): Menu[] {
|
||||
const rows: Menu[] = [];
|
||||
menuList.forEach((menu) => {
|
||||
rows.push(menu);
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
rows.push(...getAllMenuRows(menu.children));
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 全部展开
|
||||
function expandAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 全部折叠
|
||||
function collapseAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理行点击事件 - 展开/折叠树形结构
|
||||
const handleRowClick = (row: Menu) => {
|
||||
if (!tableRef.value) return;
|
||||
|
||||
// 检查该行是否有子节点
|
||||
const hasChildren = row.children && row.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
// 切换展开/折叠状态(toggleRowExpansion 会自动切换当前状态)
|
||||
tableRef.value.toggleRowExpansion(row);
|
||||
}
|
||||
};
|
||||
|
||||
// 构建菜单树(处理父子关系)
|
||||
const buildMenuTree = (menuList: Menu[]): Menu[] => {
|
||||
return menuList;
|
||||
};
|
||||
|
||||
// 获取菜单类型名称
|
||||
const getMenuTypeTitle = (type: number) => {
|
||||
const typeMap = { 1: "目录", 2: "页面", 3: "接口" };
|
||||
return typeMap[type as keyof typeof typeMap] || "未知类型";
|
||||
};
|
||||
|
||||
// 获取菜单类型标签样式
|
||||
const getMenuTypeTagType = (type: number) => {
|
||||
const typeMap = { 1: "primary", 2: "success", 3: "info" };
|
||||
return typeMap[type as keyof typeof typeMap] || "default";
|
||||
};
|
||||
|
||||
// 处理状态变更
|
||||
const handleStatusChange = async (menu: Menu) => {
|
||||
try {
|
||||
const result = await updateMenuStatus(menu.id, menu.status);
|
||||
if (!result.success) {
|
||||
ElMessage.error(result.message);
|
||||
// 状态更新失败时回滚
|
||||
menu.status = menu.status === 1 ? 0 : 1;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("更新状态失败: " + (error as Error).message);
|
||||
menu.status = menu.status === 1 ? 0 : 1;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 添加子菜单
|
||||
const handleAddSubMenu = (parentMenu: Menu) => {
|
||||
dialogType.value = 'addSub';
|
||||
dialogParentTitle.value = parentMenu.title;
|
||||
dialogMenu.value = {
|
||||
id: 0,
|
||||
pid: parentMenu.id,
|
||||
title: '',
|
||||
path: '',
|
||||
component_path: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
type: parentMenu.type === 2 ? 1 : parentMenu.type,
|
||||
permission: '',
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑菜单
|
||||
const handleEditMenu = (menu: Menu) => {
|
||||
dialogType.value = 'edit';
|
||||
dialogMenu.value = { ...menu };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除菜单
|
||||
const handleDeleteMenu = (menu: Menu) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除菜单 "${menu.title}" 吗?${menu.hasChildren ? "其下所有子菜单也将被删除。" : ""}`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteMenu(menu.id);
|
||||
if (result.success) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchMenus();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 添加菜单
|
||||
const handleAddMenu = () => {
|
||||
dialogType.value = 'add';
|
||||
dialogMenu.value = null;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 处理菜单保存
|
||||
const handleMenuSave = async (menu: Partial<Menu>) => {
|
||||
try {
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...menu };
|
||||
|
||||
// 确保 pid 是整数类型(后端要求必须是整数)
|
||||
// 处理数组情况:如果 pid 是数组(el-cascader 返回的是路径数组),取最后一个元素
|
||||
let pidValue: any = payload.pid;
|
||||
if (Array.isArray(pidValue)) {
|
||||
pidValue = pidValue.length > 0 ? pidValue[pidValue.length - 1] : null;
|
||||
}
|
||||
|
||||
// 强制转换为整数
|
||||
if (pidValue === null || pidValue === undefined || pidValue === '') {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
const parsedPid = parseInt(String(pidValue), 10);
|
||||
if (isNaN(parsedPid)) {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
payload.pid = parsedPid;
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证:确保 payload.pid 是数字类型,不是数组
|
||||
if (Array.isArray(payload.pid)) {
|
||||
payload.pid = Array.isArray(payload.pid) && payload.pid.length > 0
|
||||
? parseInt(String(payload.pid[payload.pid.length - 1]), 10) || 0
|
||||
: 0;
|
||||
}
|
||||
|
||||
// 确保是数字类型
|
||||
if (typeof payload.pid !== 'number') {
|
||||
payload.pid = parseInt(String(payload.pid), 10) || 0;
|
||||
}
|
||||
|
||||
if (menu.id === 0) {
|
||||
// 新增菜单
|
||||
const result = await createMenu(payload as Menu);
|
||||
if (result.code === 200) { // 修改这里,检查 code 而不是 success
|
||||
ElMessage.success(result.msg || "菜单添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑菜单
|
||||
const result = await updateMenu(menu.id!, payload as Menu);
|
||||
if (result.code === 200) { // 修改这里,检查 code 而不是 success
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单取消
|
||||
const handleMenuCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 组件挂载时加载菜单
|
||||
onMounted(() => {
|
||||
fetchMenus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.card-header span {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表格核心样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 展开图标与菜单内容对齐 */
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin: 0 !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon-cell) {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
|
||||
/* 菜单项样式 */
|
||||
.menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 隐藏无子女菜单的展开图标 */
|
||||
:deep(.el-table__expand-icon--hidden) {
|
||||
visibility: hidden;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin-right: 8px !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="操作日志详情" size="60%">
|
||||
<div class="log-detail" v-if="log">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="日志ID" label-width="150px">
|
||||
{{ log.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法" label-width="150px">
|
||||
<el-tag :type="getMethodTagType(log.method)" size="small">
|
||||
{{ log.method }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID" label-width="150px">
|
||||
{{ log.user_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户姓名" label-width="150px">
|
||||
{{ log.user_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作模块" label-width="150px">
|
||||
{{ log.module }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作动作" label-width="150px">
|
||||
{{ log.action }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作状态" label-width="150px">
|
||||
<el-tag :type="log.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ log.status === 1 ? "成功" : "失败" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求URL" :span="2" label-width="150px">
|
||||
<div class="url-text">{{ log.url }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址" label-width="150px">
|
||||
{{ log.ip }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作时间" label-width="150px" :span="2">
|
||||
{{ log.create_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户代理" label-width="150px" :span="2">
|
||||
<div class="user-agent-text">{{ log.user_agent || "无" }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求参数" :span="2" label-width="150px">
|
||||
<div class="json-content">
|
||||
<div class="json-header">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>请求数据</span>
|
||||
</div>
|
||||
<pre v-if="log.request_data">{{ formatJson(log.request_data) }}</pre>
|
||||
<span v-else class="empty-text">无</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="响应数据" :span="4" label-width="150px">
|
||||
<div class="json-content">
|
||||
<div class="json-header">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>响应数据</span>
|
||||
</div>
|
||||
<pre v-if="log.response_data">{{ formatJson(log.response_data) }}</pre>
|
||||
<span v-else class="empty-text">无</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="log.error_message" label="错误信息" :span="2">
|
||||
<div class="error-message">{{ log.error_message }}</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<div v-else class="loading-container">
|
||||
<el-empty description="加载中..." />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { Document } from "@element-plus/icons-vue";
|
||||
import { getOperationLogDetail } from "@/api/operationLog";
|
||||
|
||||
interface OperationLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_account: string;
|
||||
user_name: string;
|
||||
module: string;
|
||||
action: string;
|
||||
method: string;
|
||||
url: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
request_data: string;
|
||||
response_data: string;
|
||||
status: number;
|
||||
error_message: string;
|
||||
execution_time: number;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
logId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const log = ref<OperationLog | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
// 获取请求方法标签类型
|
||||
const getMethodTagType = (method: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
GET: "primary",
|
||||
POST: "success",
|
||||
PUT: "warning",
|
||||
DELETE: "danger",
|
||||
PATCH: "",
|
||||
};
|
||||
return typeMap[method] || "";
|
||||
};
|
||||
|
||||
// 格式化 JSON
|
||||
const formatJson = (jsonString: string): string => {
|
||||
try {
|
||||
const obj = typeof jsonString === "string" ? JSON.parse(jsonString) : jsonString;
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch (e) {
|
||||
return jsonString;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取日志详情
|
||||
const fetchLogDetail = async (logId?: number) => {
|
||||
const targetId = logId || props.logId;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
log.value = null; // 清空之前的数据
|
||||
try {
|
||||
const res = await getOperationLogDetail(targetId);
|
||||
if (res.code === 200) {
|
||||
log.value = res.data;
|
||||
console.log(log.value);
|
||||
} else {
|
||||
console.error("获取日志详情失败:", res?.msg || "未知错误");
|
||||
log.value = null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("获取日志详情失败", error);
|
||||
log.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal && props.logId) {
|
||||
fetchLogDetail();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit("update:modelValue", false);
|
||||
log.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open: (logId: number) => {
|
||||
if (logId) {
|
||||
fetchLogDetail(logId);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.log-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.url-text {
|
||||
word-break: break-all;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.user-agent-text {
|
||||
word-break: break-all;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
width: 950px;
|
||||
max-height: 200px;
|
||||
min-height: 60px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
background: #f5f7fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
resize: vertical;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #c0c4cc;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: "Monaco", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #303133;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f56c6c;
|
||||
background: #fef0f0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.json-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>操作日志</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filterForm" class="filter-form">
|
||||
<!-- <el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filterForm.keyword"
|
||||
placeholder="搜索用户账号、姓名、URL"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item> -->
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="filterForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="成功" value="1" />
|
||||
<el-option label="失败" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 350px"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志列表 -->
|
||||
<el-table
|
||||
:data="logs"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column prop="id" label="ID" width="60" align="center" />
|
||||
<el-table-column
|
||||
prop="user_name"
|
||||
label="用户姓名"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="action"
|
||||
label="操作"
|
||||
min-width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="module" label="模块" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag type="primary" size="small">
|
||||
{{ scope.row.module }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="method"
|
||||
label="请求方法"
|
||||
width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getMethodTagType(scope.row.method)" size="small">
|
||||
{{ scope.row.method }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="url"
|
||||
label="请求URL"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="ip" label="IP地址" width="130" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="scope.row.status === 1 ? 'success' : 'danger'"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.row.status === 1 ? "成功" : "失败" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="create_time"
|
||||
label="操作时间"
|
||||
width="180"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleViewDetail(scope.row)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<DetailDialog
|
||||
ref="detailDialogRef"
|
||||
v-model="detailDialogVisible"
|
||||
:log-id="currentLogId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Refresh, Delete, Search } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getOperationLogs,
|
||||
deleteOperationLog,
|
||||
batchDeleteOperationLogs,
|
||||
getOperationStatistics,
|
||||
} from "@/api/operationLog";
|
||||
import DetailDialog from "./components/detail.vue";
|
||||
|
||||
interface OperationLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_account: string;
|
||||
user_name: string;
|
||||
module: string;
|
||||
action: string;
|
||||
method: string;
|
||||
url: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
request_data: string;
|
||||
response_data: string;
|
||||
status: number;
|
||||
error_message: string;
|
||||
execution_time: number;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const logs = ref<OperationLog[]>([]);
|
||||
const selectedLogs = ref<OperationLog[]>([]);
|
||||
|
||||
// 筛选表单
|
||||
const filterForm = ref({
|
||||
keyword: "",
|
||||
module: "",
|
||||
action: "",
|
||||
status: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
});
|
||||
|
||||
const dateRange = ref<[string, string] | null>(null);
|
||||
|
||||
// 模块和操作列表
|
||||
const modules = ref<string[]>([]);
|
||||
const actions = ref<string[]>([]);
|
||||
|
||||
// 详情对话框
|
||||
const detailDialogVisible = ref(false);
|
||||
const detailDialogRef = ref<any>(null);
|
||||
const currentLogId = ref<number | undefined>(undefined);
|
||||
|
||||
// 获取请求方法标签类型
|
||||
const getMethodTagType = (method: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
GET: "info",
|
||||
POST: "success",
|
||||
PUT: "warning",
|
||||
DELETE: "danger",
|
||||
PATCH: "",
|
||||
};
|
||||
return typeMap[method] || "";
|
||||
};
|
||||
|
||||
// 获取操作日志列表
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
|
||||
if (filterForm.value.keyword) {
|
||||
params.keyword = filterForm.value.keyword;
|
||||
}
|
||||
if (filterForm.value.module) {
|
||||
params.module = filterForm.value.module;
|
||||
}
|
||||
if (filterForm.value.action) {
|
||||
params.action = filterForm.value.action;
|
||||
}
|
||||
if (filterForm.value.status !== "") {
|
||||
params.status = filterForm.value.status;
|
||||
}
|
||||
if (filterForm.value.startTime) {
|
||||
params.startTime = filterForm.value.startTime;
|
||||
}
|
||||
if (filterForm.value.endTime) {
|
||||
params.endTime = filterForm.value.endTime;
|
||||
}
|
||||
|
||||
const res = await getOperationLogs(params);
|
||||
if (res.code === 200) {
|
||||
logs.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取操作日志失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "获取操作日志失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取统计信息(模块和操作列表)
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
const res = await getOperationStatistics();
|
||||
if (res.code === 200) {
|
||||
modules.value = res.data.modules || [];
|
||||
actions.value = res.data.actions || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取统计信息失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新
|
||||
const refresh = () => {
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
page.value = 1;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
filterForm.value = {
|
||||
keyword: "",
|
||||
module: "",
|
||||
action: "",
|
||||
status: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
};
|
||||
dateRange.value = null;
|
||||
page.value = 1;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 时间范围变化
|
||||
const handleDateRangeChange = (val: [string, string] | null) => {
|
||||
if (val && val.length === 2) {
|
||||
filterForm.value.startTime = val[0];
|
||||
filterForm.value.endTime = val[1];
|
||||
} else {
|
||||
filterForm.value.startTime = "";
|
||||
filterForm.value.endTime = "";
|
||||
}
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
// 分页改变
|
||||
const handlePageChange = (val: number) => {
|
||||
page.value = val;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 每页数量改变
|
||||
const handleSizeChange = (val: number) => {
|
||||
pageSize.value = val;
|
||||
page.value = 1;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 选择改变
|
||||
const handleSelectionChange = (selection: OperationLog[]) => {
|
||||
selectedLogs.value = selection;
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (log: OperationLog) => {
|
||||
currentLogId.value = log.id;
|
||||
detailDialogVisible.value = true;
|
||||
// 使用 nextTick 确保组件已渲染后再调用 open 方法
|
||||
nextTick(() => {
|
||||
if (detailDialogRef.value && detailDialogRef.value.open) {
|
||||
detailDialogRef.value.open(log.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchLogs();
|
||||
fetchStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
// background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<div class="permissions-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">
|
||||
<el-icon><Key /></el-icon>
|
||||
权限管理
|
||||
</span>
|
||||
<span class="subtitle">为角色分配菜单和API访问权限</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧:角色列表 -->
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="role-card">
|
||||
<template #header>
|
||||
<div class="card-title">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<span>角色列表</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-input
|
||||
v-model="roleSearchQuery"
|
||||
placeholder="搜索角色..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
|
||||
<el-scrollbar height="600px" class="role-list">
|
||||
<div
|
||||
v-for="role in filteredRoles"
|
||||
:key="role.roleId"
|
||||
:class="['role-item', { active: selectedRole?.roleId === role.roleId }]"
|
||||
@click="selectRole(role)"
|
||||
>
|
||||
<div class="role-info">
|
||||
<div class="role-name">{{ role.roleName }}</div>
|
||||
<div class="role-code">{{ role.roleCode }}</div>
|
||||
</div>
|
||||
<el-icon v-if="selectedRole?.roleId === role.roleId" class="check-icon">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="filteredRoles.length === 0" description="暂无角色数据" />
|
||||
</el-scrollbar>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:权限分配 -->
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover" class="permission-card">
|
||||
<template #header>
|
||||
<div class="card-title">
|
||||
<el-icon><Menu /></el-icon>
|
||||
<span>权限分配</span>
|
||||
<span v-if="selectedRole" class="selected-role-name">
|
||||
({{ selectedRole.roleName }})
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!selectedRole" class="empty-state">
|
||||
<el-empty description="请先选择一个角色" />
|
||||
</div>
|
||||
|
||||
<div v-else class="permission-content">
|
||||
<!-- 搜索和操作按钮 -->
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="permissionSearchQuery"
|
||||
placeholder="搜索菜单..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 300px;"
|
||||
/>
|
||||
|
||||
<div class="actions">
|
||||
<el-button @click="expandAll">全部展开</el-button>
|
||||
<el-button @click="collapseAll">全部折叠</el-button>
|
||||
<el-button @click="checkAll">全选</el-button>
|
||||
<el-button @click="uncheckAll">取消全选</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 权限树 -->
|
||||
<el-scrollbar height="550px" class="tree-container">
|
||||
<el-tree
|
||||
ref="permissionTree"
|
||||
:data="menuTreeData"
|
||||
:props="treeProps"
|
||||
:filter-node-method="filterNode"
|
||||
node-key="menu_id"
|
||||
show-checkbox
|
||||
:default-expand-all="false"
|
||||
:check-strictly="false"
|
||||
class="permission-tree"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<div class="custom-tree-node">
|
||||
<div class="node-content">
|
||||
<el-icon v-if="data.menu_type === 1" class="menu-icon">
|
||||
<Folder />
|
||||
</el-icon>
|
||||
<el-icon v-else-if="data.menu_type === 2" class="page-icon">
|
||||
<Document />
|
||||
</el-icon>
|
||||
<el-icon v-else class="api-icon">
|
||||
<Link />
|
||||
</el-icon>
|
||||
<span class="node-label">{{ node.label }}</span>
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<el-tag v-if="data.menu_type === 1" type="primary" size="small">
|
||||
目录
|
||||
</el-tag>
|
||||
<el-tag v-else-if="data.menu_type === 2" type="success" size="small">
|
||||
页面
|
||||
</el-tag>
|
||||
<el-tag v-else type="warning" size="small">
|
||||
按钮
|
||||
</el-tag>
|
||||
<el-tag v-if="data.permission" type="info" size="small" class="permission-tag">
|
||||
{{ data.permission }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="footer">
|
||||
<el-button type="primary" @click="savePermissions" :loading="saving">
|
||||
<el-icon><Select /></el-icon>
|
||||
保存权限设置
|
||||
</el-button>
|
||||
<el-button @click="resetPermissions">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
Key,
|
||||
UserFilled,
|
||||
Menu,
|
||||
Search,
|
||||
Check,
|
||||
Folder,
|
||||
Link,
|
||||
Document,
|
||||
Select,
|
||||
RefreshLeft,
|
||||
} from '@element-plus/icons-vue';
|
||||
import { getRoleByTenantId, getAllRoles } from '@/api/role';
|
||||
import {
|
||||
getAllMenuPermissions,
|
||||
getRolePermissions,
|
||||
assignRolePermissions,
|
||||
} from '@/api/permission.js';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
// 角色相关
|
||||
const roleList = ref([]);
|
||||
const roleSearchQuery = ref('');
|
||||
const selectedRole = ref(null);
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 权限相关
|
||||
const allMenus = ref([]);
|
||||
const menuTreeData = ref([]);
|
||||
const permissionSearchQuery = ref('');
|
||||
const permissionTree = ref(null);
|
||||
const saving = ref(false);
|
||||
|
||||
// 树配置
|
||||
const treeProps = {
|
||||
children: 'children',
|
||||
label: 'menu_name',
|
||||
};
|
||||
|
||||
// 过滤后的角色列表
|
||||
const filteredRoles = computed(() => {
|
||||
if (!roleSearchQuery.value) {
|
||||
return roleList.value;
|
||||
}
|
||||
const query = roleSearchQuery.value.toLowerCase();
|
||||
return roleList.value.filter(
|
||||
(role) =>
|
||||
role.roleName.toLowerCase().includes(query) ||
|
||||
role.roleCode.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// 加载所有菜单权限(根据选中的角色过滤)
|
||||
const loadAllMenus = async (roleId = null) => {
|
||||
try {
|
||||
// 如果提供了roleId,传递给接口用于根据角色的default值过滤菜单
|
||||
const params = roleId ? { roleId } : {};
|
||||
const res = await getAllMenuPermissions(params);
|
||||
|
||||
if (res.success && res.data) {
|
||||
allMenus.value = res.data;
|
||||
buildMenuTree();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载菜单列表失败:', error);
|
||||
ElMessage.error('加载菜单列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 构建菜单树
|
||||
const buildMenuTree = () => {
|
||||
const tree = [];
|
||||
const map = new Map();
|
||||
|
||||
// 先创建所有节点的映射
|
||||
allMenus.value.forEach((menu) => {
|
||||
map.set(menu.menu_id, { ...menu, children: [] });
|
||||
});
|
||||
|
||||
// 构建树形结构
|
||||
allMenus.value.forEach((menu) => {
|
||||
const node = map.get(menu.menu_id);
|
||||
if (menu.parent_id === 0) {
|
||||
tree.push(node);
|
||||
} else {
|
||||
const parent = map.get(menu.parent_id);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menuTreeData.value = tree;
|
||||
};
|
||||
|
||||
// 选择角色
|
||||
const selectRole = async (role) => {
|
||||
selectedRole.value = role;
|
||||
// 重新加载菜单列表(根据角色的default值过滤)
|
||||
await loadAllMenus(role.roleId);
|
||||
await loadRolePermissions(role.roleId);
|
||||
};
|
||||
|
||||
// 加载角色权限
|
||||
const loadRolePermissions = async (roleId) => {
|
||||
try {
|
||||
const res = await getRolePermissions(roleId);
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 等待树渲染完成
|
||||
await nextTick();
|
||||
|
||||
// 设置选中的节点
|
||||
if (permissionTree.value) {
|
||||
permissionTree.value.setCheckedKeys(res.data.menu_ids || []);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载角色权限失败:', error);
|
||||
ElMessage.error('加载角色权限失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 保存权限
|
||||
const savePermissions = async () => {
|
||||
if (!selectedRole.value) {
|
||||
ElMessage.warning('请先选择角色');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 只获取完全选中的节点(不包括半选中的父节点)
|
||||
// 因为半选中的父节点表示部分子节点被选中,父节点本身不需要权限记录
|
||||
const checkedKeys = permissionTree.value.getCheckedKeys();
|
||||
const menuIds = checkedKeys;
|
||||
|
||||
saving.value = true;
|
||||
const res = await assignRolePermissions(selectedRole.value.roleId, menuIds);
|
||||
|
||||
if (res.success) {
|
||||
ElMessage.success('权限保存成功');
|
||||
await loadRolePermissions(selectedRole.value.roleId);
|
||||
} else {
|
||||
ElMessage.error(res.message || '权限保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存权限失败:', error);
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
ElMessage.error('请求超时,请重试。如果数据量较大,可能需要更长时间');
|
||||
} else {
|
||||
ElMessage.error(error.message || '保存权限失败,请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重置权限
|
||||
const resetPermissions = async () => {
|
||||
if (!selectedRole.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要重置权限设置吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
await loadRolePermissions(selectedRole.value.roleId);
|
||||
ElMessage.success('已重置');
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
};
|
||||
|
||||
// 全部展开
|
||||
const expandAll = () => {
|
||||
if (permissionTree.value) {
|
||||
const allKeys = allMenus.value.map((m) => m.menu_id);
|
||||
allKeys.forEach((key) => {
|
||||
const node = permissionTree.value.getNode(key);
|
||||
if (node) {
|
||||
node.expanded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 全部折叠
|
||||
const collapseAll = () => {
|
||||
if (permissionTree.value) {
|
||||
const allKeys = allMenus.value.map((m) => m.menu_id);
|
||||
allKeys.forEach((key) => {
|
||||
const node = permissionTree.value.getNode(key);
|
||||
if (node) {
|
||||
node.expanded = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 全选
|
||||
const checkAll = () => {
|
||||
if (permissionTree.value) {
|
||||
const allKeys = allMenus.value.map((m) => m.menu_id);
|
||||
permissionTree.value.setCheckedKeys(allKeys);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消全选
|
||||
const uncheckAll = () => {
|
||||
if (permissionTree.value) {
|
||||
permissionTree.value.setCheckedKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤节点
|
||||
const filterNode = (value, data) => {
|
||||
if (!value) return true;
|
||||
return (
|
||||
data.menu_name.toLowerCase().includes(value.toLowerCase()) ||
|
||||
(data.path && data.path.toLowerCase().includes(value.toLowerCase())) ||
|
||||
(data.permission && data.permission.toLowerCase().includes(value.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
// 监听搜索框变化
|
||||
watch(permissionSearchQuery, (val) => {
|
||||
if (permissionTree.value) {
|
||||
permissionTree.value.filter(val);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化
|
||||
const init = async () => {
|
||||
await loadRoles();
|
||||
await loadAllMenus();
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.permissions-container {
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.role-card,
|
||||
.permission-card {
|
||||
height: 100%;
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
.selected-role-name {
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
margin-top: 16px;
|
||||
|
||||
.role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #e6f0ff;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
|
||||
.role-name {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.role-info {
|
||||
flex: 1;
|
||||
|
||||
.role-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.role-code {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.permission-content {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.permission-tree {
|
||||
background: transparent;
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
height: auto;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #e6f0ff;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tree-node {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 16px;
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.menu-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.api-icon {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.node-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.permission-tag {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>程序管理</h2>
|
||||
<el-button type="primary" @click="showProgramDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加程序
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在加载程序数据...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<el-alert title="加载失败" :message="error" type="error" show-icon />
|
||||
<el-button type="primary" @click="fetchPrograms">重试</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 程序列表 -->
|
||||
<div v-else>
|
||||
<el-table :data="programs" stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="name" label="程序名称" min-width="160" align="center" />
|
||||
<el-table-column prop="type" label="类型" width="100" align="center" />
|
||||
<el-table-column prop="owner" label="负责人" width="120" align="center" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" align="center" />
|
||||
<el-table-column prop="remark" label="备注" min-width="140" align="center" />
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑程序弹窗 -->
|
||||
<el-dialog
|
||||
:title="isEditing ? '编辑程序' : '添加程序'"
|
||||
v-model="showProgramDialog"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
:model="programForm"
|
||||
:rules="formRules"
|
||||
ref="programFormRef"
|
||||
label-width="90px"
|
||||
>
|
||||
<el-form-item label="程序名称" prop="name">
|
||||
<el-input v-model="programForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="type">
|
||||
<el-select v-model="programForm.type" placeholder="选择类型">
|
||||
<el-option label="Web" value="Web" />
|
||||
<el-option label="服务" value="Service" />
|
||||
<el-option label="工具" value="Tool" />
|
||||
<el-option label="其他" value="Other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人" prop="owner">
|
||||
<el-input v-model="programForm.owner" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="programForm.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showProgramDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitProgramForm"> 保存 </el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules,
|
||||
} from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
|
||||
// 数据与状态
|
||||
const programs = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
|
||||
const showProgramDialog = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const programForm = reactive({
|
||||
id: null,
|
||||
name: "",
|
||||
type: "",
|
||||
owner: "",
|
||||
remark: "",
|
||||
});
|
||||
const programFormRef = ref<FormInstance>();
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: "请输入程序名称", trigger: "blur" },
|
||||
{ min: 2, max: 32, message: "名称长度2-32字符", trigger: "blur" },
|
||||
],
|
||||
type: [{ required: true, message: "请选择类型", trigger: "change" }],
|
||||
owner: [{ required: true, message: "请输入负责人", trigger: "blur" }],
|
||||
};
|
||||
|
||||
async function fetchPrograms() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
// TODO: 替换为真实API
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// 假数据
|
||||
const all = [
|
||||
{
|
||||
id: 1,
|
||||
name: "门户网站",
|
||||
type: "Web",
|
||||
owner: "张三",
|
||||
createdAt: "2023-11-08 09:22:53",
|
||||
remark: "官网",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "自动备份服务",
|
||||
type: "Service",
|
||||
owner: "李四",
|
||||
createdAt: "2024-01-16 15:40:01",
|
||||
remark: "每日凌晨自动执行",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "运维工具",
|
||||
type: "Tool",
|
||||
owner: "王五",
|
||||
createdAt: "2023-12-02 12:51:29",
|
||||
remark: "",
|
||||
},
|
||||
// ...更多
|
||||
];
|
||||
total.value = all.length;
|
||||
programs.value = all.slice(
|
||||
(page.value - 1) * pageSize.value,
|
||||
page.value * pageSize.value
|
||||
);
|
||||
} catch (err: any) {
|
||||
error.value = err.message || "获取程序列表失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (val: number) => {
|
||||
page.value = val;
|
||||
fetchPrograms();
|
||||
};
|
||||
|
||||
function resetProgramForm() {
|
||||
programForm.id = null;
|
||||
programForm.name = "";
|
||||
programForm.type = "";
|
||||
programForm.owner = "";
|
||||
programForm.remark = "";
|
||||
}
|
||||
|
||||
function handleEdit(row: any) {
|
||||
isEditing.value = true;
|
||||
programForm.id = row.id;
|
||||
programForm.name = row.name;
|
||||
programForm.type = row.type;
|
||||
programForm.owner = row.owner;
|
||||
programForm.remark = row.remark;
|
||||
showProgramDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除程序「${row.name}」? 删除后不可恢复。`,
|
||||
"警告",
|
||||
{ type: "warning" }
|
||||
);
|
||||
// TODO: 替换为真实API
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
ElMessage.success("删除成功");
|
||||
fetchPrograms();
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProgramForm() {
|
||||
await programFormRef.value?.validate();
|
||||
loading.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
// TODO: 替换为真实API更新
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
ElMessage.success("程序更新成功");
|
||||
} else {
|
||||
// TODO: 替换为真实API新增
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
ElMessage.success("程序添加成功");
|
||||
}
|
||||
showProgramDialog.value = false;
|
||||
fetchPrograms();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || "操作失败,请重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPrograms();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="角色详情" width="600px" @close="handleClose">
|
||||
<el-descriptions :column="1" border v-loading="loading">
|
||||
<el-descriptions-item label="角色ID">
|
||||
{{ roleDetail.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色名称">
|
||||
{{ roleDetail.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="roleDetail.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ roleDetail.status === 1 ? "启用" : "禁用" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="权限列表">
|
||||
<div v-if="menuNames.length > 0" style="max-height: 300px; overflow-y: auto;">
|
||||
<el-tag
|
||||
v-for="(name, index) in menuNames"
|
||||
:key="index"
|
||||
style="margin: 4px;"
|
||||
size="small"
|
||||
>
|
||||
{{ name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else style="color: var(--el-text-color-secondary);">
|
||||
暂无权限
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ roleDetail.create_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">
|
||||
{{ roleDetail.update_time }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { getRoleById } from "@/api/role";
|
||||
import { getAllMenus } from "@/api/menu";
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
roleId?: number | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
roleId: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const roleDetail = ref<any>({});
|
||||
const allMenus = ref<any[]>([]);
|
||||
|
||||
// 监听 modelValue
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val && props.roleId) {
|
||||
loadRoleDetail();
|
||||
loadMenus();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible
|
||||
watch(visible, (val) => {
|
||||
emit("update:modelValue", val);
|
||||
});
|
||||
|
||||
// 解析权限ID
|
||||
const parseRights = (rights: any): number[] => {
|
||||
if (!rights) return [];
|
||||
if (Array.isArray(rights)) return rights;
|
||||
if (typeof rights === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(rights);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// 递归获取所有菜单ID和名称的映射
|
||||
const getMenuMap = (menus: any[]): Map<number, string> => {
|
||||
const map = new Map<number, string>();
|
||||
const traverse = (items: any[]) => {
|
||||
for (const item of items) {
|
||||
map.set(item.id, item.title || item.name || `菜单${item.id}`);
|
||||
if (item.children && item.children.length > 0) {
|
||||
traverse(item.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
traverse(menus);
|
||||
return map;
|
||||
};
|
||||
|
||||
// 计算菜单名称列表
|
||||
const menuNames = computed(() => {
|
||||
if (!roleDetail.value.rights) return [];
|
||||
const rightIds = parseRights(roleDetail.value.rights);
|
||||
const menuMap = getMenuMap(allMenus.value);
|
||||
return rightIds
|
||||
.map((id) => menuMap.get(id))
|
||||
.filter((name) => name !== undefined) as string[];
|
||||
});
|
||||
|
||||
// 加载角色详情
|
||||
const loadRoleDetail = async () => {
|
||||
if (!props.roleId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getRoleById(props.roleId);
|
||||
if (res.code === 200) {
|
||||
roleDetail.value = res.data || {};
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取角色详情失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载角色详情失败:", error);
|
||||
ElMessage.error("获取角色详情失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载菜单树
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenus();
|
||||
if (res.code === 200) {
|
||||
allMenus.value = res.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载菜单失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
roleDetail.value = {};
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-descriptions__label) {
|
||||
width: 120px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑角色' : '添加角色'"
|
||||
width="600px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="请输入角色名称"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="权限设置" prop="rights">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="menuTree"
|
||||
show-checkbox
|
||||
node-key="id"
|
||||
:props="{ children: 'children', label: 'title' }"
|
||||
:default-checked-keys="form.rights"
|
||||
style="width: 100%; border: 1px solid var(--el-border-color); border-radius: 4px; padding: 10px; max-height: 400px; overflow-y: auto;"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createRole, updateRole } from "@/api/role";
|
||||
import { getAllMenus } from "@/api/menu";
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
role?: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
role: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref();
|
||||
const treeRef = ref();
|
||||
const menuTree = ref<any[]>([]);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
status: 1,
|
||||
rights: [] as number[],
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: "请输入角色名称", trigger: "blur" },
|
||||
{ min: 2, max: 50, message: "角色名称长度在 2 到 50 个字符", trigger: "blur" },
|
||||
],
|
||||
status: [{ required: true, message: "请选择状态", trigger: "change" }],
|
||||
};
|
||||
|
||||
// 监听 modelValue
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
loadMenus();
|
||||
if (props.role) {
|
||||
isEdit.value = true;
|
||||
form.value = {
|
||||
name: props.role.name,
|
||||
status: props.role.status,
|
||||
rights: parseRights(props.role.rights),
|
||||
};
|
||||
// 等待树加载完成后设置选中状态
|
||||
nextTick(() => {
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys(form.value.rights);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible
|
||||
watch(visible, (val) => {
|
||||
emit("update:modelValue", val);
|
||||
});
|
||||
|
||||
// 解析权限字符串
|
||||
const parseRights = (rights: any): number[] => {
|
||||
if (!rights) return [];
|
||||
if (Array.isArray(rights)) return rights;
|
||||
if (typeof rights === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(rights);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// 加载菜单树
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const res = await getAllMenus();
|
||||
if (res.code === 200) {
|
||||
menuTree.value = res.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载菜单失败:", error);
|
||||
ElMessage.error("加载菜单失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
name: "",
|
||||
status: 1,
|
||||
rights: [],
|
||||
};
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
|
||||
// 获取选中的菜单ID
|
||||
const checkedKeys = treeRef.value.getCheckedKeys();
|
||||
const halfCheckedKeys = treeRef.value.getHalfCheckedKeys();
|
||||
const allCheckedKeys = [...checkedKeys, ...halfCheckedKeys];
|
||||
|
||||
const submitData = {
|
||||
name: form.value.name,
|
||||
status: form.value.status,
|
||||
rights: allCheckedKeys,
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
let res;
|
||||
if (isEdit.value && props.role) {
|
||||
res = await updateRole(props.role.id, submitData);
|
||||
} else {
|
||||
res = await createRole(submitData);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(isEdit.value ? "更新成功" : "创建成功");
|
||||
emit("success");
|
||||
handleClose();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== false) {
|
||||
// 不是表单验证错误
|
||||
ElMessage.error(error.message || "操作失败");
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-tree) {
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>角色管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加角色
|
||||
</el-button>
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="error" class="error-state">
|
||||
<el-alert title="加载失败" :message="error" type="error" show-icon />
|
||||
<el-button type="primary" @click="refresh">重试</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 角色列表 -->
|
||||
<div v-else>
|
||||
<el-table :data="roles" stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column
|
||||
prop="name"
|
||||
label="角色名称"
|
||||
min-width="150"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? "启用" : "禁用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="rights"
|
||||
label="权限"
|
||||
min-width="150"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleView(row)"
|
||||
>
|
||||
<el-icon><View /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.default !== 1 && row.default !== 2"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.id !== 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<RoleEditDialog
|
||||
v-model="editDialogVisible"
|
||||
:role="currentRole"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<RoleDetailDialog
|
||||
v-model="detailDialogVisible"
|
||||
:roleId="currentRoleId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, View, Edit, Delete, Refresh } from "@element-plus/icons-vue";
|
||||
import { getAllRoles, getRoleById, deleteRole } from "@/api/role";
|
||||
import RoleEditDialog from "./components/edit.vue";
|
||||
import RoleDetailDialog from "./components/detail.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
// 使用 auth store 获取用户信息
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const roles = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const editDialogVisible = ref(false);
|
||||
const detailDialogVisible = ref(false);
|
||||
const currentRole = ref<any>(null);
|
||||
const currentRoleId = ref<number | null>(null);
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = authStore.user;
|
||||
if (userInfo && userInfo.id) {
|
||||
// console.log('用户名:', userInfo.username || userInfo.nickname);
|
||||
// console.log('用户ID:', userInfo.id);
|
||||
// console.log('角色:', userInfo.role);
|
||||
} else {
|
||||
// console.log('未找到用户信息或用户未登录');
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await getAllRoles();
|
||||
if (res.code === 200) {
|
||||
roles.value = res.data || [];
|
||||
console.log('角色列表:', roles.value);
|
||||
} else {
|
||||
error.value = res.msg || "获取角色列表失败";
|
||||
ElMessage.error(error.value);
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || "获取角色列表失败";
|
||||
ElMessage.error(error.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
await fetchRoles();
|
||||
if (!error.value) {
|
||||
ElMessage.success("刷新成功");
|
||||
}
|
||||
}
|
||||
|
||||
// 添加角色
|
||||
function handleAdd() {
|
||||
currentRole.value = null;
|
||||
editDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
function handleView(row: any) {
|
||||
currentRoleId.value = row.id;
|
||||
detailDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 编辑角色
|
||||
function handleEdit(row: any) {
|
||||
currentRole.value = { ...row };
|
||||
editDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除角色「${row.name}」吗?删除后不可恢复。`,
|
||||
"警告",
|
||||
{ type: "warning" }
|
||||
);
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await deleteRole(row.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchRoles();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || "删除失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
function handleEditSuccess() {
|
||||
fetchRoles();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
padding: 32px 0 16px 0;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 5px;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<el-form
|
||||
ref="contactFormRef"
|
||||
:model="contactForm"
|
||||
:rules="contactRules"
|
||||
label-width="120px"
|
||||
style="max-width: 600px"
|
||||
>
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input
|
||||
v-model="contactForm.phone"
|
||||
placeholder="请输入联系电话"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="contactForm.email"
|
||||
placeholder="请输入联系邮箱"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="公司地址" prop="address">
|
||||
<el-input
|
||||
v-model="contactForm.address"
|
||||
placeholder="请输入公司地址"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="工作时间" prop="workTime">
|
||||
<el-input
|
||||
v-model="contactForm.workTime"
|
||||
placeholder="如:周一至周五 9:00-18:00"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveContactSettings">保存设置</el-button>
|
||||
<el-button @click="resetContactForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
|
||||
const contactFormRef = ref<FormInstance>();
|
||||
|
||||
const contactForm = reactive({
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
workTime: ""
|
||||
});
|
||||
|
||||
const contactRules: FormRules = {
|
||||
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }]
|
||||
};
|
||||
|
||||
const saveContactSettings = async () => {
|
||||
if (!contactFormRef.value) return;
|
||||
await contactFormRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
// TODO: 保存联系方式
|
||||
ElMessage.success("联系方式保存成功");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetContactForm = () => {
|
||||
contactForm.phone = "";
|
||||
contactForm.email = "";
|
||||
contactForm.address = "";
|
||||
contactForm.workTime = "";
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
contactForm
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<el-form
|
||||
ref="legalNoticeFormRef"
|
||||
:model="formData"
|
||||
label-width="120px"
|
||||
style="max-width: 600px"
|
||||
>
|
||||
<el-form-item label="法律声明" prop="legalNotice">
|
||||
<el-input
|
||||
v-model="legalNotice"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="请输入法律声明"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="隐私条款" prop="privacyTerms">
|
||||
<el-input
|
||||
v-model="privacyTerms"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="请输入隐私条款"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveLegalInfos">保存设置</el-button>
|
||||
<el-button @click="resetLegalNoticeForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
|
||||
import { getLegalInfos, saveLegalInfos } from "@/api/sitesettings";
|
||||
|
||||
const legalNoticeFormRef = ref<FormInstance>();
|
||||
|
||||
const legalNotice = ref("");
|
||||
const privacyTerms = ref("");
|
||||
|
||||
const seoForm = reactive({
|
||||
legalNotice: "",
|
||||
privacyTerms: "",
|
||||
});
|
||||
|
||||
const formData = {
|
||||
legalNotice,
|
||||
privacyTerms,
|
||||
};
|
||||
|
||||
//调用法律声明和隐私条款数据
|
||||
const initLegalInfos = async () => {
|
||||
const res = await getLegalInfos();
|
||||
if (res.code === 200 && res.data) {
|
||||
const data = res.data;
|
||||
const dataMap: Record<string, string> = {};
|
||||
data.forEach((item: any) => {
|
||||
dataMap[item.label] = item.value;
|
||||
});
|
||||
legalNotice.value = dataMap["legalNotice"] || "";
|
||||
privacyTerms.value = dataMap["privacyTerms"] || "";
|
||||
}
|
||||
};
|
||||
|
||||
const saveLegalInfos = async () => {
|
||||
if (!legalNoticeFormRef.value) return;
|
||||
await legalNoticeFormRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
// TODO: 保存法律声明和隐私条款
|
||||
ElMessage.success("法律声明和隐私条款保存成功");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetLegalNoticeForm = () => {
|
||||
legalNotice.value = "";
|
||||
privacyTerms.value = "";
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
legalNotice,
|
||||
privacyTerms,
|
||||
});
|
||||
|
||||
// 初始化法律声明和隐私条款数据
|
||||
onMounted(() => {
|
||||
initLegalInfos();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<el-form
|
||||
ref="normalFormRef"
|
||||
:model="formData"
|
||||
:rules="normalRules"
|
||||
label-width="120px"
|
||||
style="max-width: 600px"
|
||||
>
|
||||
<el-form-item label="站点名称" prop="sitename">
|
||||
<el-input v-model="sitename" placeholder="请输入站点名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="站点Logo" prop="logo">
|
||||
<el-upload
|
||||
class="logo-uploader"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
:on-change="handleLogoChange"
|
||||
>
|
||||
<img
|
||||
v-if="logo"
|
||||
:src="API_BASE_URL + logo.replace(/^\//, '/')"
|
||||
class="logo-image"
|
||||
/>
|
||||
<el-icon v-else class="logo-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="企业名称" prop="companyname">
|
||||
<el-input v-model="companyname" placeholder="请输入企业名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="站点SEO描述" prop="description">
|
||||
<el-input v-model="description" placeholder="请输入站点SEO描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版权信息" prop="copyright">
|
||||
<el-input v-model="copyright" placeholder="如:© 2026 公司名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备案号 " prop="icp">
|
||||
<el-input v-model="icp" placeholder="如:苏ICP备20260000号" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSaveNormalInfos"
|
||||
>保存设置</el-button
|
||||
>
|
||||
<el-button @click="resetnormalForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import type { FormInstance, FormRules, UploadFile } from "element-plus";
|
||||
import { getNormalInfos, saveNormalInfos } from "@/api/sitesettings";
|
||||
import { uploadFile } from "@/api/file";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const normalFormRef = ref<FormInstance>();
|
||||
|
||||
const sitename = ref("");
|
||||
const companyname = ref("");
|
||||
const logo = ref("");
|
||||
const description = ref("");
|
||||
const copyright = ref("");
|
||||
const icp = ref("");
|
||||
|
||||
const formData = {
|
||||
sitename,
|
||||
companyname,
|
||||
logo,
|
||||
description,
|
||||
copyright,
|
||||
icp,
|
||||
};
|
||||
|
||||
const normalRules: FormRules = {
|
||||
sitename: [{ required: true, message: "请输入站点名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
//调用基础数据
|
||||
const initNormalInfos = async () => {
|
||||
const res = await getNormalInfos();
|
||||
if (res.code === 200 && res.data) {
|
||||
const data = res.data;
|
||||
const dataMap: Record<string, string> = {};
|
||||
data.forEach((item: any) => {
|
||||
dataMap[item.label] = item.value;
|
||||
});
|
||||
sitename.value = dataMap["sitename"] || "";
|
||||
logo.value = dataMap["logo"] || "";
|
||||
description.value = dataMap["description"] || "";
|
||||
copyright.value = dataMap["copyright"] || "";
|
||||
icp.value = dataMap["icp"] || "";
|
||||
companyname.value = dataMap["companyname"] || "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoChange = (file: UploadFile) => {
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("file", file.raw);
|
||||
uploadFormData.append("cate", "site");
|
||||
|
||||
uploadFile(uploadFormData).then((uploadRes) => {
|
||||
if (
|
||||
(uploadRes.code === 200 || uploadRes.code === 201) &&
|
||||
uploadRes.data &&
|
||||
uploadRes.data.url
|
||||
) {
|
||||
logo.value = uploadRes.data.url.replace(/\\/g, "/");
|
||||
} else {
|
||||
ElMessage.error(uploadRes.msg || "上传失败");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveNormalInfos = async () => {
|
||||
if (!normalFormRef.value) return;
|
||||
await normalFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
const data = [
|
||||
{ label: "sitename", value: sitename.value },
|
||||
{ label: "logo", value: logo.value },
|
||||
{ label: "description", value: description.value },
|
||||
{ label: "companyname", value: companyname.value },
|
||||
{ label: "copyright", value: copyright.value },
|
||||
{ label: "icp", value: icp.value },
|
||||
];
|
||||
const res = await saveNormalInfos(data);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("保存成功");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetnormalForm = () => {
|
||||
sitename.value = "";
|
||||
companyname.value = "";
|
||||
logo.value = "";
|
||||
description.value = "";
|
||||
copyright.value = "";
|
||||
icp.value = "";
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
sitename,
|
||||
companyname,
|
||||
logo,
|
||||
description,
|
||||
copyright,
|
||||
icp,
|
||||
});
|
||||
|
||||
// 初始化基础数据
|
||||
onMounted(() => {
|
||||
initNormalInfos();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<el-form
|
||||
ref="otherFormRef"
|
||||
:model="otherForm"
|
||||
label-width="120px"
|
||||
style="max-width: 600px"
|
||||
>
|
||||
<el-form-item label="开启维护模式">
|
||||
<el-switch v-model="otherForm.maintenanceMode" />
|
||||
<span class="form-tip">开启后,前台将显示维护中页面</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="开启注册">
|
||||
<el-switch v-model="otherForm.allowRegister" />
|
||||
<span class="form-tip">允许用户在前台注册账号</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<el-switch v-model="otherForm.captchaEnabled" />
|
||||
<span class="form-tip">登录、注册等操作需要验证码</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveOtherSettings">保存设置</el-button>
|
||||
<el-button @click="resetOtherForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance } from "element-plus";
|
||||
|
||||
const otherFormRef = ref<FormInstance>();
|
||||
|
||||
const otherForm = reactive({
|
||||
maintenanceMode: false,
|
||||
allowRegister: false,
|
||||
captchaEnabled: true
|
||||
});
|
||||
|
||||
const saveOtherSettings = () => {
|
||||
// TODO: 保存其他设置
|
||||
ElMessage.success("其他设置保存成功");
|
||||
};
|
||||
|
||||
const resetOtherForm = () => {
|
||||
otherForm.maintenanceMode = false;
|
||||
otherForm.allowRegister = false;
|
||||
otherForm.captchaEnabled = true;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
otherForm
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<el-form
|
||||
ref="seoFormRef"
|
||||
:model="seoForm"
|
||||
:rules="seoRules"
|
||||
label-width="120px"
|
||||
style="max-width: 600px"
|
||||
>
|
||||
<el-form-item label="SEO标题" prop="seoTitle">
|
||||
<el-input
|
||||
v-model="seoForm.seoTitle"
|
||||
placeholder="请输入SEO标题"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="SEO关键词" prop="seoKeywords">
|
||||
<el-input
|
||||
v-model="seoForm.seoKeywords"
|
||||
placeholder="多个关键词用逗号分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="SEO描述" prop="seoDescription">
|
||||
<el-input
|
||||
v-model="seoForm.seoDescription"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入SEO描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSeoSettings">保存设置</el-button>
|
||||
<el-button @click="resetSeoForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
|
||||
const seoFormRef = ref<FormInstance>();
|
||||
|
||||
const seoForm = reactive({
|
||||
seoTitle: "",
|
||||
seoKeywords: "",
|
||||
seoDescription: ""
|
||||
});
|
||||
|
||||
const seoRules: FormRules = {
|
||||
seoTitle: [{ required: true, message: "请输入SEO标题", trigger: "blur" }]
|
||||
};
|
||||
|
||||
const saveSeoSettings = async () => {
|
||||
if (!seoFormRef.value) return;
|
||||
await seoFormRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
// TODO: 保存SEO设置
|
||||
ElMessage.success("SEO设置保存成功");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetSeoForm = () => {
|
||||
seoForm.seoTitle = "";
|
||||
seoForm.seoKeywords = "";
|
||||
seoForm.seoDescription = "";
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
seoForm
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>站点设置</h2>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<div class="settings-container">
|
||||
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<normalSettings ref="normalSettingsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="SEO设置" name="seo">
|
||||
<seoSettings ref="seoSettingsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="联系方式" name="contact">
|
||||
<contactSettings ref="contactSettingsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="法律声明&隐私条款" name="legalNotice">
|
||||
<legalNoticeSettings ref="legalNoticeSettingsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="其他设置" name="other">
|
||||
<otherSettings ref="otherSettingsRef" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import normalSettings from "./components/normalSettings.vue";
|
||||
import seoSettings from "./components/seoSettings.vue";
|
||||
import contactSettings from "./components/contactSettings.vue";
|
||||
import otherSettings from "./components/otherSettings.vue";
|
||||
import legalNoticeSettings from "./components/legalNotice.vue";
|
||||
|
||||
const activeTab = ref("basic");
|
||||
|
||||
const normalSettingsRef = ref();
|
||||
const seoSettingsRef = ref();
|
||||
const contactSettingsRef = ref();
|
||||
const otherSettingsRef = ref();
|
||||
const legalNoticeSettingsRef = ref();
|
||||
|
||||
// 初始化各标签页数据
|
||||
const initSettings = async () => {
|
||||
// TODO: 从后端获取各设置数据并赋值给对应组件
|
||||
if (normalSettingsRef.value) {
|
||||
// normalSettingsRef.value.normalinfos = ...
|
||||
}
|
||||
if (seoSettingsRef.value) {
|
||||
// seoSettingsRef.value.seoForm = ...
|
||||
}
|
||||
if (contactSettingsRef.value) {
|
||||
// contactSettingsRef.value.contactForm = ...
|
||||
}
|
||||
if (otherSettingsRef.value) {
|
||||
// otherSettingsRef.value.otherForm = ...
|
||||
}
|
||||
if (legalNoticeSettingsRef.value) {
|
||||
// legalNoticeSettingsRef.value.legalNoticeForm = ...
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="修改密码" width="400px" @close="handleClose">
|
||||
<el-form :model="form">
|
||||
<!-- 用户账号(只读) -->
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="form.username" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 新密码 -->
|
||||
<el-form-item label="新密码">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
placeholder="请输入新密码(6-16位)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<el-form-item label="确认密码">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-form-item v-if="passwordError">
|
||||
<el-alert :title="passwordError" type="error" :closable="false" style="color: #f56c6c;" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 对话框脚部 -->
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { changePassword } from "@/api/user";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
userId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close']);
|
||||
|
||||
const visible = ref(false);
|
||||
const passwordError = ref("");
|
||||
|
||||
const form = ref<any>({
|
||||
id: null,
|
||||
username: "",
|
||||
});
|
||||
|
||||
const passwordForm = ref<any>({
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
// 监听 modelValue
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal && props.userId) {
|
||||
form.value.id = props.userId;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 userId 变化
|
||||
watch(() => props.userId, (newVal) => {
|
||||
if (newVal) {
|
||||
form.value.id = newVal;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
// 校验密码
|
||||
const validatePassword = (password: string) => {
|
||||
if (!password) {
|
||||
return "请输入密码";
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return "密码长度不能小于6位";
|
||||
}
|
||||
if (password.length > 16) {
|
||||
return "密码长度不能大于16位";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 校验确认密码
|
||||
const validateConfirmPassword = (password: string, confirmPassword: string) => {
|
||||
if (!confirmPassword) {
|
||||
return "请再次输入密码";
|
||||
}
|
||||
if (confirmPassword !== password) {
|
||||
return "两次输入的密码不一致";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
passwordForm.value = {
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
passwordError.value = "";
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 清除之前的错误
|
||||
passwordError.value = "";
|
||||
|
||||
try {
|
||||
if (!form.value.id) {
|
||||
passwordError.value = "用户ID不能为空";
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验新密码格式
|
||||
const passwordCheck = validatePassword(passwordForm.value.newPassword);
|
||||
if (passwordCheck !== true) {
|
||||
passwordError.value = passwordCheck;
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验确认密码
|
||||
const confirmCheck = validateConfirmPassword(
|
||||
passwordForm.value.newPassword,
|
||||
passwordForm.value.confirmPassword
|
||||
);
|
||||
if (confirmCheck !== true) {
|
||||
passwordError.value = confirmCheck;
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用接口修改密码
|
||||
const res = await changePassword(form.value.id, passwordForm.value);
|
||||
|
||||
if (res.code === 200 || res.msg === '修改成功') {
|
||||
ElMessage.success("密码修改成功");
|
||||
visible.value = false;
|
||||
emit('update:modelValue', false);
|
||||
emit('submit');
|
||||
} else {
|
||||
passwordError.value = res.msg || "密码修改失败";
|
||||
}
|
||||
} catch (e: any) {
|
||||
const errorMsg = e?.response?.data?.msg || e?.response?.data?.message || e?.message || "操作失败";
|
||||
passwordError.value = errorMsg;
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open: (userId: number, username: string) => {
|
||||
form.value = {
|
||||
id: userId,
|
||||
username: username,
|
||||
};
|
||||
passwordForm.value = {
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
passwordError.value = "";
|
||||
visible.value = true;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="用户信息预览" size="50%">
|
||||
<div class="user-preview" v-if="user">
|
||||
<div class="user-header">
|
||||
<div class="user-avatar">
|
||||
<el-avatar :size="80" :icon="UserFilled" />
|
||||
</div>
|
||||
<h2 class="user-name">{{ user.name || "未知用户" }}</h2>
|
||||
<el-tag :type="user.status === 1 ? 'success' : 'danger'" size="large">
|
||||
{{ user.status === 1 ? "启用" : "禁用" }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="user-info">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">
|
||||
{{ user.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="账号">
|
||||
{{ user.account || "未设置" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">
|
||||
{{ user.name || "未设置" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">
|
||||
{{ user.sex === 1 ? "男" : "女" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">
|
||||
{{ user.phone || "未设置" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="QQ">
|
||||
{{ user.qq || "未设置" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">
|
||||
{{ user.email || "未设置" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
<el-tag :type="getRoleTagType(user.group_id)" size="small">
|
||||
{{ getRoleName(user.group_id) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录IP">
|
||||
{{ user.last_login_ip || "未登录" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录次数">
|
||||
{{ user.login_count || 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ user.create_time || "未知" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ user.update_time || "未知" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-user">
|
||||
<el-empty description="暂无用户信息" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { UserFilled } from "@element-plus/icons-vue";
|
||||
import { getAllRoles } from "@/api/role";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
account: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
qq: string;
|
||||
email: string;
|
||||
sex: number;
|
||||
group_id: number;
|
||||
status: number;
|
||||
last_login_ip: string;
|
||||
login_count: number;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
user: {
|
||||
type: Object as () => User | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const roles = ref<Role[]>([]);
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// 监听visible变化,同步给父组件
|
||||
watch(visible, (newVal) => {
|
||||
emit("update:modelValue", newVal);
|
||||
});
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await getAllRoles();
|
||||
roles.value = res.data || [];
|
||||
} catch (e) {
|
||||
roles.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色tag状态
|
||||
function getRoleTagType(group_id: number): string {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: "primary",
|
||||
2: "success",
|
||||
3: "warning",
|
||||
4: "danger",
|
||||
};
|
||||
return typeMap[group_id] || "primary";
|
||||
}
|
||||
|
||||
// 获取角色名称
|
||||
function getRoleName(group_id: number): string {
|
||||
const role = roles.value.find((r) => r.id === group_id);
|
||||
return role?.name || "未知";
|
||||
}
|
||||
|
||||
// 暴露open方法供父组件调用
|
||||
const open = (userData?: User) => {
|
||||
visible.value = true;
|
||||
fetchRoles();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
});
|
||||
|
||||
// 初始化时获取角色列表
|
||||
fetchRoles();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-preview {
|
||||
padding: 20px;
|
||||
|
||||
.user-header {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
|
||||
.user-avatar {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin: 15px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-user {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="dialogTitle" width="500px">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<div class="form-title">账号信息</div>
|
||||
<!-- 账号 -->
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="form.account" :disabled="!isAdd" placeholder="请输入账号" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-form-item label="密码" prop="password" v-if="isAdd">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
:placeholder="isAdd ? '请输入密码(至少6位)' : '留空则不修改密码'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<el-form-item label="确认密码" prop="confirmPassword" v-if="isAdd">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
:placeholder="isAdd ? '请再次输入密码' : '留空则不修改密码'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider></el-divider>
|
||||
<div class="form-title">个人信息</div>
|
||||
<!-- 姓名 -->
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="form.name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 电话 -->
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- QQ -->
|
||||
<el-form-item label="QQ">
|
||||
<el-input v-model="form.qq" placeholder="请输入QQ" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 性别 -->
|
||||
<el-form-item label="性别">
|
||||
<el-radio-group v-model="form.sex" placeholder="请选择性别">
|
||||
<el-radio-button label="男" :value="1" />
|
||||
<el-radio-button label="女" :value="2" />
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 状态 -->
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="form.status"
|
||||
placeholder="请选择状态"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 对话框脚部 -->
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { addUser, editUser, getUserInfo } from "@/api/user";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
statusDict: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "submit", "close"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const formRef = ref<any>(null);
|
||||
const isAdd = ref(false);
|
||||
|
||||
const form = ref<any>({
|
||||
id: null,
|
||||
account: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
qq: "",
|
||||
sex: 1,
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return isAdd.value ? "添加用户" : "编辑用户";
|
||||
});
|
||||
|
||||
// 密码验证规则
|
||||
const validatePassword = (rule: any, value: any, callback: any) => {
|
||||
if (isAdd.value) {
|
||||
// 新增用户时,密码必填
|
||||
if (!value) {
|
||||
callback(new Error("请输入密码"));
|
||||
return;
|
||||
}
|
||||
if (value.length < 6) {
|
||||
callback(new Error("密码长度不能少于6位"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 编辑用户时,如果填写了密码,则必须符合规则
|
||||
if (value && value.length < 6) {
|
||||
callback(new Error("密码长度不能少于6位"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
// 确认密码验证规则
|
||||
const validateConfirmPassword = (rule: any, value: any, callback: any) => {
|
||||
if (isAdd.value) {
|
||||
// 新增用户时,确认密码必填
|
||||
if (!value) {
|
||||
callback(new Error("请再次输入密码"));
|
||||
return;
|
||||
}
|
||||
if (value !== form.value.password) {
|
||||
callback(new Error("两次输入的密码不一致"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 编辑用户时,如果填写了密码,则确认密码必须一致
|
||||
if (form.value.password && value !== form.value.password) {
|
||||
callback(new Error("两次输入的密码不一致"));
|
||||
return;
|
||||
}
|
||||
// 如果填写了确认密码但没填密码,提示错误
|
||||
if (value && !form.value.password) {
|
||||
callback(new Error("请先输入密码"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
account: [
|
||||
{ required: true, message: "请输入账号", trigger: "blur" },
|
||||
{ min: 3, max: 20, message: "账号长度在 3 到 20 个字符", trigger: "blur" },
|
||||
],
|
||||
name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
password: [{ validator: validatePassword, trigger: "blur" }],
|
||||
confirmPassword: [{ validator: validateConfirmPassword, trigger: "blur" }],
|
||||
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }],
|
||||
};
|
||||
|
||||
// 监听 modelValue
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 statusDict 变化,用于调试
|
||||
watch(
|
||||
() => props.statusDict,
|
||||
(newVal) => {},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const loadUserData = async (user: any) => {
|
||||
try {
|
||||
// 处理两种调用方式:传递用户对象或用户 ID
|
||||
const userId = typeof user === "number" ? user : user?.id || user?.userId;
|
||||
if (!userId) {
|
||||
throw new Error("未提供有效的用户 ID");
|
||||
}
|
||||
|
||||
const res = await getUserInfo(userId);
|
||||
|
||||
const data = res.data || res;
|
||||
|
||||
// 确保 sex 和 status 都是数字类型
|
||||
const sexValue =
|
||||
data.sex !== undefined && data.sex !== null
|
||||
? Number(data.sex)
|
||||
: 1;
|
||||
|
||||
const statusValue =
|
||||
data.status !== undefined && data.status !== null
|
||||
? Number(data.status)
|
||||
: 1;
|
||||
|
||||
form.value = {
|
||||
id: data.id,
|
||||
account: data.account,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
qq: data.qq,
|
||||
sex: sexValue,
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: data.email,
|
||||
status: statusValue,
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error("Failed to load user data:", e);
|
||||
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
|
||||
ElMessage.error(errorMsg);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (error) {
|
||||
ElMessage.warning("请检查表单填写是否正确");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证密码一致性
|
||||
if (isAdd.value) {
|
||||
// 新增用户时,密码必填
|
||||
if (!form.value.password) {
|
||||
ElMessage.error("请输入密码");
|
||||
return;
|
||||
}
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
ElMessage.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 编辑用户时,如果填写了密码,则必须填写确认密码且一致
|
||||
if (form.value.password) {
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
ElMessage.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (isAdd.value) {
|
||||
// 新增用户
|
||||
const submitData: any = {
|
||||
account: form.value.account,
|
||||
name: form.value.name,
|
||||
phone: form.value.phone,
|
||||
qq: form.value.qq,
|
||||
sex: form.value.sex,
|
||||
email: form.value.email,
|
||||
status: form.value.status,
|
||||
password: form.value.password,
|
||||
};
|
||||
|
||||
await addUser(submitData);
|
||||
ElMessage.success("添加成功");
|
||||
} else {
|
||||
// 编辑用户
|
||||
if (!form.value.id || form.value.id === 0) {
|
||||
ElMessage.error("用户ID不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData: any = {
|
||||
id: form.value.id,
|
||||
account: form.value.account,
|
||||
name: form.value.name,
|
||||
phone: form.value.phone,
|
||||
qq: form.value.qq,
|
||||
sex: form.value.sex,
|
||||
email: form.value.email,
|
||||
status: form.value.status,
|
||||
};
|
||||
|
||||
// 只有在填写了密码时才添加到提交数据中
|
||||
if (form.value.password) {
|
||||
submitData.password = form.value.password;
|
||||
}
|
||||
|
||||
await editUser(form.value.id, submitData);
|
||||
ElMessage.success("更新成功");
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
emit("submit");
|
||||
} catch (e: any) {
|
||||
const errorMsg = e?.response?.data?.message || e?.message || "操作失败";
|
||||
ElMessage.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
loadUserData,
|
||||
openAdd: () => {
|
||||
isAdd.value = true;
|
||||
form.value = {
|
||||
id: 0,
|
||||
account: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
qq: "",
|
||||
sex: 1,
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
status: 1,
|
||||
};
|
||||
visible.value = true;
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
},
|
||||
openEdit: (user: any) => {
|
||||
isAdd.value = false;
|
||||
visible.value = true;
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
// 异步加载用户详细信息
|
||||
loadUserData(user);
|
||||
},
|
||||
open: (user?: any) => {
|
||||
if (user) {
|
||||
isAdd.value = false;
|
||||
loadUserData(user);
|
||||
} else {
|
||||
isAdd.value = true;
|
||||
form.value = {
|
||||
id: 0,
|
||||
account: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
qq: "",
|
||||
sex: 1,
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
visible.value = true;
|
||||
// 清除表单验证
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>用户管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddUser">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加用户
|
||||
</el-button>
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 用户列表表格 -->
|
||||
<el-table :data="users" style="width: 100%" v-loading="loading">
|
||||
<el-table-column
|
||||
prop="id"
|
||||
label="ID"
|
||||
align="center"
|
||||
fixed="left"
|
||||
/>
|
||||
<el-table-column prop="account" label="账号" align="center" />
|
||||
<el-table-column
|
||||
prop="name"
|
||||
label="姓名"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<span class="name-link" @click="handlePreview(scope.row)">{{ scope.row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="group_id" label="角色" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="getRoleTagType(scope.row.group_id)"
|
||||
size="small"
|
||||
>
|
||||
{{ getRoleTagText(roles, scope.row.group_id) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="phone"
|
||||
label="手机号"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="qq" label="QQ" align="center" />
|
||||
<el-table-column
|
||||
prop="last_login_ip"
|
||||
label="最后登录IP"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="login_count"
|
||||
label="登陆次数"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{
|
||||
scope.row.status === 1 ? "启用" : "禁用"
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleEdit(scope.row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="handleChangePassword(scope.row)"
|
||||
>
|
||||
修改密码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.username !== 'admin' && scope.row.id !== 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
layout="total, prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户对话框组件 -->
|
||||
<UserEditDialog
|
||||
ref="userEditRef"
|
||||
:modelValue="editDialogVisible"
|
||||
@update:modelValue="editDialogVisible = $event"
|
||||
:is-edit="isEdit"
|
||||
@submit="handleEditSuccess"
|
||||
@close="editDialogVisible = false"
|
||||
/>
|
||||
|
||||
<!-- 修改密码对话框组件 -->
|
||||
<ChangePasswordDialog
|
||||
ref="changePasswordRef"
|
||||
:modelValue="passwordDialogVisible"
|
||||
@update:modelValue="passwordDialogVisible = $event"
|
||||
:user-id="currentUserId"
|
||||
@submit="handlePasswordChangeSuccess"
|
||||
@close="passwordDialogVisible = false"
|
||||
/>
|
||||
|
||||
<!-- 预览用户对话框组件 -->
|
||||
<PreviewDialog
|
||||
ref="previewDialogRef"
|
||||
:modelValue="previewDialogVisible"
|
||||
@update:modelValue="previewDialogVisible = $event"
|
||||
:user="currentUser"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||
import { getAllUsers, deleteUser } from "@/api/user";
|
||||
import { getAllRoles } from "@/api/role";
|
||||
import UserEditDialog from "./components/userEdit.vue";
|
||||
import ChangePasswordDialog from "./components/changePassword.vue";
|
||||
import PreviewDialog from "./components/preview.vue";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
account: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
qq: string;
|
||||
sex: number;
|
||||
group_id: number;
|
||||
status: number;
|
||||
last_login_ip: string;
|
||||
login_count: number;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
status?: number;
|
||||
rights?: string;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const roles = ref<Role[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 组件引用
|
||||
const userEditRef = ref();
|
||||
const changePasswordRef = ref();
|
||||
const previewDialogRef = ref();
|
||||
|
||||
// 编辑/密码对话框状态
|
||||
const editDialogVisible = ref(false);
|
||||
const passwordDialogVisible = ref(false);
|
||||
const previewDialogVisible = ref(false);
|
||||
const editDialogTitle = ref("添加用户");
|
||||
const isEdit = ref(false);
|
||||
const currentUserId = ref<number | undefined>(undefined);
|
||||
const currentUser = ref<User | undefined>(undefined);
|
||||
|
||||
//刷新
|
||||
const refresh = async () => {
|
||||
await fetchUsers();
|
||||
};
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await getAllRoles();
|
||||
roles.value = res.data || [];
|
||||
} catch (e) {
|
||||
roles.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色tag状态
|
||||
function getRoleTagType(group_id: number): string {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: "primary",
|
||||
2: "success",
|
||||
3: "warning",
|
||||
4: "danger",
|
||||
};
|
||||
return typeMap[group_id] || "primary";
|
||||
}
|
||||
|
||||
// 获取角色tag文本
|
||||
function getRoleTagText(roles: Role[] | undefined, group_id: number): string {
|
||||
if (!roles || !Array.isArray(roles)) {
|
||||
return "未知";
|
||||
}
|
||||
return roles.find((role) => role.id === group_id)?.name || "未知";
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAllUsers();
|
||||
users.value = res.data.list;
|
||||
} catch (e) {
|
||||
users.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 添加用户
|
||||
const handleAddUser = () => {
|
||||
isEdit.value = false;
|
||||
editDialogVisible.value = true;
|
||||
if (userEditRef.value) {
|
||||
userEditRef.value.open();
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑用户
|
||||
const handleEdit = (user: User) => {
|
||||
isEdit.value = true;
|
||||
editDialogVisible.value = true;
|
||||
if (userEditRef.value) {
|
||||
userEditRef.value.open(user);
|
||||
}
|
||||
};
|
||||
|
||||
// 预览用户
|
||||
const handlePreview = (user: User) => {
|
||||
currentUser.value = user;
|
||||
previewDialogVisible.value = true;
|
||||
if (previewDialogRef.value) {
|
||||
previewDialogRef.value.open(user);
|
||||
}
|
||||
};
|
||||
|
||||
//修改密码
|
||||
const handleChangePassword = async (user: User) => {
|
||||
changePasswordRef.value.open(user.id, user.account);
|
||||
passwordDialogVisible.value = true;
|
||||
currentUserId.value = user.id;
|
||||
};
|
||||
|
||||
// 编辑成功回调
|
||||
const handleEditSuccess = () => {
|
||||
editDialogVisible.value = false;
|
||||
ElMessage.success(isEdit.value ? "编辑成功" : "添加成功");
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
// 密码修改成功回调
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
passwordDialogVisible.value = false;
|
||||
ElMessage.success("密码修改成功");
|
||||
};
|
||||
|
||||
// 分页改变
|
||||
const handlePageChange = (val: number) => {
|
||||
page.value = val;
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const handleDelete = async (user: User) => {
|
||||
ElMessageBox.confirm("确认删除该用户?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchUsers();
|
||||
} catch (e) {
|
||||
ElMessage.error("删除失败");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
fetchUsers();
|
||||
fetchRoles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-alert__title) {
|
||||
color: #f56c6c !important;
|
||||
}
|
||||
|
||||
.name-link {
|
||||
color: #409eff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #66b1ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>用户信息</h2>
|
||||
<div v-if="userInfo">
|
||||
<p>用户名: {{ userInfo.username }}</p>
|
||||
<p>邮箱: {{ userInfo.email }}</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
<button @click="fetchUserInfo">获取用户信息</button>
|
||||
<button @click="handleUpdateUserInfo">更新用户信息</button>
|
||||
<button @click="handleDeleteUser">删除用户</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { getUserInfo, updateUserInfo as updateUserInfoApi, deleteUser as deleteUserApi } from "@/api/user";
|
||||
|
||||
const userId = "123"; // 假设用户ID为123
|
||||
const userInfo = ref(null);
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const info = await getUserInfo(userId);
|
||||
userInfo.value = info;
|
||||
} catch (error) {
|
||||
console.error("获取用户信息失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateUserInfo = async () => {
|
||||
try {
|
||||
const updatedInfo = {
|
||||
username: "newUsername",
|
||||
email: "newEmail@example.com",
|
||||
};
|
||||
await updateUserInfoApi(userId, updatedInfo);
|
||||
fetchUserInfo(); // 重新获取更新后的用户信息
|
||||
} catch (error) {
|
||||
console.error("更新用户信息失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
try {
|
||||
await deleteUserApi(userId);
|
||||
userInfo.value = null; // 清空用户信息
|
||||
} catch (error) {
|
||||
console.error("删除用户失败", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 样式代码 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user