first commit

This commit is contained in:
2026-01-26 09:32:17 +08:00
commit 9429a700d0
2928 changed files with 30330 additions and 0 deletions
+84
View File
@@ -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>
+734
View File
@@ -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>
+469
View File
@@ -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>
+668
View File
@@ -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>
+8
View File
@@ -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>
+293
View File
@@ -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>
+278
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
// Apps 父路由组件,用于显示子路由
</script>
<template>
<router-view />
</template>
<style scoped>
/* Apps 父路由容器 */
</style>
+704
View File
@@ -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>
+789
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
this is home
</template>
<style scoped>
</style>
+8
View File
@@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style scoped></style>
+584
View File
@@ -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>
+170
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
// Settings 父路由组件,用于显示子路由
</script>
<template>
<router-view />
</template>
<style scoped>
/* Settings 父路由容器 */
</style>
+420
View File
@@ -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>
+332
View File
@@ -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支持 jpgpnggif 格式大小不超过 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>
+301
View File
@@ -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>
+368
View File
@@ -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支持 jpgpnggif 格式大小不超过 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>
+563
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<router-view />
</template>
<style scoped>
</style>
+429
View File
@@ -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>
+508
View File
@@ -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>
+401
View File
@@ -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>
+589
View File
@@ -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 {
// roleIddefault
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>
+244
View File
@@ -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>
+217
View File
@@ -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>
+226
View File
@@ -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>
+109
View File
@@ -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>
+342
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>
+58
View File
@@ -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"; // ID123
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>