更新仪表盘
This commit is contained in:
@@ -15,16 +15,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@ckeditor/ckeditor5-vue": "^7.3.0",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"ckeditor5": "^47.4.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-i18n": "^11.2.8",
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"vuedraggable": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
|
|||||||
@@ -1,958 +1,47 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// 模式切换:work 或 widget
|
|
||||||
const currentMode = ref('work')
|
|
||||||
const showCustomizeModal = ref(false)
|
|
||||||
|
|
||||||
// 工作台模式的快捷链接配置
|
|
||||||
const workLinks = ref([
|
|
||||||
{ id: 1, name: '用户管理', icon: '👥', route: '/system/user', enabled: true, color: '#1890ff' },
|
|
||||||
{ id: 2, name: '角色管理', icon: '👑', route: '/system/role', enabled: true, color: '#52c41a' },
|
|
||||||
{ id: 3, name: '菜单管理', icon: '📋', route: '/system/menu', enabled: true, color: '#fa8c16' },
|
|
||||||
{ id: 4, name: '系统设置', icon: '⚙️', route: '/system/setting', enabled: true, color: '#722ed1' },
|
|
||||||
{ id: 5, name: '文件上传', icon: '📤', route: '/upload', enabled: true, color: '#13c2c2' },
|
|
||||||
{ id: 6, name: '个人中心', icon: '👤', route: '#', enabled: false, color: '#eb2f96' },
|
|
||||||
])
|
|
||||||
|
|
||||||
// 组件模式的组件配置
|
|
||||||
const widgets = ref([
|
|
||||||
{ id: 1, name: '用户统计', icon: '📊', enabled: true, color: '#1890ff' },
|
|
||||||
{ id: 2, name: '数据图表', icon: '📈', enabled: true, color: '#52c41a' },
|
|
||||||
{ id: 3, name: '最近活动', icon: '🔔', enabled: true, color: '#fa8c16' },
|
|
||||||
{ id: 4, name: '快速操作', icon: '⚡', enabled: true, color: '#722ed1' },
|
|
||||||
{ id: 5, name: '系统状态', icon: '💻', enabled: false, color: '#13c2c2' },
|
|
||||||
{ id: 6, name: '待办事项', icon: '✅', enabled: false, color: '#eb2f96' },
|
|
||||||
])
|
|
||||||
|
|
||||||
// 临时配置(用于自定义弹窗)
|
|
||||||
const tempWorkLinks = ref([])
|
|
||||||
const tempWidgets = ref([])
|
|
||||||
|
|
||||||
// 计算属性:过滤出已启用的链接
|
|
||||||
const enabledWorkLinks = computed(() => workLinks.value.filter(link => link.enabled))
|
|
||||||
const enabledWidgets = computed(() => widgets.value.filter(widget => widget.enabled))
|
|
||||||
|
|
||||||
// 模式切换
|
|
||||||
const switchMode = (mode) => {
|
|
||||||
currentMode.value = mode
|
|
||||||
saveDashboardConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开自定义弹窗
|
|
||||||
const openCustomizeModal = () => {
|
|
||||||
showCustomizeModal.value = true
|
|
||||||
tempWorkLinks.value = JSON.parse(JSON.stringify(workLinks.value))
|
|
||||||
tempWidgets.value = JSON.parse(JSON.stringify(widgets.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭自定义弹窗
|
|
||||||
const closeCustomizeModal = () => {
|
|
||||||
showCustomizeModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换work链接状态
|
|
||||||
const toggleWorkLink = (id) => {
|
|
||||||
const link = tempWorkLinks.value.find(l => l.id === id)
|
|
||||||
if (link) {
|
|
||||||
link.enabled = !link.enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换widget状态
|
|
||||||
const toggleWidget = (id) => {
|
|
||||||
const widget = tempWidgets.value.find(w => w.id === id)
|
|
||||||
if (widget) {
|
|
||||||
widget.enabled = !widget.enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存自定义配置
|
|
||||||
const saveCustomConfig = () => {
|
|
||||||
workLinks.value = JSON.parse(JSON.stringify(tempWorkLinks.value))
|
|
||||||
widgets.value = JSON.parse(JSON.stringify(widgets.value))
|
|
||||||
saveDashboardConfig()
|
|
||||||
showCustomizeModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导航到指定路由
|
|
||||||
const navigateTo = (route) => {
|
|
||||||
if (route && route !== '#') {
|
|
||||||
router.push(route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存配置到localStorage
|
|
||||||
const saveDashboardConfig = () => {
|
|
||||||
const config = {
|
|
||||||
mode: currentMode.value,
|
|
||||||
workLinks: workLinks.value,
|
|
||||||
widgets: widgets.value
|
|
||||||
}
|
|
||||||
localStorage.setItem('dashboardConfig', JSON.stringify(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从localStorage加载配置
|
|
||||||
const loadDashboardConfig = () => {
|
|
||||||
const saved = localStorage.getItem('dashboardConfig')
|
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(saved)
|
|
||||||
currentMode.value = config.mode || 'work'
|
|
||||||
workLinks.value = config.workLinks || workLinks.value
|
|
||||||
widgets.value = config.widgets || widgets.value
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载配置失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时加载配置
|
|
||||||
onMounted(() => {
|
|
||||||
loadDashboardConfig()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="home-page">
|
||||||
<!-- 头部区域 -->
|
<a-skeleton v-if="loading" active />
|
||||||
<div class="dashboard-header">
|
<component v-else :is="dashboardComponent" @on-mounted="handleMounted" />
|
||||||
<h1 class="page-title">仪表盘</h1>
|
|
||||||
|
|
||||||
<!-- 模式切换器 -->
|
|
||||||
<div class="mode-switcher">
|
|
||||||
<button
|
|
||||||
:class="['mode-btn', { active: currentMode === 'work' }]"
|
|
||||||
@click="switchMode('work')"
|
|
||||||
>
|
|
||||||
<span class="mode-icon">🏠</span>
|
|
||||||
<span>工作台</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['mode-btn', { active: currentMode === 'widget' }]"
|
|
||||||
@click="switchMode('widget')"
|
|
||||||
>
|
|
||||||
<span class="mode-icon">🧩</span>
|
|
||||||
<span>组件模式</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自定义按钮 -->
|
|
||||||
<button class="customize-btn" @click="openCustomizeModal">
|
|
||||||
<span>⚙️</span>
|
|
||||||
<span>自定义</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 工作台模式 -->
|
|
||||||
<div v-if="currentMode === 'work'" class="work-mode">
|
|
||||||
<div class="welcome-banner">
|
|
||||||
<div class="welcome-content">
|
|
||||||
<h2>欢迎回来!</h2>
|
|
||||||
<p>今天也是高效工作的一天 🚀</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="work-links-grid">
|
|
||||||
<div
|
|
||||||
v-for="link in enabledWorkLinks"
|
|
||||||
:key="link.id"
|
|
||||||
class="work-link-card"
|
|
||||||
:style="{ '--accent-color': link.color }"
|
|
||||||
@click="navigateTo(link.route)"
|
|
||||||
>
|
|
||||||
<div class="link-icon" :style="{ backgroundColor: link.color + '15', color: link.color }">
|
|
||||||
{{ link.icon }}
|
|
||||||
</div>
|
|
||||||
<div class="link-name">{{ link.name }}</div>
|
|
||||||
<div class="link-arrow">→</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="enabledWorkLinks.length === 0" class="empty-state">
|
|
||||||
<div class="empty-icon">📭</div>
|
|
||||||
<div class="empty-text">暂无快捷链接</div>
|
|
||||||
<div class="empty-desc">点击自定义按钮添加常用功能</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 组件模式 -->
|
|
||||||
<div v-if="currentMode === 'widget'" class="widget-mode">
|
|
||||||
<div class="widgets-grid">
|
|
||||||
<!-- 用户统计组件 -->
|
|
||||||
<div v-if="enabledWidgets.find(w => w.id === 1)" class="widget-card">
|
|
||||||
<div class="widget-header">
|
|
||||||
<div class="widget-title">
|
|
||||||
<span>📊</span>
|
|
||||||
用户统计
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="widget-body">
|
|
||||||
<div class="stat-row">
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">总用户数</div>
|
|
||||||
<div class="stat-value">1,234</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">新增用户</div>
|
|
||||||
<div class="stat-value">+156</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 数据图表组件 -->
|
|
||||||
<div v-if="enabledWidgets.find(w => w.id === 2)" class="widget-card large">
|
|
||||||
<div class="widget-header">
|
|
||||||
<div class="widget-title">
|
|
||||||
<span>📈</span>
|
|
||||||
数据图表
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="widget-body">
|
|
||||||
<div class="chart-placeholder">
|
|
||||||
<div class="chart-bars">
|
|
||||||
<div class="chart-bar" style="height: 60%"></div>
|
|
||||||
<div class="chart-bar" style="height: 80%"></div>
|
|
||||||
<div class="chart-bar" style="height: 45%"></div>
|
|
||||||
<div class="chart-bar" style="height: 90%"></div>
|
|
||||||
<div class="chart-bar" style="height: 70%"></div>
|
|
||||||
<div class="chart-bar" style="height: 85%"></div>
|
|
||||||
<div class="chart-bar" style="height: 55%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-labels">
|
|
||||||
<span>周一</span>
|
|
||||||
<span>周二</span>
|
|
||||||
<span>周三</span>
|
|
||||||
<span>周四</span>
|
|
||||||
<span>周五</span>
|
|
||||||
<span>周六</span>
|
|
||||||
<span>周日</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 最近活动组件 -->
|
|
||||||
<div v-if="enabledWidgets.find(w => w.id === 3)" class="widget-card">
|
|
||||||
<div class="widget-header">
|
|
||||||
<div class="widget-title">
|
|
||||||
<span>🔔</span>
|
|
||||||
最近活动
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="widget-body">
|
|
||||||
<div class="activity-list">
|
|
||||||
<div class="activity-item">
|
|
||||||
<div class="activity-dot success"></div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<div class="activity-text">新用户注册</div>
|
|
||||||
<div class="activity-time">5分钟前</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="activity-item">
|
|
||||||
<div class="activity-dot warning"></div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<div class="activity-text">系统更新完成</div>
|
|
||||||
<div class="activity-time">30分钟前</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="activity-item">
|
|
||||||
<div class="activity-dot info"></div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<div class="activity-text">数据备份成功</div>
|
|
||||||
<div class="activity-time">1小时前</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 快速操作组件 -->
|
|
||||||
<div v-if="enabledWidgets.find(w => w.id === 4)" class="widget-card">
|
|
||||||
<div class="widget-header">
|
|
||||||
<div class="widget-title">
|
|
||||||
<span>⚡</span>
|
|
||||||
快速操作
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="widget-body">
|
|
||||||
<div class="quick-actions">
|
|
||||||
<button class="quick-action-btn">➕ 添加用户</button>
|
|
||||||
<button class="quick-action-btn">📤 上传文件</button>
|
|
||||||
<button class="quick-action-btn">📝 新建报告</button>
|
|
||||||
<button class="quick-action-btn">🔄 刷新数据</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<div v-if="enabledWidgets.length === 0" class="empty-state">
|
|
||||||
<div class="empty-icon">🧩</div>
|
|
||||||
<div class="empty-text">暂无组件</div>
|
|
||||||
<div class="empty-desc">点击自定义按钮添加组件</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自定义弹窗 -->
|
|
||||||
<div v-if="showCustomizeModal" class="modal-overlay" @click.self="closeCustomizeModal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>自定义仪表盘</h2>
|
|
||||||
<button class="modal-close" @click="closeCustomizeModal">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<!-- 工作台链接配置 -->
|
|
||||||
<div class="config-section">
|
|
||||||
<h3>工作台快捷链接</h3>
|
|
||||||
<div class="config-items">
|
|
||||||
<div
|
|
||||||
v-for="link in tempWorkLinks"
|
|
||||||
:key="link.id"
|
|
||||||
class="config-item"
|
|
||||||
>
|
|
||||||
<div class="item-info">
|
|
||||||
<span class="item-icon" :style="{ backgroundColor: link.color + '20', color: link.color }">
|
|
||||||
{{ link.icon }}
|
|
||||||
</span>
|
|
||||||
<span class="item-name">{{ link.name }}</span>
|
|
||||||
</div>
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" v-model="link.enabled">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 组件配置 -->
|
|
||||||
<div class="config-section">
|
|
||||||
<h3>组件显示</h3>
|
|
||||||
<div class="config-items">
|
|
||||||
<div
|
|
||||||
v-for="widget in tempWidgets"
|
|
||||||
:key="widget.id"
|
|
||||||
class="config-item"
|
|
||||||
>
|
|
||||||
<div class="item-info">
|
|
||||||
<span class="item-icon" :style="{ backgroundColor: widget.color + '20', color: widget.color }">
|
|
||||||
{{ widget.icon }}
|
|
||||||
</span>
|
|
||||||
<span class="item-name">{{ widget.name }}</span>
|
|
||||||
</div>
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" v-model="widget.enabled">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" @click="closeCustomizeModal">取消</button>
|
|
||||||
<button class="btn btn-primary" @click="saveCustomConfig">保存</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup>
|
||||||
.dashboard {
|
import { ref, onMounted, computed } from 'vue'
|
||||||
animation: fadeIn 0.3s ease;
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'HomePage',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const dashboard = ref(import.meta.env.VITE_APP_DASHBOARD || 'work')
|
||||||
|
|
||||||
|
// 动态导入组件
|
||||||
|
const components = {
|
||||||
|
work: defineAsyncComponent(() => import('./work/index.vue')),
|
||||||
|
widgets: defineAsyncComponent(() => import('./widgets/index.vue')),
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
const dashboardComponent = computed(() => {
|
||||||
from {
|
return components[dashboard.value] || components.work
|
||||||
opacity: 0;
|
})
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
const handleMounted = () => {
|
||||||
to {
|
loading.value = false
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 头部样式 */
|
onMounted(() => {
|
||||||
.dashboard-header {
|
// 模拟加载延迟
|
||||||
display: flex;
|
setTimeout(() => {
|
||||||
align-items: center;
|
loading.value = false
|
||||||
justify-content: space-between;
|
}, 300)
|
||||||
margin-bottom: 24px;
|
})
|
||||||
flex-wrap: wrap;
|
</script>
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
<style scoped lang="scss">
|
||||||
font-size: 28px;
|
.home-page {
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-switcher {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-btn:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-btn.active {
|
|
||||||
background: white;
|
|
||||||
color: #1890ff;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customize-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customize-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工作台模式样式 */
|
|
||||||
.welcome-banner {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 32px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-content h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-content p {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-links-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-link-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
border-left: 4px solid var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-link-card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-icon {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-arrow {
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--accent-color);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-link-card:hover .link-arrow {
|
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 组件模式样式 */
|
|
||||||
.widgets-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-grid .large {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 统计组件 */
|
|
||||||
.stat-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 图表组件 */
|
|
||||||
.chart-placeholder {
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-bars {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
height: 120px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-bar {
|
|
||||||
flex: 1;
|
|
||||||
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
transition: height 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-labels {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 活动列表 */
|
|
||||||
.activity-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-dot.success {
|
|
||||||
background: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-dot.warning {
|
|
||||||
background: #fa8c16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-dot.info {
|
|
||||||
background: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 快速操作 */
|
|
||||||
.quick-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-action-btn {
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f8f9ff;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-action-btn:hover {
|
|
||||||
border-color: #667eea;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 空状态 */
|
|
||||||
.empty-state {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-desc {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 弹窗样式 */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 20px;
|
|
||||||
animation: fadeIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
height: 100%;
|
||||||
max-height: 90vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: slideUp 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h2 {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #666;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close:hover {
|
|
||||||
background: #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-section {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-section:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-section h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-item:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 切换开关 */
|
|
||||||
.toggle-switch {
|
|
||||||
position: relative;
|
|
||||||
width: 48px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: #d9d9d9;
|
|
||||||
transition: 0.3s;
|
|
||||||
border-radius: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider:before {
|
|
||||||
position: absolute;
|
|
||||||
content: '';
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
left: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
background-color: white;
|
|
||||||
transition: 0.3s;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider:before {
|
|
||||||
transform: translateX(24px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.widgets-grid .large {
|
|
||||||
grid-column: span 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.dashboard-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-switcher {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customize-btn {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-links-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
27
src/pages/home/widgets/components/about.vue
Normal file
27
src/pages/home/widgets/components/about.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<a-card :bordered="true" title="关于项目" class="about-card">
|
||||||
|
<p>高性能 / 精致 / 优雅。基于Vue3 + Ant Design Vue 的中后台前端解决方案,如果喜欢就点个星星支持一下。</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://gitee.com/lolicode/scui" target="_blank">
|
||||||
|
<img src="https://gitee.com/lolicode/scui/badge/star.svg?theme=dark" alt="star" style="vertical-align: middle;">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'AboutWidget',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.about-card {
|
||||||
|
p {
|
||||||
|
color: #999;
|
||||||
|
margin-top: 10px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
132
src/pages/home/widgets/components/echarts.vue
Normal file
132
src/pages/home/widgets/components/echarts.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<a-card :bordered="true" title="实时收入">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<div ref="chartRef" style="width: 100%; height: 300px;"></div>
|
||||||
|
</a-spin>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'EchartsWidget',
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartRef = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
let chart = null
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
// 初始化图表
|
||||||
|
const initChart = () => {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
chart = echarts.init(chartRef.value)
|
||||||
|
|
||||||
|
// 生成初始数据
|
||||||
|
const now = new Date()
|
||||||
|
const xData = []
|
||||||
|
const yData = []
|
||||||
|
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const time = new Date(now.getTime() - i * 2000)
|
||||||
|
xData.unshift(time.toLocaleTimeString().replace(/^\D*/, ''))
|
||||||
|
yData.push(Math.round(Math.random() * 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
boundaryGap: false,
|
||||||
|
type: 'category',
|
||||||
|
data: xData,
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '价格',
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '收入',
|
||||||
|
type: 'line',
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: {
|
||||||
|
width: 1,
|
||||||
|
color: '#409EFF',
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: 0.1,
|
||||||
|
color: '#79bbff',
|
||||||
|
},
|
||||||
|
data: yData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.setOption(option)
|
||||||
|
|
||||||
|
// 模拟实时更新
|
||||||
|
timer = setInterval(() => {
|
||||||
|
const newTime = new Date().toLocaleTimeString().replace(/^\D*/, '')
|
||||||
|
const newValue = Math.round(Math.random() * 100)
|
||||||
|
|
||||||
|
xData.shift()
|
||||||
|
xData.push(newTime)
|
||||||
|
|
||||||
|
yData.shift()
|
||||||
|
yData.push(newValue)
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
xAxis: {
|
||||||
|
data: xData,
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
data: yData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}, 2100)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 模拟加载延迟
|
||||||
|
setTimeout(() => {
|
||||||
|
loading.value = false
|
||||||
|
initChart()
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (chart) {
|
||||||
|
chart.resize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
if (chart) {
|
||||||
|
chart.dispose()
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// 样式根据需要添加
|
||||||
|
</style>
|
||||||
8
src/pages/home/widgets/components/index.js
Normal file
8
src/pages/home/widgets/components/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { markRaw } from 'vue'
|
||||||
|
const resultComps = {}
|
||||||
|
const files = import.meta.glob('./*.vue', { eager: true })
|
||||||
|
Object.keys(files).forEach((fileName) => {
|
||||||
|
let comp = files[fileName]
|
||||||
|
resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, '$1')] = comp.default
|
||||||
|
})
|
||||||
|
export default markRaw(resultComps)
|
||||||
50
src/pages/home/widgets/components/info.vue
Normal file
50
src/pages/home/widgets/components/info.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<a-card :bordered="true" title="系统信息" class="info-card">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-descriptions bordered :column="1">
|
||||||
|
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
||||||
|
{{ item.values }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-spin>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'InfoWidget',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const sysInfo = ref([])
|
||||||
|
|
||||||
|
const getSystemList = async () => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.info.get()
|
||||||
|
if (res.code === 1) {
|
||||||
|
sysInfo.value = res.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取系统信息失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getSystemList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.info-card {
|
||||||
|
:deep(.ant-descriptions-item-label) {
|
||||||
|
width: 120px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
src/pages/home/widgets/components/progress.vue
Normal file
36
src/pages/home/widgets/components/progress.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<a-card :bordered="true" title="进度环">
|
||||||
|
<div class="progress">
|
||||||
|
<a-progress type="dashboard" :percent="85.5" :width="160">
|
||||||
|
<template #format="percent">
|
||||||
|
<div class="percentage-value">{{ percent }}%</div>
|
||||||
|
<div class="percentage-label">当前进度</div>
|
||||||
|
</template>
|
||||||
|
</a-progress>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'ProgressWidget',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.progress {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.percentage-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percentage-label {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
src/pages/home/widgets/components/sms.vue
Normal file
93
src/pages/home/widgets/components/sms.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<a-row :gutter="10">
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-card :bordered="true" title="今日数量">
|
||||||
|
<a-statistic :value="count.today" :formatter="formatNumber" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-card :bordered="true" title="昨日数量">
|
||||||
|
<a-statistic :value="count.yesterday" :formatter="formatNumber" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-card :bordered="true" title="本周数量">
|
||||||
|
<a-statistic :value="count.week" :formatter="formatNumber" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-card :bordered="true" title="上周数量">
|
||||||
|
<a-statistic :value="count.last_week" :formatter="formatNumber" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-card :bordered="true" title="今年数量">
|
||||||
|
<a-statistic :value="count.year" :formatter="formatNumber" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="4">
|
||||||
|
<a-card :bordered="true" title="总数量">
|
||||||
|
<a-statistic :value="count.all" :formatter="formatNumber" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'SmsWidget',
|
||||||
|
})
|
||||||
|
|
||||||
|
const count = ref({})
|
||||||
|
|
||||||
|
const getSmsCount = async () => {
|
||||||
|
try {
|
||||||
|
// 注意:API中可能没有短信统计接口,这里使用模拟数据
|
||||||
|
// 如果有接口,请取消注释以下代码
|
||||||
|
// const res = await systemApi.sms.count.get()
|
||||||
|
// if (res.code === 1) {
|
||||||
|
// count.value = res.data
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
count.value = {
|
||||||
|
today: 1234,
|
||||||
|
yesterday: 5678,
|
||||||
|
week: 45678,
|
||||||
|
last_week: 43210,
|
||||||
|
year: 567890,
|
||||||
|
all: 1234567
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取短信统计失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (value) => {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getSmsCount()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.ant-card-head-title) {
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-statistic-content) {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
72
src/pages/home/widgets/components/time.vue
Normal file
72
src/pages/home/widgets/components/time.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<a-card :bordered="true" title="时钟" class="time-card">
|
||||||
|
<div class="time">
|
||||||
|
<h2>{{ time }}</h2>
|
||||||
|
<p>{{ day }}</p>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import tool from '@/utils/tool'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'TimeWidget',
|
||||||
|
})
|
||||||
|
|
||||||
|
const time = ref('')
|
||||||
|
const day = ref('')
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
const showTime = () => {
|
||||||
|
time.value = tool.dateFormat(new Date(), 'hh:mm:ss')
|
||||||
|
day.value = tool.dateFormat(new Date(), 'yyyy年MM月dd日')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
showTime()
|
||||||
|
timer = setInterval(() => {
|
||||||
|
showTime()
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.time-card {
|
||||||
|
background: linear-gradient(to right, #8e54e9, #4776e6);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
:deep(.ant-card-head-title) {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-head) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
h2 {
|
||||||
|
font-size: 40px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
src/pages/home/widgets/components/ver.vue
Normal file
56
src/pages/home/widgets/components/ver.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<a-card :bordered="true" title="版本信息" class="ver-card">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-descriptions bordered :column="1">
|
||||||
|
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
||||||
|
{{ item.values }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-spin>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'VerWidget',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const sysInfo = ref([])
|
||||||
|
|
||||||
|
const getSystemList = async () => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.version.get()
|
||||||
|
if (res.code === 1) {
|
||||||
|
sysInfo.value = res.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取版本信息失败:', error)
|
||||||
|
// 使用模拟数据作为fallback
|
||||||
|
sysInfo.value = [
|
||||||
|
{ label: '系统版本', values: '1.0.0' },
|
||||||
|
{ label: '框架版本', values: 'Vue 3.x' },
|
||||||
|
{ label: '构建时间', values: '2024-01-01' },
|
||||||
|
]
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getSystemList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ver-card {
|
||||||
|
:deep(.ant-descriptions-item-label) {
|
||||||
|
width: 120px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
src/pages/home/widgets/components/welcome.vue
Normal file
96
src/pages/home/widgets/components/welcome.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<a-card :bordered="true" title="欢迎">
|
||||||
|
<div class="welcome">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="/favicon.ico" alt="logo">
|
||||||
|
<h2>VueAdmin</h2>
|
||||||
|
</div>
|
||||||
|
<div class="tips">
|
||||||
|
<div class="tips-item">
|
||||||
|
<div class="tips-item-icon"><MenuOutlined /></div>
|
||||||
|
<div class="tips-item-message">这里是项目控制台,你可以点击右上方的"自定义"按钮来添加移除或者移动部件。</div>
|
||||||
|
</div>
|
||||||
|
<div class="tips-item">
|
||||||
|
<div class="tips-item-icon"><RocketOutlined /></div>
|
||||||
|
<div class="tips-item-message">在提高前端算力、减少带宽请求和代码执行力上多次优化,并且持续着。</div>
|
||||||
|
</div>
|
||||||
|
<div class="tips-item">
|
||||||
|
<div class="tips-item-icon"><CoffeeOutlined /></div>
|
||||||
|
<div class="tips-item-message">项目目的:让前端工作更快乐</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { MenuOutlined, RocketOutlined, CoffeeOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'WelcomeWidget',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.welcome {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome .logo {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: normal;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
padding: 0 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7.5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-item-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 20px;
|
||||||
|
color: #1890ff;
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-item-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
598
src/pages/home/widgets/index.vue
Normal file
598
src/pages/home/widgets/index.vue
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['widgets-home', customizing ? 'customizing' : '']" ref="main">
|
||||||
|
<div class="widgets-content">
|
||||||
|
<div class="widgets-top">
|
||||||
|
<div class="widgets-top-title">控制台</div>
|
||||||
|
<div class="widgets-top-actions">
|
||||||
|
<a-button v-if="customizing" type="primary" shape="round" @click="handleSave">
|
||||||
|
<template #icon><CheckOutlined /></template>
|
||||||
|
完成
|
||||||
|
</a-button>
|
||||||
|
<a-button v-else type="primary" shape="round" @click="handleCustom">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
自定义
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="widgets" ref="widgetsRef">
|
||||||
|
<div class="widgets-wrapper">
|
||||||
|
<div v-if="nowCompsList.length <= 0" class="no-widgets">
|
||||||
|
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||||
|
</div>
|
||||||
|
<a-row :gutter="15">
|
||||||
|
<a-col
|
||||||
|
v-for="(item, index) in grid.layout"
|
||||||
|
:key="index"
|
||||||
|
:md="item"
|
||||||
|
:xs="24"
|
||||||
|
>
|
||||||
|
<div class="draggable-wrapper">
|
||||||
|
<draggable
|
||||||
|
v-model="grid.compsList[index]"
|
||||||
|
item-key="key"
|
||||||
|
:animation="200"
|
||||||
|
handle=".customize-overlay"
|
||||||
|
group="widgets"
|
||||||
|
class="draggable-box"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="widgets-item">
|
||||||
|
<component :is="allComps[element]" />
|
||||||
|
<div v-if="customizing" class="customize-overlay">
|
||||||
|
<a-button
|
||||||
|
class="close"
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
shape="circle"
|
||||||
|
@click="removeComp(element)"
|
||||||
|
>
|
||||||
|
<template #icon><CloseOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<label>
|
||||||
|
<component :is="allComps[element].icon" />
|
||||||
|
{{ allComps[element].title }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义侧边栏 -->
|
||||||
|
<a-drawer
|
||||||
|
v-if="customizing"
|
||||||
|
:open="customizing"
|
||||||
|
:width="360"
|
||||||
|
placement="right"
|
||||||
|
:closable="false"
|
||||||
|
:mask="false"
|
||||||
|
class="widgets-drawer"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="widgets-aside-title">
|
||||||
|
<PlusCircleOutlined /> 添加部件
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="text" @click="handleClose">
|
||||||
|
<template #icon><CloseOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 布局选择 -->
|
||||||
|
<div class="select-layout">
|
||||||
|
<h3>选择布局</h3>
|
||||||
|
<div class="select-layout-options">
|
||||||
|
<div
|
||||||
|
class="select-layout-item item01"
|
||||||
|
:class="{ active: grid.layout.join(',') === '12,6,6' }"
|
||||||
|
@click="setLayout([12, 6, 6])"
|
||||||
|
>
|
||||||
|
<a-row :gutter="2">
|
||||||
|
<a-col :span="12"><span></span></a-col>
|
||||||
|
<a-col :span="6"><span></span></a-col>
|
||||||
|
<a-col :span="6"><span></span></a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="select-layout-item item02"
|
||||||
|
:class="{ active: grid.layout.join(',') === '24,16,8' }"
|
||||||
|
@click="setLayout([24, 16, 8])"
|
||||||
|
>
|
||||||
|
<a-row :gutter="2">
|
||||||
|
<a-col :span="24"><span></span></a-col>
|
||||||
|
<a-col :span="16"><span></span></a-col>
|
||||||
|
<a-col :span="8"><span></span></a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="select-layout-item item03"
|
||||||
|
:class="{ active: grid.layout.join(',') === '24' }"
|
||||||
|
@click="setLayout([24])"
|
||||||
|
>
|
||||||
|
<a-row :gutter="2">
|
||||||
|
<a-col :span="24"><span></span></a-col>
|
||||||
|
<a-col :span="24"><span></span></a-col>
|
||||||
|
<a-col :span="24"><span></span></a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 部件列表 -->
|
||||||
|
<div class="widgets-list">
|
||||||
|
<h3>可用部件</h3>
|
||||||
|
<div v-if="myCompsList.length <= 0" class="widgets-list-nodata">
|
||||||
|
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||||
|
</div>
|
||||||
|
<div v-for="item in myCompsList" :key="item.key" class="widgets-list-item">
|
||||||
|
<div class="item-logo">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<h2>{{ item.title }}</h2>
|
||||||
|
<p>{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<a-button type="primary" @click="addComp(item)">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="handleResetDefault">恢复默认</a-button>
|
||||||
|
</template>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||||
|
import { Empty } from 'ant-design-vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
PlusCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import allComps from './components'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'WidgetsPage',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 定义组件元数据
|
||||||
|
allComps.welcome.icon = 'GiftOutlined'
|
||||||
|
allComps.welcome.title = '欢迎'
|
||||||
|
allComps.welcome.description = '项目特色以及文档链接'
|
||||||
|
|
||||||
|
allComps.info.icon = 'MonitorOutlined'
|
||||||
|
allComps.info.title = '系统信息'
|
||||||
|
allComps.info.description = '当前项目系统信息'
|
||||||
|
|
||||||
|
allComps.about.icon = 'SettingOutlined'
|
||||||
|
allComps.about.title = '关于项目'
|
||||||
|
allComps.about.description = '点个星星支持一下'
|
||||||
|
|
||||||
|
allComps.echarts.icon = 'LineChartOutlined'
|
||||||
|
allComps.echarts.title = '实时收入'
|
||||||
|
allComps.echarts.description = 'Echarts组件演示'
|
||||||
|
|
||||||
|
allComps.progress.icon = 'DashboardOutlined'
|
||||||
|
allComps.progress.title = '进度环'
|
||||||
|
allComps.progress.description = '进度环原子组件演示'
|
||||||
|
|
||||||
|
allComps.time.icon = 'ClockCircleOutlined'
|
||||||
|
allComps.time.title = '时钟'
|
||||||
|
allComps.time.description = '演示部件效果'
|
||||||
|
|
||||||
|
allComps.sms.icon = 'MessageOutlined'
|
||||||
|
allComps.sms.title = '短信统计'
|
||||||
|
allComps.sms.description = '短信统计'
|
||||||
|
|
||||||
|
allComps.ver.icon = 'FileTextOutlined'
|
||||||
|
allComps.ver.title = '版本信息'
|
||||||
|
allComps.ver.description = '当前项目版本信息'
|
||||||
|
|
||||||
|
// 导入图标组件
|
||||||
|
import {
|
||||||
|
GiftOutlined,
|
||||||
|
MonitorOutlined,
|
||||||
|
SettingOutlined as SettingIcon,
|
||||||
|
LineChartOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
// 图标映射
|
||||||
|
const iconMap = {
|
||||||
|
GiftOutlined,
|
||||||
|
MonitorOutlined,
|
||||||
|
SettingOutlined: SettingIcon,
|
||||||
|
LineChartOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换组件中的icon引用
|
||||||
|
Object.keys(allComps).forEach((key) => {
|
||||||
|
if (allComps[key].icon && typeof allComps[key].icon === 'string') {
|
||||||
|
allComps[key].icon = iconMap[allComps[key].icon] || GiftOutlined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const customizing = ref(false)
|
||||||
|
const widgetsRef = ref(null)
|
||||||
|
const defaultGrid = {
|
||||||
|
layout: [12, 6, 6],
|
||||||
|
compsList: [['welcome', 'info'], ['echarts', 'progress'], ['time', 'sms']],
|
||||||
|
}
|
||||||
|
const grid = reactive({ layout: [], compsList: [] })
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
const initGrid = () => {
|
||||||
|
const savedGrid = localStorage.getItem('widgetsGrid')
|
||||||
|
if (savedGrid) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedGrid)
|
||||||
|
grid.layout = parsed.layout
|
||||||
|
grid.compsList = parsed.compsList
|
||||||
|
} catch (e) {
|
||||||
|
resetToDefault()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetToDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToDefault = () => {
|
||||||
|
grid.layout = [...defaultGrid.layout]
|
||||||
|
grid.compsList = defaultGrid.compsList.map((arr) => [...arr])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const allCompsList = computed(() => {
|
||||||
|
const list = []
|
||||||
|
for (const key in allComps) {
|
||||||
|
list.push({
|
||||||
|
key,
|
||||||
|
title: allComps[key].title,
|
||||||
|
icon: allComps[key].icon,
|
||||||
|
description: allComps[key].description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const myCompKeys = grid.compsList.flat()
|
||||||
|
list.forEach((comp) => {
|
||||||
|
comp.disabled = myCompKeys.includes(comp.key)
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const myCompsList = computed(() => {
|
||||||
|
return allCompsList.value.filter((item) => !item.disabled)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nowCompsList = computed(() => {
|
||||||
|
return grid.compsList.flat()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const handleCustom = () => {
|
||||||
|
customizing.value = true
|
||||||
|
const oldWidth = widgetsRef.value?.offsetWidth || 0
|
||||||
|
nextTick(() => {
|
||||||
|
if (widgetsRef.value) {
|
||||||
|
const scale = widgetsRef.value.offsetWidth / oldWidth
|
||||||
|
widgetsRef.value.style.setProperty('transform', `scale(${scale})`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLayout = (layout) => {
|
||||||
|
grid.layout = layout
|
||||||
|
if (layout.join(',') === '24') {
|
||||||
|
grid.compsList[0] = [...grid.compsList[0], ...grid.compsList[1], ...grid.compsList[2]]
|
||||||
|
grid.compsList[1] = []
|
||||||
|
grid.compsList[2] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addComp = (item) => {
|
||||||
|
grid.compsList[0].push(item.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeComp = (key) => grid.compsList.forEach((list, index) => {
|
||||||
|
grid.compsList[index] = list.filter((k) => k !== key)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
customizing.value = false
|
||||||
|
if (widgetsRef.value) {
|
||||||
|
widgetsRef.value.style.removeProperty('transform')
|
||||||
|
}
|
||||||
|
localStorage.setItem('widgetsGrid', JSON.stringify(grid))
|
||||||
|
emit('on-mounted')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetDefault = () => {
|
||||||
|
customizing.value = false
|
||||||
|
if (widgetsRef.value) {
|
||||||
|
widgetsRef.value.style.removeProperty('transform')
|
||||||
|
}
|
||||||
|
resetToDefault()
|
||||||
|
localStorage.removeItem('widgetsGrid')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
customizing.value = false
|
||||||
|
if (widgetsRef.value) {
|
||||||
|
widgetsRef.value.style.removeProperty('transform')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
initGrid()
|
||||||
|
emit('on-mounted')
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['on-mounted'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.widgets-home {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-top {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-top-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets {
|
||||||
|
transform-origin: top left;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizing .widgets-wrapper {
|
||||||
|
margin-right: -360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizing .widgets-wrapper .ant-col {
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizing .widgets-wrapper .draggable-wrapper {
|
||||||
|
border: 1px dashed #1890ff;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizing .widgets-wrapper .no-widgets {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizing .widgets-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-overlay:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-overlay label {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 30px;
|
||||||
|
border-radius: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: grab;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
margin-right: 15px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-overlay .close {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-list {
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-list-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 15px;
|
||||||
|
align-items: center;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-list-item .item-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(180, 180, 180, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 15px;
|
||||||
|
color: #6a8bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-list-item .item-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-list-item .item-info h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: normal;
|
||||||
|
cursor: default;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-list-item .item-info p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
cursor: default;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-wrapper .sortable-ghost {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-layout {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-layout-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-layout-item {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border: 2px solid #d9d9d9;
|
||||||
|
padding: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
background: #d9d9d9;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.item02 span {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.item02 .ant-col:nth-child(1) span {
|
||||||
|
height: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.item03 span {
|
||||||
|
height: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #1890ff;
|
||||||
|
|
||||||
|
span {
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-drawer {
|
||||||
|
:deep(.ant-drawer-body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgets-aside-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.customizing .widgets {
|
||||||
|
transform: scale(1) !important;
|
||||||
|
}
|
||||||
|
.customizing .widgets-drawer {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.customizing .widgets-wrapper {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
450
src/pages/home/work/components/myapp.vue
Normal file
450
src/pages/home/work/components/myapp.vue
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<div class="my-app">
|
||||||
|
<a-card :bordered="false">
|
||||||
|
<template #title>
|
||||||
|
<div class="card-title">
|
||||||
|
<SettingOutlined />
|
||||||
|
<span>我的常用应用</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="primary" @click="showDrawer">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
添加应用
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="myApps.length === 0" class="empty-state">
|
||||||
|
<a-empty description="暂无常用应用,请点击上方按钮添加" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="apps-grid">
|
||||||
|
<draggable v-model="myApps" item-key="id" :animation="200" ghost-class="ghost" drag-class="dragging"
|
||||||
|
class="draggable-grid">
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="app-item" @click="handleAppClick(element)">
|
||||||
|
<div class="app-icon">
|
||||||
|
<component :is="element.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="app-name">{{ element.name }}</div>
|
||||||
|
<div class="app-description">{{ element.description }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 添加应用抽屉 -->
|
||||||
|
<a-drawer v-model:open="drawerVisible" title="管理应用" :width="650" placement="right" class="app-drawer">
|
||||||
|
<div class="drawer-content">
|
||||||
|
<div class="app-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>
|
||||||
|
<StarFilled />
|
||||||
|
我的常用
|
||||||
|
</h3>
|
||||||
|
<span class="count">{{ myApps.length }} 个应用</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">拖拽卡片调整顺序,点击移除按钮移除应用</p>
|
||||||
|
<div class="draggable-wrapper">
|
||||||
|
<draggable
|
||||||
|
v-model="myApps"
|
||||||
|
item-key="id"
|
||||||
|
:animation="200"
|
||||||
|
ghost-class="drawer-ghost"
|
||||||
|
drag-class="drawer-dragging"
|
||||||
|
class="draggable-list"
|
||||||
|
group="apps">
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="drawer-app-item">
|
||||||
|
<div class="drag-handle">
|
||||||
|
<HolderOutlined />
|
||||||
|
</div>
|
||||||
|
<div class="app-icon">
|
||||||
|
<component :is="element.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="app-name">{{ element.name }}</div>
|
||||||
|
<div class="app-description">{{ element.description }}</div>
|
||||||
|
</div>
|
||||||
|
<a-button type="text" danger size="small" @click.stop="removeApp(element.id)">
|
||||||
|
<template #icon>
|
||||||
|
<CloseOutlined />
|
||||||
|
</template>
|
||||||
|
移除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<div v-if="myApps.length === 0" class="empty-zone">
|
||||||
|
<a-empty description="暂无常用应用" :image="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider style="margin: 24px 0" />
|
||||||
|
|
||||||
|
<div class="app-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>
|
||||||
|
<AppstoreOutlined />
|
||||||
|
全部应用
|
||||||
|
</h3>
|
||||||
|
<span class="count">{{ allApps.length }} 个可用</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">拖拽卡片到上方添加为常用应用</p>
|
||||||
|
<div class="draggable-wrapper">
|
||||||
|
<draggable
|
||||||
|
v-model="allApps"
|
||||||
|
item-key="id"
|
||||||
|
:animation="200"
|
||||||
|
ghost-class="drawer-ghost"
|
||||||
|
drag-class="drawer-dragging"
|
||||||
|
class="draggable-list"
|
||||||
|
group="apps">
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="drawer-app-item">
|
||||||
|
<div class="drag-handle">
|
||||||
|
<HolderOutlined />
|
||||||
|
</div>
|
||||||
|
<div class="app-icon">
|
||||||
|
<component :is="element.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="app-name">{{ element.name }}</div>
|
||||||
|
<div class="app-description">{{ element.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<div v-if="allApps.length === 0" class="empty-zone">
|
||||||
|
<a-empty description="所有应用已添加" :image="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="drawerVisible = false">取消</a-button>
|
||||||
|
<a-button type="primary" @click="handleSave">保存设置</a-button>
|
||||||
|
</template>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'MyApp',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 应用数据
|
||||||
|
const allAppsList = [
|
||||||
|
{ id: 1, name: '用户管理', description: '管理系统用户', icon: 'UserOutlined', path: '/auth/user' },
|
||||||
|
{ id: 2, name: '角色管理', description: '管理系统角色', icon: 'SettingOutlined', path: '/auth/role' },
|
||||||
|
{ id: 3, name: '日志管理', description: '查看系统日志', icon: 'FileTextOutlined', path: '/system/log' },
|
||||||
|
{ id: 4, name: '数据统计', description: '查看数据报表', icon: 'BarChartOutlined', path: '/statistics' },
|
||||||
|
{ id: 5, name: '日程安排', description: '查看日程', icon: 'CalendarOutlined', path: '/schedule' },
|
||||||
|
{ id: 6, name: '消息中心', description: '查看消息', icon: 'MessageOutlined', path: '/messages' },
|
||||||
|
{ id: 7, name: '订单管理', description: '管理订单', icon: 'ShoppingCartOutlined', path: '/orders' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const myApps = ref([])
|
||||||
|
const allApps = ref([])
|
||||||
|
|
||||||
|
// 从本地存储加载数据
|
||||||
|
const loadApps = () => {
|
||||||
|
const savedApps = localStorage.getItem('myApps')
|
||||||
|
if (savedApps) {
|
||||||
|
const savedIds = JSON.parse(savedApps)
|
||||||
|
myApps.value = allAppsList.filter(app => savedIds.includes(app.id))
|
||||||
|
} else {
|
||||||
|
// 默认显示前4个应用
|
||||||
|
myApps.value = allAppsList.slice(0, 4)
|
||||||
|
}
|
||||||
|
updateAllApps()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新全部应用列表(排除已添加的)
|
||||||
|
const updateAllApps = () => {
|
||||||
|
const myAppIds = myApps.value.map(app => app.id)
|
||||||
|
allApps.value = allAppsList.filter(app => !myAppIds.includes(app.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示抽屉
|
||||||
|
const showDrawer = () => {
|
||||||
|
drawerVisible.value = true
|
||||||
|
updateAllApps()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除应用
|
||||||
|
const removeApp = (id) => {
|
||||||
|
const index = myApps.value.findIndex(app => app.id === id)
|
||||||
|
if (index > -1) {
|
||||||
|
myApps.value.splice(index, 1)
|
||||||
|
updateAllApps()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用点击处理
|
||||||
|
const handleAppClick = (app) => {
|
||||||
|
// 这里可以跳转到对应的路由
|
||||||
|
message.info(`打开应用: ${app.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存设置
|
||||||
|
const handleSave = () => {
|
||||||
|
const appIds = myApps.value.map(app => app.id)
|
||||||
|
localStorage.setItem('myApps', JSON.stringify(appIds))
|
||||||
|
message.success('保存成功')
|
||||||
|
drawerVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
loadApps()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.my-app {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-grid {
|
||||||
|
.draggable-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ghost {
|
||||||
|
opacity: 0.3;
|
||||||
|
background: #e6f7ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉样式(不使用 scoped 的 deep,直接使用全局样式)
|
||||||
|
.my-app .app-drawer {
|
||||||
|
.ant-drawer-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-footer {
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-content {
|
||||||
|
.app-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #262626;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-wrapper {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.draggable-list {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-zone {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.ant-empty-description {
|
||||||
|
color: #bfbfbf;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-app-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
cursor: grab;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
margin-right: 12px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: color 0.25s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-right: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽时的样式
|
||||||
|
.drawer-ghost {
|
||||||
|
opacity: 0.4;
|
||||||
|
background: #e6f7ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
src/pages/home/work/index.vue
Normal file
21
src/pages/home/work/index.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="work-page">
|
||||||
|
<MyApp />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import MyApp from './components/myapp.vue'
|
||||||
|
|
||||||
|
// 定义组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'WorkPage',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.work-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,86 +1,109 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-modal :open="visible" :title="isEdit ? $t('common.editArea') : $t('common.addArea')" :width="600"
|
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :mask-closable="false"
|
||||||
:ok-text="$t('common.confirm')" :cancel-text="$t('common.cancel')" @ok="handleConfirm" @cancel="handleClose"
|
:footer="null" @cancel="handleCancel">
|
||||||
:after-close="handleClose">
|
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
|
||||||
<a-form ref="formRef" :model="formData" :label-col="{ span: 5 }">
|
:wrapper-col="{ span: 18 }">
|
||||||
<a-form-item v-if="!isEdit" name="code" :label="$t('common.areaCode')"
|
<a-form-item v-if="mode === 'add'" label="地区编码" name="code">
|
||||||
:rules="[{ required: true, message: $t('common.pleaseEnter') + $t('common.areaCode') }]">
|
<a-input v-model:value="form.code" placeholder="请输入地区编码" allow-clear />
|
||||||
<a-input v-model:value="formData.code" :placeholder="$t('common.pleaseEnter')" :maxlength="20" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="地区名称" name="title">
|
||||||
<a-form-item name="title" :label="$t('common.areaName')"
|
<a-input v-model:value="form.title" placeholder="请输入地区名称" allow-clear />
|
||||||
:rules="[{ required: true, message: $t('common.pleaseEnter') + $t('common.areaName') }]">
|
|
||||||
<a-input v-model:value="formData.title" :placeholder="$t('common.pleaseEnter')" :maxlength="50" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="上级地区" name="parent_code">
|
||||||
<a-form-item name="parent_code" :label="$t('common.parentArea')">
|
<a-tree-select v-model:value="form.parent_code" :tree-data="areaTreeData"
|
||||||
<a-tree-select v-model:value="formData.parent_code" :tree-data="areaTreeData"
|
:field-names="{ label: 'title', value: 'code', children: 'children' }" tree-default-expand-all
|
||||||
:placeholder="$t('common.pleaseSelect')"
|
show-search placeholder="请选择上级地区" allow-clear tree-node-filter-prop="title" />
|
||||||
:field-names="{ label: 'title', value: 'code', children: 'children' }" allow-clear show-search
|
|
||||||
tree-default-expand-all />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
<a-form-item name="status" :label="$t('common.status')"
|
<a-radio-group v-model:value="form.status">
|
||||||
:rules="[{ required: true, message: $t('common.pleaseSelect') + $t('common.status') }]">
|
<a-radio :value="1">正常</a-radio>
|
||||||
<a-radio-group v-model:value="formData.status">
|
<a-radio :value="0">禁用</a-radio>
|
||||||
<a-radio :value="1">{{ $t('common.enabled') }}</a-radio>
|
|
||||||
<a-radio :value="0">{{ $t('common.disabled') }}</a-radio>
|
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="handleCancel">取 消</a-button>
|
||||||
|
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||||
|
</template>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
import systemApi from '@/api/system'
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
// 定义组件名称
|
const emit = defineEmits(['success', 'closed'])
|
||||||
defineOptions({
|
|
||||||
name: 'AreaModal',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps({
|
const mode = ref('add')
|
||||||
visible: {
|
const titleMap = {
|
||||||
type: Boolean,
|
add: '新增地区',
|
||||||
default: false,
|
edit: '编辑地区',
|
||||||
},
|
show: '查看地区'
|
||||||
// 是否为编辑模式
|
}
|
||||||
isEdit: {
|
const visible = ref(false)
|
||||||
type: Boolean,
|
const isSaveing = ref(false)
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// 编辑时的初始数据
|
|
||||||
initialData: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:visible', 'confirm'])
|
|
||||||
|
|
||||||
const formRef = ref(null)
|
|
||||||
const areaTreeData = ref([])
|
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive({
|
const form = reactive({
|
||||||
id: null,
|
id: null,
|
||||||
code: '',
|
code: '',
|
||||||
title: '',
|
title: '',
|
||||||
parent_code: null,
|
parent_code: null,
|
||||||
status: 1,
|
status: 1
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取地区树数据
|
// 表单引用
|
||||||
const fetchAreaTree = async () => {
|
const dialogForm = ref()
|
||||||
|
|
||||||
|
// 验证规则
|
||||||
|
const rules = {
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入地区编码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入地区名称', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地区树数据
|
||||||
|
const areaTreeData = ref([])
|
||||||
|
|
||||||
|
// 显示对话框
|
||||||
|
const open = (openMode = 'add') => {
|
||||||
|
mode.value = openMode
|
||||||
|
visible.value = true
|
||||||
|
if (openMode !== 'show') {
|
||||||
|
loadAreaTree()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
setData,
|
||||||
|
open,
|
||||||
|
close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载地区树数据
|
||||||
|
const loadAreaTree = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await systemApi.area.list.get({ pageSize: 1000 })
|
const res = await systemApi.area.list.get({ pageSize: 1000 })
|
||||||
if (res.code === 200 || res.code === 1) {
|
if (res.code === 1) {
|
||||||
const list = res.data.list || res.data || []
|
const list = res.data.list || res.data || []
|
||||||
areaTreeData.value = buildTree(list)
|
areaTreeData.value = buildTree(list)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取地区树失败:', error)
|
console.error('加载地区树失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,62 +129,49 @@ const buildTree = (list) => {
|
|||||||
return roots
|
return roots
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听弹窗显示和初始数据变化
|
// 表单提交方法
|
||||||
watch(
|
const submit = async () => {
|
||||||
() => props.initialData,
|
|
||||||
(newVal) => {
|
|
||||||
if (props.isEdit && Object.keys(newVal).length > 0) {
|
|
||||||
formData.id = newVal.id || null
|
|
||||||
formData.code = newVal.code || ''
|
|
||||||
formData.title = newVal.title || ''
|
|
||||||
formData.parent_code = newVal.parent_code || null
|
|
||||||
formData.status = newVal.status ?? 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// 监听弹窗打开,加载地区树数据
|
|
||||||
watch(
|
|
||||||
() => props.visible,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
fetchAreaTree()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 监听弹窗关闭,重置表单
|
|
||||||
watch(
|
|
||||||
() => props.visible,
|
|
||||||
(newVal) => {
|
|
||||||
if (!newVal) {
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 确认提交
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
try {
|
try {
|
||||||
const values = await formRef.value.validate()
|
await dialogForm.value.validate()
|
||||||
emit('confirm', values)
|
isSaveing.value = true
|
||||||
} catch {
|
let res = {}
|
||||||
// 表单验证错误,不处理
|
if (mode.value === 'add') {
|
||||||
|
res = await systemApi.area.add.post(form)
|
||||||
|
} else {
|
||||||
|
res = await systemApi.area.edit.post(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaveing.value = false
|
||||||
|
if (res.code === 1) {
|
||||||
|
emit('success', form, mode.value)
|
||||||
|
visible.value = false
|
||||||
|
message.success('操作成功')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isSaveing.value = false
|
||||||
|
console.error('表单验证失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭弹窗并重置表单
|
// 表单注入数据
|
||||||
const handleClose = () => {
|
const setData = (data) => {
|
||||||
formRef.value?.resetFields()
|
form.id = data.id
|
||||||
formData.id = null
|
form.code = data.code
|
||||||
formData.code = ''
|
form.title = data.title
|
||||||
formData.title = ''
|
form.parent_code = data.parent_code
|
||||||
formData.parent_code = null
|
form.status = data.status ?? 1
|
||||||
formData.status = 1
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setData,
|
||||||
|
close
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
// 弹窗样式可根据需要添加</style>
|
// 弹窗样式可根据需要添加
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,75 +1,124 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pages system-area">
|
<div class="pages area-page">
|
||||||
<sc-table ref="tableRef" :columns="columns" :data-source="dataSource" :loading="loading"
|
<div class="left-box">
|
||||||
:pagination="pagination" @refresh="loadData" @change="handleTableChange" :row-selection="rowSelection"
|
<div class="header">
|
||||||
:show-action="true" :actions="actions" :show-index="true" :show-striped="true">
|
<a-input v-model:value="areaKeyword" placeholder="搜索地区..." allow-clear @change="handleAreaSearch">
|
||||||
<!-- 工具栏左侧 -->
|
<template #prefix>
|
||||||
<template #toolLeft>
|
<search-outlined style="color: rgba(0, 0, 0, 0.45)" />
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon>
|
|
||||||
<PlusOutlined />
|
|
||||||
</template>
|
</template>
|
||||||
{{ $t('common.add') }}
|
</a-input>
|
||||||
</a-button>
|
</div>
|
||||||
<a-button danger @click="handleBatchDelete" :disabled="!selectedRowKeys.length">
|
<div class="body">
|
||||||
<template #icon>
|
<a-tree v-model:selectedKeys="selectedAreaKeys" :tree-data="filteredAreaTree"
|
||||||
<DeleteOutlined />
|
:field-names="{ title: 'title', key: 'code', children: 'children' }" show-line @select="onAreaSelect">
|
||||||
|
<template #icon="{ dataRef }">
|
||||||
|
<folder-outlined v-if="dataRef.children" />
|
||||||
|
<file-outlined v-else />
|
||||||
</template>
|
</template>
|
||||||
{{ $t('common.batchDelete') }}
|
</a-tree>
|
||||||
</a-button>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
<div class="right-box">
|
||||||
<!-- 状态列自定义 -->
|
<div class="tool-bar">
|
||||||
<template #status="{ text }">
|
<div class="left-panel">
|
||||||
<a-tag :color="text === 1 ? 'green' : 'red'">
|
<a-form layout="inline" :model="searchForm">
|
||||||
{{ text === 1 ? $t('common.enabled') : $t('common.disabled') }}
|
<a-form-item>
|
||||||
</a-tag>
|
<a-input v-model:value="searchForm.title" placeholder="请输入地区名称" allow-clear
|
||||||
</template>
|
style="width: 160px" />
|
||||||
|
</a-form-item>
|
||||||
<!-- 级别列自定义 -->
|
<a-form-item>
|
||||||
<template #level="{ text }">
|
<a-select v-model:value="searchForm.level" placeholder="地区级别" allow-clear style="width: 120px">
|
||||||
<a-tag color="blue">{{ getLevelText(text) }}</a-tag>
|
<a-select-option :value="1">省</a-select-option>
|
||||||
</template>
|
<a-select-option :value="2">市</a-select-option>
|
||||||
</sc-table>
|
<a-select-option :value="3">区/县</a-select-option>
|
||||||
|
<a-select-option :value="4">街道</a-select-option>
|
||||||
<!-- 添加/编辑弹窗 -->
|
</a-select>
|
||||||
<AreaModal v-model:visible="modalVisible" :is-edit="isEdit" :initial-data="currentData"
|
</a-form-item>
|
||||||
@confirm="handleModalConfirm" />
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><search-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><redo-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<a-button type="primary" @click="handleAdd">
|
||||||
|
<template #icon><plus-outlined /></template>
|
||||||
|
新增
|
||||||
|
</a-button>
|
||||||
|
<a-button danger @click="handleBatchDelete" :disabled="!selectedRowKeys.length">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
批量删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-content">
|
||||||
|
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
||||||
|
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
||||||
|
:row-selection="rowSelection">
|
||||||
|
<template #level="{ record }">
|
||||||
|
<a-tag color="blue">{{ getLevelText(record.level) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="record.status === 1 ? 'success' : 'error'">
|
||||||
|
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template #action="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||||
|
<a-popconfirm title="确定删除该地区吗?" @confirm="handleDelete(record)">
|
||||||
|
<a-button type="link" size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</scTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑地区弹窗 -->
|
||||||
|
<area-modal v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
import { SearchOutlined, RedoOutlined, DeleteOutlined, PlusOutlined, FolderOutlined, FileOutlined } from '@ant-design/icons-vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import scTable from '@/components/scTable/index.vue'
|
||||||
|
import areaModal from './components/AreaModal.vue'
|
||||||
import systemApi from '@/api/system'
|
import systemApi from '@/api/system'
|
||||||
import ScTable from '@/components/scTable/index.vue'
|
|
||||||
import AreaModal from './components/AreaModal.vue'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'SystemArea',
|
name: 'systemArea'
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
// 表格引用
|
||||||
const tableRef = ref(null)
|
const tableRef = ref(null)
|
||||||
|
|
||||||
// 数据源
|
// 对话框状态
|
||||||
const dataSource = ref([])
|
const dialog = reactive({
|
||||||
const loading = ref(false)
|
save: false
|
||||||
|
})
|
||||||
|
|
||||||
// 分页
|
// 弹窗引用
|
||||||
const pagination = reactive(false)
|
const saveDialogRef = ref(null)
|
||||||
|
|
||||||
|
// 地区树数据
|
||||||
|
const areaTree = ref([])
|
||||||
|
const filteredAreaTree = ref([])
|
||||||
|
const selectedAreaKeys = ref([])
|
||||||
|
const areaKeyword = ref('')
|
||||||
|
|
||||||
// 选中的行
|
// 选中的行
|
||||||
const selectedRowKeys = ref([])
|
const selectedRowKeys = ref([])
|
||||||
|
|
||||||
// 弹窗相关
|
|
||||||
const modalVisible = ref(false)
|
|
||||||
const isEdit = ref(false)
|
|
||||||
const currentData = ref({})
|
|
||||||
|
|
||||||
// 行选择配置
|
// 行选择配置
|
||||||
const rowSelection = computed(() => ({
|
const rowSelection = computed(() => ({
|
||||||
selectedRowKeys: selectedRowKeys.value,
|
selectedRowKeys: selectedRowKeys.value,
|
||||||
@@ -78,131 +127,217 @@ const rowSelection = computed(() => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 操作列配置
|
// 搜索表单
|
||||||
const actions = computed(() => [
|
const searchForm = reactive({
|
||||||
{
|
title: '',
|
||||||
label: t('common.edit'),
|
level: null,
|
||||||
onClick: handleEdit,
|
parent_code: null
|
||||||
},
|
})
|
||||||
{
|
|
||||||
label: t('common.delete'),
|
|
||||||
onClick: handleDelete,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 添加
|
// 表格数据
|
||||||
const handleAdd = () => {
|
const tableData = ref([])
|
||||||
isEdit.value = false
|
const loading = ref(false)
|
||||||
currentData.value = {}
|
|
||||||
modalVisible.value = true
|
// 分页配置
|
||||||
}
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
pageSizeOptions: ['20', '50', '100', '200']
|
||||||
|
})
|
||||||
|
|
||||||
|
// 行key
|
||||||
|
const rowKey = 'id'
|
||||||
|
|
||||||
// 获取级别文本
|
// 获取级别文本
|
||||||
const getLevelText = (level) => {
|
const getLevelText = (level) => {
|
||||||
const levelMap = {
|
const levelMap = {
|
||||||
1: t('common.province'),
|
1: '省',
|
||||||
2: t('common.city'),
|
2: '市',
|
||||||
3: t('common.district'),
|
3: '区/县',
|
||||||
4: t('common.street'),
|
4: '街道'
|
||||||
}
|
}
|
||||||
return levelMap[level] || t('common.unknown')
|
return levelMap[level] || '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列配置
|
// 表格列配置
|
||||||
const columns = ref([
|
const columns = [
|
||||||
{
|
{ title: '地区名称', dataIndex: 'title', key: 'title', width: 200 },
|
||||||
title: t('common.areaName'),
|
{ title: '地区编码', dataIndex: 'code', key: 'code', width: 150 },
|
||||||
dataIndex: 'title',
|
{ title: '地区级别', dataIndex: 'level', key: 'level', width: 100, slot: 'level' },
|
||||||
key: 'title',
|
{ title: '上级地区', dataIndex: 'parent_code', key: 'parent_code', width: 150 },
|
||||||
width: 200,
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
|
||||||
},
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||||
{
|
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, align: 'center', slot: 'action', fixed: 'right' }
|
||||||
title: t('common.areaCode'),
|
]
|
||||||
dataIndex: 'code',
|
|
||||||
key: 'code',
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('common.areaLevel'),
|
|
||||||
dataIndex: 'level',
|
|
||||||
key: 'level',
|
|
||||||
width: 100,
|
|
||||||
slot: 'level',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('common.parentArea'),
|
|
||||||
dataIndex: 'parent_code',
|
|
||||||
key: 'parent_code',
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('common.status'),
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
|
||||||
width: 100,
|
|
||||||
slot: 'status',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('common.createTime'),
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
key: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 加载数据
|
// 加载地区树
|
||||||
const loadData = async () => {
|
const loadAreaTree = async () => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.area.list.get({ pageSize: 1000 })
|
||||||
|
if (res.code === 1) {
|
||||||
|
const list = res.data.list || res.data || []
|
||||||
|
const tree = buildTree(list)
|
||||||
|
areaTree.value = tree
|
||||||
|
filteredAreaTree.value = tree
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载地区树失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建树结构
|
||||||
|
const buildTree = (list) => {
|
||||||
|
const map = {}
|
||||||
|
const roots = []
|
||||||
|
|
||||||
|
// 创建映射,以 code 作为键
|
||||||
|
list.forEach((item) => {
|
||||||
|
map[item.code] = { ...item, children: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建树,通过 parent_code 关联
|
||||||
|
list.forEach((item) => {
|
||||||
|
if (item.parent_code && map[item.parent_code]) {
|
||||||
|
map[item.parent_code].children.push(map[item.code])
|
||||||
|
} else if (item.parent_code === '0' || !item.parent_code) {
|
||||||
|
roots.push(map[item.code])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地区搜索
|
||||||
|
const handleAreaSearch = (e) => {
|
||||||
|
const keyword = e.target?.value || ''
|
||||||
|
areaKeyword.value = keyword
|
||||||
|
if (!keyword) {
|
||||||
|
filteredAreaTree.value = areaTree.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归过滤地区树
|
||||||
|
const filterTree = (nodes) => {
|
||||||
|
return nodes.reduce((acc, node) => {
|
||||||
|
const isMatch = node.title && node.title.toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
const filteredChildren = node.children ? filterTree(node.children) : []
|
||||||
|
|
||||||
|
if (isMatch || filteredChildren.length > 0) {
|
||||||
|
acc.push({
|
||||||
|
...node,
|
||||||
|
children: filteredChildren.length > 0 ? filteredChildren : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredAreaTree.value = filterTree(areaTree.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载地区列表数据
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
|
||||||
const params = {
|
const params = {
|
||||||
is_tree: 1,
|
page: pagination.current,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
...searchForm
|
||||||
}
|
}
|
||||||
const res = await systemApi.area.list.get(params)
|
const res = await systemApi.area.list.get(params)
|
||||||
if (res.code === 1) {
|
if (res.code === 1) {
|
||||||
dataSource.value = res.data.list || res.data || []
|
tableData.value = res.data?.data || res.data?.list || []
|
||||||
|
pagination.total = res.data?.total || 0
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '加载数据失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(t('common.fetchDataFailed'))
|
console.error('加载地区列表失败:', error)
|
||||||
console.error('获取地区列表失败:', error)
|
message.error('加载数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表格变化处理
|
// 分页变化处理
|
||||||
const handleTableChange = ({ pagination: newPagination }) => {
|
const handlePaginationChange = ({ page, pageSize }) => {
|
||||||
if (newPagination.current) pagination.current = newPagination.current
|
pagination.current = page
|
||||||
if (newPagination.pageSize) pagination.pageSize = newPagination.pageSize
|
pagination.pageSize = pageSize
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑
|
// 地区选择事件
|
||||||
const handleEdit = (record) => {
|
const onAreaSelect = (selectedKeys) => {
|
||||||
isEdit.value = true
|
if (selectedKeys && selectedKeys.length > 0) {
|
||||||
currentData.value = { ...record }
|
searchForm.parent_code = selectedKeys[0]
|
||||||
modalVisible.value = true
|
} else {
|
||||||
|
searchForm.parent_code = null
|
||||||
|
}
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除
|
// 搜索
|
||||||
const handleDelete = (record) => {
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.title = ''
|
||||||
|
searchForm.level = null
|
||||||
|
searchForm.parent_code = null
|
||||||
|
selectedAreaKeys.value = []
|
||||||
|
areaKeyword.value = ''
|
||||||
|
filteredAreaTree.value = areaTree.value
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增地区
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialog.save = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saveDialogRef.value?.open('add')
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看地区
|
||||||
|
const handleView = (record) => {
|
||||||
|
dialog.save = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saveDialogRef.value?.open('show').setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑地区
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
dialog.save = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saveDialogRef.value?.open('edit').setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除地区
|
||||||
|
const handleDelete = async (record) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: t('common.confirmDelete'),
|
title: '确认删除',
|
||||||
content: `${t('common.deleteConfirm')}: ${record.title}?`,
|
content: `确定要删除地区 "${record.title}" 吗?`,
|
||||||
okText: t('common.confirm'),
|
okText: '确定',
|
||||||
cancelText: t('common.cancel'),
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
// 注意:API 中没有删除接口,这里模拟删除成功
|
// 注意:API 中没有删除接口,这里模拟删除成功
|
||||||
// 如果后端有删除接口,请取消注释以下代码
|
message.success('删除成功')
|
||||||
// const res = await systemApi.area.delete.post({ id: record.id })
|
|
||||||
// if (res.code === 200 || res.code === 1) {
|
|
||||||
message.success(t('common.deleteSuccess'))
|
|
||||||
loadData()
|
loadData()
|
||||||
selectedRowKeys.value = [] // 清除选择
|
selectedRowKeys.value = []
|
||||||
// }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(t('common.deleteFailed'))
|
|
||||||
console.error('删除地区失败:', error)
|
console.error('删除地区失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -211,65 +346,87 @@ const handleDelete = (record) => {
|
|||||||
// 批量删除
|
// 批量删除
|
||||||
const handleBatchDelete = () => {
|
const handleBatchDelete = () => {
|
||||||
if (selectedRowKeys.value.length === 0) {
|
if (selectedRowKeys.value.length === 0) {
|
||||||
message.warning(t('common.selectDataFirst'))
|
message.warning('请先选择要删除的数据')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: t('common.confirmBatchDelete'),
|
title: '确认批量删除',
|
||||||
content: `${t('common.batchDeleteConfirm')}: ${selectedRowKeys.value.length} ${t('common.items')}?`,
|
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条数据吗?`,
|
||||||
okText: t('common.confirm'),
|
okText: '确定',
|
||||||
cancelText: t('common.cancel'),
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
// 注意:API 中没有批量删除接口,这里模拟删除成功
|
// 注意:API 中没有批量删除接口,这里模拟删除成功
|
||||||
// 如果后端有批量删除接口,请取消注释以下代码
|
message.success('删除成功')
|
||||||
// const res = await systemApi.area.batchDelete.post({ ids: selectedRowKeys.value })
|
|
||||||
// if (res.code === 200 || res.code === 1) {
|
|
||||||
message.success(t('common.deleteSuccess'))
|
|
||||||
selectedRowKeys.value = []
|
selectedRowKeys.value = []
|
||||||
loadData()
|
loadData()
|
||||||
// }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(t('common.deleteFailed'))
|
|
||||||
console.error('批量删除失败:', error)
|
console.error('批量删除失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗确认
|
// 保存成功回调
|
||||||
const handleModalConfirm = async (values) => {
|
const handleSaveSuccess = (data, mode) => {
|
||||||
try {
|
if (mode === 'add') {
|
||||||
let res
|
loadAreaTree()
|
||||||
if (isEdit.value) {
|
loadData()
|
||||||
// 编辑
|
} else if (mode === 'edit') {
|
||||||
res = await systemApi.area.edit.post(values)
|
loadAreaTree()
|
||||||
if (res.code === 200 || res.code === 1) {
|
loadData()
|
||||||
message.success(t('common.editSuccess'))
|
|
||||||
modalVisible.value = false
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 添加
|
|
||||||
res = await systemApi.area.add.post(values)
|
|
||||||
if (res.code === 200 || res.code === 1) {
|
|
||||||
message.success(t('common.addSuccess'))
|
|
||||||
modalVisible.value = false
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.errorFields) {
|
|
||||||
return // 表单验证错误
|
|
||||||
}
|
|
||||||
message.error(isEdit.value ? t('common.editFailed') : t('common.addFailed'))
|
|
||||||
console.error(isEdit.value ? '编辑地区失败:' : '添加地区失败:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化加载数据
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadAreaTree()
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.area-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.left-box {
|
||||||
|
width: 260px;
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.table-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,288 @@
|
|||||||
<template>
|
<template>
|
||||||
<div></div>
|
<div class="pages client-page">
|
||||||
|
<div class="tool-bar">
|
||||||
|
<div class="left-panel">
|
||||||
|
<a-button type="primary" @click="handleAdd">
|
||||||
|
<template #icon><plus-outlined /></template>
|
||||||
|
新增
|
||||||
|
</a-button>
|
||||||
|
<a-button danger @click="handleBatchDelete" :disabled="!selectedRowKeys.length">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
批量删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<a-form layout="inline" :model="searchForm">
|
||||||
|
<a-form-item>
|
||||||
|
<a-input v-model:value="searchForm.title" placeholder="请输入名称" allow-clear style="width: 200px" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><search-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><redo-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-content">
|
||||||
|
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
||||||
|
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
||||||
|
:row-selection="rowSelection">
|
||||||
|
<template #action="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||||
|
<a-button type="link" size="small" @click="handleMenu(record)">菜单</a-button>
|
||||||
|
<a-popconfirm title="确定删除吗?" @confirm="handleDelete(record)">
|
||||||
|
<a-button type="link" size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</scTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑客户端弹窗 -->
|
||||||
|
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
|
||||||
|
|
||||||
|
<!-- 菜单管理抽屉 -->
|
||||||
|
<menu-drawer v-if="dialog.menu" ref="menuDrawerRef" @closed="dialog.menu = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { SearchOutlined, RedoOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
import scTable from '@/components/scTable/index.vue'
|
||||||
|
import saveDialog from './save.vue'
|
||||||
|
import menuDrawer from './menu.vue'
|
||||||
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "systemClient"
|
name: 'systemClient'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格引用
|
||||||
|
const tableRef = ref(null)
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const dialog = reactive({
|
||||||
|
save: false,
|
||||||
|
menu: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗引用
|
||||||
|
const saveDialogRef = ref(null)
|
||||||
|
const menuDrawerRef = ref(null)
|
||||||
|
|
||||||
|
// 选中的行
|
||||||
|
const selectedRowKeys = ref([])
|
||||||
|
|
||||||
|
// 行选择配置
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
selectedRowKeys: selectedRowKeys.value,
|
||||||
|
onChange: (selectedKeys) => {
|
||||||
|
selectedRowKeys.value = selectedKeys
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 搜索表单
|
||||||
|
const searchForm = reactive({
|
||||||
|
title: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格数据
|
||||||
|
const tableData = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
pageSizeOptions: ['20', '50', '100', '200']
|
||||||
|
})
|
||||||
|
|
||||||
|
// 行key
|
||||||
|
const rowKey = 'id'
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||||
|
{ title: '名称', dataIndex: 'title', key: 'title', width: 200 },
|
||||||
|
{ title: '客户端ID', dataIndex: 'app_id', key: 'app_id', width: 200 },
|
||||||
|
{ title: '客户端Secret', dataIndex: 'secret', key: 'secret', width: 300 },
|
||||||
|
{ title: '添加时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||||
|
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 },
|
||||||
|
{ title: '操作', dataIndex: 'action', key: 'action', width: 220, align: 'center', slot: 'action', fixed: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加载客户端列表数据
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.current,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
...searchForm
|
||||||
|
}
|
||||||
|
const res = await systemApi.client.list.get(params)
|
||||||
|
if (res.code === 1) {
|
||||||
|
tableData.value = res.data?.data || []
|
||||||
|
pagination.total = res.data?.total || 0
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '加载数据失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载客户端列表失败:', error)
|
||||||
|
message.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化处理
|
||||||
|
const handlePaginationChange = ({ page, pageSize }) => {
|
||||||
|
pagination.current = page
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.title = ''
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增客户端
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialog.save = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saveDialogRef.value?.open('add').setData({})
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看客户端
|
||||||
|
const handleView = (record) => {
|
||||||
|
dialog.save = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saveDialogRef.value?.open('show').setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑客户端
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
dialog.save = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saveDialogRef.value?.open('edit').setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单管理
|
||||||
|
const handleMenu = (record) => {
|
||||||
|
dialog.menu = true
|
||||||
|
setTimeout(() => {
|
||||||
|
menuDrawerRef.value?.open().setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除客户端
|
||||||
|
const handleDelete = async (record) => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.client.delete.post({ id: record.id })
|
||||||
|
if (res.code === 1) {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除客户端失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
if (selectedRowKeys.value.length === 0) {
|
||||||
|
message.warning('请先选择要删除的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认批量删除',
|
||||||
|
content: `确定要删除选中的 ${selectedRowKeys.value.length} 项吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const promises = selectedRowKeys.value.map(id => systemApi.client.delete.post({ id }))
|
||||||
|
await Promise.all(promises)
|
||||||
|
message.success('操作成功')
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存成功回调
|
||||||
|
const handleSaveSuccess = () => {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.client-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.tool-bar {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,136 +1,314 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-drawer :title="detail.title + '菜单'" v-model="visible" size="80%" destroy-on-close :close-on-click-modal="false" @closed="$emit('closed')">
|
<a-drawer :title="detail.title + '菜单'" :open="visible" width="80%" :destroy-on-close="true"
|
||||||
<el-header>
|
:mask-closable="false" @close="handleClose">
|
||||||
<div class="left-panel">
|
<div class="menu-drawer">
|
||||||
<el-button type="primary" icon="el-icon-plus" @click="add"></el-button>
|
<div class="tool-bar">
|
||||||
</div>
|
<div class="left-panel">
|
||||||
<div class="right-panel">
|
<a-button type="primary" @click="handleAdd">
|
||||||
<div class="right-panel-search">
|
<template #icon><plus-outlined /></template>
|
||||||
<el-input v-model="search.title" placeholder="名称" clearable></el-input>
|
新增
|
||||||
<el-button type="primary" icon="el-icon-search" @click="upsearch"></el-button>
|
</a-button>
|
||||||
|
<a-button danger @click="handleBatchDelete" :disabled="!selectedRowKeys.length">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
批量删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<a-form layout="inline" :model="searchForm">
|
||||||
|
<a-form-item>
|
||||||
|
<a-input v-model:value="searchForm.title" placeholder="请输入名称" allow-clear
|
||||||
|
style="width: 200px" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><search-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><redo-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-content">
|
||||||
|
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
||||||
|
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
||||||
|
:row-selection="rowSelection">
|
||||||
|
<template #client="{ record }">
|
||||||
|
{{ record.client?.title || '-' }}
|
||||||
|
</template>
|
||||||
|
<template #action="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||||
|
<a-popconfirm title="确定删除吗?" @confirm="handleDelete(record)">
|
||||||
|
<a-button type="link" size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</scTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</a-drawer>
|
||||||
<el-main class="nopadding">
|
|
||||||
<scTable ref="table" :apiObj="list.apiObj" :column="list.column" row-key="id" @selection-change="selectionChange" :params="search" hidePagination>
|
<!-- 菜单表单弹窗 -->
|
||||||
<el-table-column type="selection" />
|
<menu-form-dialog v-if="dialog.form" ref="formDialogRef" @success="handleFormSuccess"
|
||||||
<template #client="scope">
|
@closed="dialog.form = false" />
|
||||||
{{ scope.row.client?.title }}
|
|
||||||
</template>
|
|
||||||
<template #operation="scope">
|
|
||||||
<el-button-group>
|
|
||||||
<el-button type="primary" @click="edit(scope.row, scope.$index)">编辑</el-button>
|
|
||||||
<el-button type="primary" @click="table_show(scope.row, scope.$index)">查看</el-button>
|
|
||||||
<el-popconfirm title="确定删除吗?" @confirm="table_delete(scope.row, scope.$index)">
|
|
||||||
<template #reference>
|
|
||||||
<el-button type="danger">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-popconfirm>
|
|
||||||
</el-button-group>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</el-main>
|
|
||||||
</el-drawer>
|
|
||||||
<save ref="saveBox" v-if="dialog.save" @success="upsearch" @closed="dialog.save=false" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import save from './menuform.vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { SearchOutlined, RedoOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
import scTable from '@/components/scTable/index.vue'
|
||||||
|
import menuFormDialog from './menuform.vue'
|
||||||
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
export default {
|
const emit = defineEmits(['closed'])
|
||||||
emits: ['success', 'closed'],
|
|
||||||
components: { save },
|
const visible = ref(false)
|
||||||
data(){
|
const detail = ref({})
|
||||||
return {
|
|
||||||
detail: {},
|
// 对话框状态
|
||||||
visible: false,
|
const dialog = reactive({
|
||||||
isSaveing: false,
|
form: false
|
||||||
dialog: {search: false, menu: false, save: false},
|
})
|
||||||
list: {
|
|
||||||
apiObj: this.$API.system.client.menu.list,
|
// 弹窗引用
|
||||||
column: [
|
const formDialogRef = ref(null)
|
||||||
{prop: 'id', label: 'ID', width: 80},
|
|
||||||
{prop: 'title', label: '名称'},
|
// 选中的行
|
||||||
{prop: 'client', label: '所属客户端', width: 180},
|
const selectedRowKeys = ref([])
|
||||||
{prop: 'url', label: '链接', width: 260},
|
|
||||||
{prop: 'sort', label: '排序', width: 80},
|
// 行选择配置
|
||||||
{prop: 'created_at', label: '添加时间', width: 160},
|
const rowSelection = computed(() => ({
|
||||||
{prop: 'updated_at', label: '更新时间', width: 160},
|
selectedRowKeys: selectedRowKeys.value,
|
||||||
{prop: 'operation', label: '操作', width: 160, fixed: 'right'}
|
onChange: (selectedKeys) => {
|
||||||
],
|
selectedRowKeys.value = selectedKeys
|
||||||
},
|
|
||||||
searchFields: [
|
|
||||||
{title: '标题', key: 'title', type: 'string'},
|
|
||||||
],
|
|
||||||
actions: {
|
|
||||||
add: {title: '', icon: 'a-icon-plus-outlined', type: 'primary'},
|
|
||||||
},
|
|
||||||
selection: [],
|
|
||||||
search: {is_tree: 1}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mounted(){
|
}))
|
||||||
},
|
|
||||||
methods:{
|
// 搜索表单
|
||||||
//显示
|
const searchForm = reactive({
|
||||||
open(){
|
title: '',
|
||||||
this.visible = true;
|
client_id: null
|
||||||
return this;
|
})
|
||||||
},
|
|
||||||
//表单注入数据
|
// 表格数据
|
||||||
setData(data){
|
const tableData = ref([])
|
||||||
this.loading = true
|
const loading = ref(false)
|
||||||
this.detail = data
|
|
||||||
},
|
// 分页配置
|
||||||
upsearch(){
|
const pagination = reactive({
|
||||||
this.$refs.table.reload(this.search);
|
current: 1,
|
||||||
},
|
pageSize: 20,
|
||||||
moreUpsearch(search){
|
total: 0,
|
||||||
this.search = search;
|
showSizeChanger: true,
|
||||||
this.upsearch();
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
},
|
pageSizeOptions: ['20', '50', '100', '200']
|
||||||
moreSearch(){
|
})
|
||||||
this.dialog.search = true
|
|
||||||
this.$nextTick(() => {
|
// 行key
|
||||||
this.$refs.searchBox.open().setData(this.search)
|
const rowKey = 'id'
|
||||||
})
|
|
||||||
},
|
// 表格列配置
|
||||||
//表格选择后回调事件
|
const columns = [
|
||||||
selectionChange(selection){
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||||
this.selection = selection;
|
{ title: '名称', dataIndex: 'title', key: 'title', width: 200 },
|
||||||
console.log(selection)
|
{ title: '所属客户端', dataIndex: 'client', key: 'client', width: 180, slot: 'client' },
|
||||||
},
|
{ title: '链接', dataIndex: 'url', key: 'url', width: 260 },
|
||||||
add(){
|
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
|
||||||
this.dialog.save = true
|
{ title: '添加时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||||
this.$nextTick(() => {
|
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 },
|
||||||
this.$refs.saveBox.open('add').setData()
|
{ title: '操作', dataIndex: 'action', key: 'action', width: 180, align: 'center', slot: 'action', fixed: 'right' }
|
||||||
})
|
]
|
||||||
},
|
|
||||||
edit(item){
|
// 显示抽屉
|
||||||
this.dialog.save = true
|
const open = () => {
|
||||||
this.$nextTick(() => {
|
visible.value = true
|
||||||
this.$refs.saveBox.open('edit').setData(item)
|
return {
|
||||||
})
|
setData,
|
||||||
},
|
open,
|
||||||
table_show(item){
|
close
|
||||||
this.dialog.save = true
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$refs.saveBox.open('show').setData(item)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async table_delete(item){
|
|
||||||
let res = await this.$API.system.client.delete.post({id: item.id});
|
|
||||||
if(res.code == 1){
|
|
||||||
//这里选择刷新整个表格 OR 插入/编辑现有表格数据
|
|
||||||
this.upsearch()
|
|
||||||
this.$message.success("删除成功")
|
|
||||||
}else{
|
|
||||||
this.$message.error(res.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭抽屉
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理关闭
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置数据
|
||||||
|
const setData = (data) => {
|
||||||
|
detail.value = data
|
||||||
|
searchForm.client_id = data.id
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载菜单列表数据
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.current,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
client_id: searchForm.client_id,
|
||||||
|
title: searchForm.title,
|
||||||
|
is_tree: 0
|
||||||
|
}
|
||||||
|
const res = await systemApi.client.menu.list.get(params)
|
||||||
|
if (res.code === 1) {
|
||||||
|
tableData.value = res.data?.data || []
|
||||||
|
pagination.total = res.data?.total || 0
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '加载数据失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载菜单列表失败:', error)
|
||||||
|
message.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化处理
|
||||||
|
const handlePaginationChange = ({ page, pageSize }) => {
|
||||||
|
pagination.current = page
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.title = ''
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增菜单
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialog.form = true
|
||||||
|
setTimeout(() => {
|
||||||
|
formDialogRef.value?.open('add').setData({ client_id: detail.value.id })
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看菜单
|
||||||
|
const handleView = (record) => {
|
||||||
|
dialog.form = true
|
||||||
|
setTimeout(() => {
|
||||||
|
formDialogRef.value?.open('show').setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑菜单
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
dialog.form = true
|
||||||
|
setTimeout(() => {
|
||||||
|
formDialogRef.value?.open('edit').setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除菜单
|
||||||
|
const handleDelete = async (record) => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.client.menu.delete.post({ id: record.id })
|
||||||
|
if (res.code === 1) {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除菜单失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
if (selectedRowKeys.value.length === 0) {
|
||||||
|
message.warning('请先选择要删除的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认批量删除',
|
||||||
|
content: `确定要删除选中的 ${selectedRowKeys.value.length} 项吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const promises = selectedRowKeys.value.map(id => systemApi.client.menu.delete.post({ id }))
|
||||||
|
await Promise.all(promises)
|
||||||
|
message.success('操作成功')
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单成功回调
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setData,
|
||||||
|
close
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
|
.menu-drawer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.tool-bar {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,76 +1,202 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :title="titleMap[mode]" v-model="visible" size="50%" destroy-on-close @closed="$emit('closed')">
|
<a-modal :title="titleMap[mode]" :open="visible" :width="600" :destroy-on-close="true" :mask-closable="false"
|
||||||
<el-main>
|
:footer="null" @cancel="handleCancel">
|
||||||
<scForm v-model="form" :config="config" :apiObj="apiObj" @onSuccess="onSuccess"></scForm>
|
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
|
||||||
</el-main>
|
:wrapper-col="{ span: 18 }">
|
||||||
</el-dialog>
|
<a-form-item label="名称" name="title">
|
||||||
|
<a-input v-model:value="form.title" placeholder="请输入名称" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="上级菜单" name="parent_id">
|
||||||
|
<a-tree-select v-model:value="form.parent_id" :tree-data="menuTreeData"
|
||||||
|
:field-names="{ label: 'title', value: 'id', children: 'children' }" tree-default-expand-all
|
||||||
|
show-search placeholder="请选择上级菜单" allow-clear tree-node-filter-prop="title" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="链接" name="url">
|
||||||
|
<a-input v-model:value="form.url" placeholder="请输入链接" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="所属位置" name="position">
|
||||||
|
<a-select v-model:value="form.position" placeholder="请选择所属位置" allow-clear>
|
||||||
|
<a-select-option value="top">顶部</a-select-option>
|
||||||
|
<a-select-option value="bottom">底部</a-select-option>
|
||||||
|
<a-select-option value="left">左侧</a-select-option>
|
||||||
|
<a-select-option value="right">右侧</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="图标" name="icon">
|
||||||
|
<a-input v-model:value="form.icon" placeholder="请输入图标" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="排序" name="sort">
|
||||||
|
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" placeholder="请输入排序" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="是否显示" name="is_show">
|
||||||
|
<a-switch v-model:checked="form.is_show" :checked-value="1" :un-checked-value="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="是否新窗口" name="is_blank">
|
||||||
|
<a-switch v-model:checked="form.is_blank" :checked-value="1" :un-checked-value="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
|
<a-switch v-model:checked="form.status" :checked-value="1" :un-checked-value="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="handleCancel">取 消</a-button>
|
||||||
|
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, reactive } from 'vue'
|
||||||
emits: ['success', 'closed'],
|
import { message } from 'ant-design-vue'
|
||||||
data(){
|
import systemApi from '@/api/system'
|
||||||
return {
|
|
||||||
mode: 'add',
|
const emit = defineEmits(['success', 'closed'])
|
||||||
titleMap: { add: '添加', edit: '编辑', show: '查看' },
|
|
||||||
form: {status: 1},
|
const mode = ref('add')
|
||||||
config: {
|
const titleMap = {
|
||||||
labelWidth: '120px',
|
add: '添加',
|
||||||
formItems: [
|
edit: '编辑',
|
||||||
{ name: 'title', label: '名称', type: 'string', options: {} },
|
show: '查看'
|
||||||
{ name: 'parent_id', label: '上级菜单', type: 'scSelectTree', options: {apiObj: this.$API.system.client.menu.list, props: {label: 'title', value: 'id'}} },
|
}
|
||||||
{ name: 'url', label: '链接', type: 'string' },
|
const visible = ref(false)
|
||||||
{ name: 'position', label: '所属位置', type: 'scSelect', options: {dic: 'client_menu_position'} },
|
const isSaving = ref(false)
|
||||||
{ name: 'icon', label: '图标', type: 'scIconSelect' },
|
|
||||||
{ name: 'sort', label: '排序', type: 'number', options: {} },
|
// 表单数据
|
||||||
{ name: 'is_show', label: '是否显示', type: 'boolean', options: {} },
|
const form = reactive({
|
||||||
{ name: 'is_blank', label: '是否新窗口', type: 'boolean', options: {} },
|
id: null,
|
||||||
{ name: 'status', label: '状态', type: 'boolean', options: {} }
|
title: '',
|
||||||
]
|
parent_id: null,
|
||||||
},
|
url: '',
|
||||||
rules: {
|
position: 'top',
|
||||||
title: [{ required: true, message: '请输入模型标题' }]
|
icon: '',
|
||||||
},
|
sort: 0,
|
||||||
apiObj: this.$API.system.client.menu.add,
|
is_show: 1,
|
||||||
visible: false,
|
is_blank: 0,
|
||||||
isSaveing: false
|
status: 1
|
||||||
}
|
})
|
||||||
},
|
|
||||||
mounted(){
|
// 表单引用
|
||||||
},
|
const dialogForm = ref()
|
||||||
methods:{
|
|
||||||
open(mode){
|
// 验证规则
|
||||||
this.mode = mode
|
const rules = {
|
||||||
this.visible = true
|
title: [
|
||||||
this.isSaveing = false
|
{ required: true, message: '请输入名称', trigger: 'blur' }
|
||||||
return this;
|
]
|
||||||
},
|
}
|
||||||
setData(data){
|
|
||||||
if(this.mode == 'edit'){
|
// 菜单树数据
|
||||||
this.form.id = data.id
|
const menuTreeData = ref([])
|
||||||
this.apiObj = this.$API.system.client.menu.edit
|
|
||||||
}else{
|
// 显示对话框
|
||||||
this.apiObj = this.$API.system.client.menu.add
|
const open = (openMode = 'add') => {
|
||||||
}
|
mode.value = openMode
|
||||||
this.isSaveing = false
|
visible.value = true
|
||||||
this.config.formItems.map(item => {
|
loadMenuTree()
|
||||||
this.form[item.name] = data[item.name]
|
return {
|
||||||
})
|
setData,
|
||||||
},
|
open,
|
||||||
onSuccess(res){
|
close
|
||||||
if(res.code == 1){
|
|
||||||
this.$emit('success', res)
|
|
||||||
this.visible = false
|
|
||||||
this.isSaveing = false
|
|
||||||
this.$message.success("操作成功!")
|
|
||||||
}else{
|
|
||||||
this.$message.error(res.message)
|
|
||||||
this.isSaveing = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载菜单树数据
|
||||||
|
const loadMenuTree = async () => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.client.menu.list.get({ is_tree: 1 })
|
||||||
|
if (res.code === 1) {
|
||||||
|
const list = res.data || []
|
||||||
|
menuTreeData.value = buildTree(list)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载菜单树失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建树结构
|
||||||
|
const buildTree = (list) => {
|
||||||
|
const map = {}
|
||||||
|
const roots = []
|
||||||
|
|
||||||
|
// 创建映射,以 id 作为键
|
||||||
|
list.forEach((item) => {
|
||||||
|
map[item.id] = { ...item, children: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建树,通过 parent_id 关联
|
||||||
|
list.forEach((item) => {
|
||||||
|
if (item.parent_id && map[item.parent_id]) {
|
||||||
|
map[item.parent_id].children.push(map[item.id])
|
||||||
|
} else if (!item.parent_id || item.parent_id === 0) {
|
||||||
|
roots.push(map[item.id])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单提交方法
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await dialogForm.value.validate()
|
||||||
|
isSaving.value = true
|
||||||
|
let res = {}
|
||||||
|
|
||||||
|
if (mode.value === 'add') {
|
||||||
|
res = await systemApi.client.menu.add.post(form)
|
||||||
|
} else {
|
||||||
|
res = await systemApi.client.menu.edit.post(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = false
|
||||||
|
if (res.code === 1) {
|
||||||
|
emit('success', form, mode.value)
|
||||||
|
visible.value = false
|
||||||
|
message.success('操作成功')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isSaving.value = false
|
||||||
|
console.error('表单验证失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单注入数据
|
||||||
|
const setData = (data) => {
|
||||||
|
if (mode.value === 'edit' || mode.value === 'show') {
|
||||||
|
form.id = data.id
|
||||||
|
form.title = data.title || ''
|
||||||
|
form.parent_id = data.parent_id
|
||||||
|
form.url = data.url || ''
|
||||||
|
form.position = data.position || 'top'
|
||||||
|
form.icon = data.icon || ''
|
||||||
|
form.sort = data.sort || 0
|
||||||
|
form.is_show = data.is_show ?? 1
|
||||||
|
form.is_blank = data.is_blank ?? 0
|
||||||
|
form.status = data.status ?? 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setData,
|
||||||
|
close
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
|
// 弹窗样式可根据需要添加
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,69 +1,140 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :title="titleMap[mode]" v-model="visible" size="50%" destroy-on-close @closed="$emit('closed')">
|
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :mask-closable="false"
|
||||||
<el-main>
|
:footer="null" @cancel="handleCancel">
|
||||||
<scForm v-model="form" :config="config" :apiObj="apiObj" @onSuccess="onSuccess"></scForm>
|
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
|
||||||
</el-main>
|
:wrapper-col="{ span: 18 }">
|
||||||
</el-dialog>
|
<a-form-item label="客户端名称" name="title">
|
||||||
|
<a-input v-model:value="form.title" placeholder="请输入客户端名称" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="客户端ID" name="app_id">
|
||||||
|
<a-input v-model:value="form.app_id" placeholder="客户端APPID" allow-clear :disabled="mode !== 'add'" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="客户端密匙" name="secret">
|
||||||
|
<a-input v-model:value="form.secret" placeholder="客户端密匙" allow-clear :disabled="mode !== 'add'" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
|
<a-radio-group v-model:value="form.status">
|
||||||
|
<a-radio :value="1">正常</a-radio>
|
||||||
|
<a-radio :value="0">禁用</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="handleCancel">取 消</a-button>
|
||||||
|
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, reactive } from 'vue'
|
||||||
emits: ['success', 'closed'],
|
import { message } from 'ant-design-vue'
|
||||||
data(){
|
import systemApi from '@/api/system'
|
||||||
return {
|
|
||||||
mode: 'add',
|
const emit = defineEmits(['success', 'closed'])
|
||||||
titleMap: { add: '添加', edit: '编辑', show: '查看' },
|
|
||||||
form: {status: 1},
|
const mode = ref('add')
|
||||||
config: {
|
const titleMap = {
|
||||||
labelWidth: '120px',
|
add: '添加',
|
||||||
formItems: [
|
edit: '编辑',
|
||||||
{ name: 'title', label: '客户端名称', type: 'string', options: {} },
|
show: '查看'
|
||||||
{ name: 'app_id', label: '客户端APPID', type: 'string', options: {}, disabled: true },
|
}
|
||||||
{ name: 'secret', label: '客户端密匙', type: 'string', options: {}, disabled: true },
|
const visible = ref(false)
|
||||||
{ name: 'status', label: '状态', type: 'boolean', options: {} }
|
const isSaveing = ref(false)
|
||||||
]
|
|
||||||
},
|
// 表单数据
|
||||||
rules: {
|
const form = reactive({
|
||||||
title: [{ required: true, message: '请输入模型标题' }]
|
id: null,
|
||||||
},
|
title: '',
|
||||||
apiObj: this.$API.system.client.add,
|
app_id: '',
|
||||||
visible: false,
|
secret: '',
|
||||||
isSaveing: false
|
status: 1
|
||||||
}
|
})
|
||||||
},
|
|
||||||
mounted(){
|
// 表单引用
|
||||||
},
|
const dialogForm = ref()
|
||||||
methods:{
|
|
||||||
open(mode){
|
// 验证规则
|
||||||
this.mode = mode
|
const rules = {
|
||||||
this.visible = true
|
title: [
|
||||||
this.isSaveing = false
|
{ required: true, message: '请输入客户端名称', trigger: 'blur' }
|
||||||
return this;
|
],
|
||||||
},
|
app_id: [
|
||||||
setData(data){
|
{ required: true, message: '请输入客户端ID', trigger: 'blur' }
|
||||||
if(this.mode == 'edit'){
|
],
|
||||||
this.form.id = data.id
|
secret: [
|
||||||
this.apiObj = this.$API.system.client.edit
|
{ required: true, message: '请输入客户端密匙', trigger: 'blur' }
|
||||||
}else{
|
]
|
||||||
this.apiObj = this.$API.system.client.add
|
}
|
||||||
}
|
|
||||||
this.isSaveing = false
|
// 显示对话框
|
||||||
this.config.formItems.map(item => {
|
const open = (openMode = 'add') => {
|
||||||
this.form[item.name] = data[item.name] ? data[item.name] : ''
|
mode.value = openMode
|
||||||
})
|
visible.value = true
|
||||||
},
|
return {
|
||||||
onSuccess(res){
|
setData,
|
||||||
if(res.code == 1){
|
open,
|
||||||
this.$emit('success', res)
|
close
|
||||||
this.visible = false
|
|
||||||
this.$message.success("操作成功!")
|
|
||||||
}else{
|
|
||||||
this.$message.error(res.message)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单提交方法
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await dialogForm.value.validate()
|
||||||
|
isSaveing.value = true
|
||||||
|
let res = {}
|
||||||
|
|
||||||
|
if (mode.value === 'add') {
|
||||||
|
res = await systemApi.client.add.post(form)
|
||||||
|
} else {
|
||||||
|
res = await systemApi.client.edit.post(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaveing.value = false
|
||||||
|
if (res.code === 1) {
|
||||||
|
emit('success', form, mode.value)
|
||||||
|
visible.value = false
|
||||||
|
message.success('操作成功')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isSaveing.value = false
|
||||||
|
console.error('表单验证失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单注入数据
|
||||||
|
const setData = (data) => {
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
form.id = data.id
|
||||||
|
form.title = data.title || ''
|
||||||
|
form.app_id = data.app_id || ''
|
||||||
|
form.secret = data.secret || ''
|
||||||
|
form.status = data.status ?? 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setData,
|
||||||
|
close
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
|
// 弹窗样式可根据需要添加
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,144 @@
|
|||||||
<template>
|
<template>
|
||||||
<div></div>
|
<a-modal :title="titleMap[mode]" :open="visible" :width="400" :destroy-on-close="true" :mask-closable="false"
|
||||||
|
:footer="null" @cancel="handleCancel">
|
||||||
|
<a-form :model="form" :rules="rules" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
||||||
|
<a-form-item label="编码" name="name">
|
||||||
|
<a-input v-model:value="form.name" placeholder="字典编码" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="字典名称" name="title">
|
||||||
|
<a-input v-model:value="form.title" placeholder="字典显示名称" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="父路径" name="parent_id">
|
||||||
|
<a-tree-select v-model:value="form.parent_id" :tree-data="dicData"
|
||||||
|
:field-names="{ label: 'title', value: 'id', children: 'children' }" tree-default-expand-all
|
||||||
|
show-search placeholder="请选择父路径" allow-clear tree-node-filter-prop="title" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="handleCancel">取 消</a-button>
|
||||||
|
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineOptions({
|
import { ref, reactive } from 'vue'
|
||||||
name: "systemDic"
|
import { message } from 'ant-design-vue'
|
||||||
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'closed'])
|
||||||
|
|
||||||
|
const mode = ref('add')
|
||||||
|
const titleMap = {
|
||||||
|
add: '新增字典分类',
|
||||||
|
edit: '编辑字典分类'
|
||||||
|
}
|
||||||
|
const visible = ref(false)
|
||||||
|
const isSaveing = ref(false)
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const form = reactive({
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
name: '',
|
||||||
|
parent_id: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const dialogForm = ref()
|
||||||
|
|
||||||
|
// 验证规则
|
||||||
|
const rules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入编码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入字典名称', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字典树数据
|
||||||
|
const dicData = ref([])
|
||||||
|
|
||||||
|
// 显示对话框
|
||||||
|
const open = (openMode = 'add') => {
|
||||||
|
mode.value = openMode
|
||||||
|
visible.value = true
|
||||||
|
loadDic()
|
||||||
|
return {
|
||||||
|
setData,
|
||||||
|
open,
|
||||||
|
close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载字典列表
|
||||||
|
const loadDic = async () => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.dictionary.category.get({ is_tree: 1 })
|
||||||
|
if (res.code === 1) {
|
||||||
|
dicData.value = res.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载字典列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单提交方法
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await dialogForm.value.validate()
|
||||||
|
isSaveing.value = true
|
||||||
|
let res = {}
|
||||||
|
form.parent_id = form.parent_id || 0
|
||||||
|
|
||||||
|
if (mode.value === 'add') {
|
||||||
|
res = await systemApi.dictionary.addcate.post(form)
|
||||||
|
} else {
|
||||||
|
res = await systemApi.dictionary.editcate.post(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaveing.value = false
|
||||||
|
if (res.code === 1) {
|
||||||
|
emit('success', form, mode.value)
|
||||||
|
visible.value = false
|
||||||
|
message.success('操作成功')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isSaveing.value = false
|
||||||
|
console.error('表单验证失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单注入数据
|
||||||
|
const setData = (data) => {
|
||||||
|
form.id = data.id
|
||||||
|
form.title = data.title
|
||||||
|
form.name = data.name
|
||||||
|
form.parent_id = data.parent_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setData,
|
||||||
|
close
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// 弹窗样式可根据需要添加
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,460 @@
|
|||||||
<template>
|
<template>
|
||||||
<div></div>
|
<div class="pages dic-page">
|
||||||
|
<div class="left-box">
|
||||||
|
<div class="header">
|
||||||
|
<a-input v-model:value="dicFilterText" placeholder="搜索字典..." allow-clear @change="handleDicSearch">
|
||||||
|
<template #prefix>
|
||||||
|
<search-outlined style="color: rgba(0, 0, 0, 0.45)" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<a-tree v-model:selectedKeys="selectedDicKeys" :tree-data="filteredDicList"
|
||||||
|
:field-names="{ title: 'title', key: 'id', children: 'children' }" show-line @select="onDicSelect">
|
||||||
|
<template #title="{ title, name }">
|
||||||
|
<div class="custom-tree-node">
|
||||||
|
<span class="label">{{ title }}</span>
|
||||||
|
<span class="code">{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #icon="{ dataRef }">
|
||||||
|
<folder-outlined v-if="dataRef.children" />
|
||||||
|
<file-outlined v-else />
|
||||||
|
</template>
|
||||||
|
</a-tree>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<a-button type="primary" @click="addDic" block>
|
||||||
|
<template #icon><plus-outlined /></template>
|
||||||
|
字典分类
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right-box">
|
||||||
|
<div class="tool-bar">
|
||||||
|
<div class="left-panel">
|
||||||
|
<a-button type="primary" @click="addInfo">
|
||||||
|
<template #icon><plus-outlined /></template>
|
||||||
|
新增项
|
||||||
|
</a-button>
|
||||||
|
<a-button danger @click="batchDel" :disabled="!selectedRowKeys.length">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
批量删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-content">
|
||||||
|
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
||||||
|
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
||||||
|
:row-selection="rowSelection">
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="record.status === 1 ? 'success' : 'default'">
|
||||||
|
{{ record.status === 1 ? '是' : '否' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template #action="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="tableEdit(record)">编辑</a-button>
|
||||||
|
<a-popconfirm title="确定删除吗?" @confirm="tableDel(record)">
|
||||||
|
<a-button type="link" size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</scTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 字典分类弹窗 -->
|
||||||
|
<dic-dialog v-if="dialog.dic" ref="dicDialogRef" @success="handleDicSuccess" @closed="dialog.dic = false" />
|
||||||
|
|
||||||
|
<!-- 字典明细弹窗 -->
|
||||||
|
<list-dialog v-if="dialog.list" ref="listDialogRef" @success="handleListSuccess" @closed="dialog.list = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { SearchOutlined, RedoOutlined, DeleteOutlined, PlusOutlined, FolderOutlined, FileOutlined, EditOutlined } from '@ant-design/icons-vue'
|
||||||
|
import scTable from '@/components/scTable/index.vue'
|
||||||
|
import dicDialog from './dic.vue'
|
||||||
|
import listDialog from './list.vue'
|
||||||
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "systemDic"
|
name: 'systemDic'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格引用
|
||||||
|
const tableRef = ref(null)
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const dialog = reactive({
|
||||||
|
dic: false,
|
||||||
|
list: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗引用
|
||||||
|
const dicDialogRef = ref(null)
|
||||||
|
const listDialogRef = ref(null)
|
||||||
|
|
||||||
|
// 字典树数据
|
||||||
|
const dicList = ref([])
|
||||||
|
const filteredDicList = ref([])
|
||||||
|
const selectedDicKeys = ref([])
|
||||||
|
const dicFilterText = ref('')
|
||||||
|
|
||||||
|
// 选中的行
|
||||||
|
const selectedRowKeys = ref([])
|
||||||
|
|
||||||
|
// 行选择配置
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
selectedRowKeys: selectedRowKeys.value,
|
||||||
|
onChange: (selectedKeys) => {
|
||||||
|
selectedRowKeys.value = selectedKeys
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 当前选中的字典
|
||||||
|
const currentDic = ref(null)
|
||||||
|
|
||||||
|
// 表格数据
|
||||||
|
const tableData = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
pageSizeOptions: ['20', '50', '100', '200']
|
||||||
|
})
|
||||||
|
|
||||||
|
// 行key
|
||||||
|
const rowKey = 'id'
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns = [
|
||||||
|
{ title: '名称', dataIndex: 'title', key: 'title', width: 200 },
|
||||||
|
{ title: '键值', dataIndex: 'values', key: 'values', width: 150 },
|
||||||
|
{ title: '是否有效', dataIndex: 'status', key: 'status', width: 100, slot: 'status' },
|
||||||
|
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100 },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||||
|
{ title: '操作', dataIndex: 'action', key: 'action', width: 150, align: 'center', slot: 'action', fixed: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加载字典树
|
||||||
|
const loadDicTree = async () => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.dictionary.category.get({ is_tree: 1 })
|
||||||
|
if (res.code === 1) {
|
||||||
|
dicList.value = res.data || []
|
||||||
|
filteredDicList.value = res.data || []
|
||||||
|
|
||||||
|
// 获取第一个节点,设置选中 & 加载明细列表
|
||||||
|
const firstNode = dicList.value[0]
|
||||||
|
if (firstNode) {
|
||||||
|
selectedDicKeys.value = [firstNode.id]
|
||||||
|
currentDic.value = firstNode
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载字典树失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字典搜索
|
||||||
|
const handleDicSearch = (e) => {
|
||||||
|
const keyword = e.target?.value || ''
|
||||||
|
dicFilterText.value = keyword
|
||||||
|
if (!keyword) {
|
||||||
|
filteredDicList.value = dicList.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归过滤字典树
|
||||||
|
const filterTree = (nodes) => {
|
||||||
|
return nodes.reduce((acc, node) => {
|
||||||
|
const targetText = (node.title || '') + (node.name || '')
|
||||||
|
const isMatch = targetText.toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
const filteredChildren = node.children ? filterTree(node.children) : []
|
||||||
|
|
||||||
|
if (isMatch || filteredChildren.length > 0) {
|
||||||
|
acc.push({
|
||||||
|
...node,
|
||||||
|
children: filteredChildren.length > 0 ? filteredChildren : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredDicList.value = filterTree(dicList.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载字典明细列表数据
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!currentDic.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.current,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
group_id: currentDic.value.id
|
||||||
|
}
|
||||||
|
const res = await systemApi.dictionary.list.get(params)
|
||||||
|
if (res.code === 1) {
|
||||||
|
tableData.value = res.data?.data || []
|
||||||
|
pagination.total = res.data?.total || 0
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '加载数据失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载字典明细列表失败:', error)
|
||||||
|
message.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化处理
|
||||||
|
const handlePaginationChange = ({ page, pageSize }) => {
|
||||||
|
pagination.current = page
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字典选择事件
|
||||||
|
const onDicSelect = (selectedKeys, info) => {
|
||||||
|
if (selectedKeys && selectedKeys.length > 0) {
|
||||||
|
const selectedId = selectedKeys[0]
|
||||||
|
currentDic.value = findDicById(filteredDicList.value, selectedId)
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据ID查找字典
|
||||||
|
const findDicById = (nodes, id) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === id) return node
|
||||||
|
if (node.children) {
|
||||||
|
const found = findDicById(node.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增字典分类
|
||||||
|
const addDic = () => {
|
||||||
|
dialog.dic = true
|
||||||
|
setTimeout(() => {
|
||||||
|
dicDialogRef.value?.open('add')
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑字典分类
|
||||||
|
const dicEdit = (data) => {
|
||||||
|
dialog.dic = true
|
||||||
|
setTimeout(() => {
|
||||||
|
dicDialogRef.value?.open('edit').setData(data)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除字典分类
|
||||||
|
const dicDel = (node, data) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定删除 ${data.name} 项吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await systemApi.dictionary.delCate.post({ id: data.id })
|
||||||
|
message.success('操作成功')
|
||||||
|
loadDicTree()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除字典分类失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增字典明细
|
||||||
|
const addInfo = () => {
|
||||||
|
if (!currentDic.value) {
|
||||||
|
message.warning('请先选择字典分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.list = true
|
||||||
|
setTimeout(() => {
|
||||||
|
listDialogRef.value?.open('add').setData({ dic_type: currentDic.value.code })
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑字典明细
|
||||||
|
const tableEdit = (record) => {
|
||||||
|
dialog.list = true
|
||||||
|
setTimeout(() => {
|
||||||
|
listDialogRef.value?.open('edit').setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除字典明细
|
||||||
|
const tableDel = async (record) => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.dictionary.delete.post({ id: record.id })
|
||||||
|
if (res.code === 1) {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除字典明细失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除字典明细
|
||||||
|
const batchDel = () => {
|
||||||
|
if (selectedRowKeys.value.length === 0) {
|
||||||
|
message.warning('请先选择要删除的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认批量删除',
|
||||||
|
content: `确定要删除选中的 ${selectedRowKeys.value.length} 项吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const promises = selectedRowKeys.value.map(id => systemApi.dictionary.delete.post({ id }))
|
||||||
|
await Promise.all(promises)
|
||||||
|
message.success('操作成功')
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字典分类成功回调
|
||||||
|
const handleDicSuccess = (data, mode) => {
|
||||||
|
if (mode === 'add') {
|
||||||
|
loadDicTree()
|
||||||
|
} else if (mode === 'edit') {
|
||||||
|
loadDicTree()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字典明细成功回调
|
||||||
|
const handleListSuccess = () => {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadDicTree()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dic-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.left-box {
|
||||||
|
width: 300px;
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
:deep(.ant-tree) {
|
||||||
|
.ant-tree-treenode {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.code {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.do {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.table-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tree-node {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
padding-right: 24px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.do {
|
||||||
|
display: none;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.ant-btn-link {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,109 +1,163 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :title="titleMap[mode]" v-model="visible" :width="400" destroy-on-close @closed="$emit('closed')">
|
<a-modal :title="titleMap[mode]" :open="visible" :width="450" :destroy-on-close="true" :mask-closable="false"
|
||||||
<el-form :model="form" :rules="rules" ref="dialogForm" label-width="100px" label-position="left">
|
:footer="null" @cancel="handleCancel">
|
||||||
<el-form-item label="所属字典" prop="group_id">
|
<a-form :model="form" :rules="rules" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
||||||
<el-cascader v-model="form.group_id" :options="dic" :props="dicProps" :show-all-levels="false" clearable></el-cascader>
|
<a-form-item label="所属字典" name="group_id">
|
||||||
</el-form-item>
|
<a-tree-select v-model:value="form.group_id" :tree-data="dicData"
|
||||||
<el-form-item label="项名称" prop="title">
|
:field-names="{ label: 'title', value: 'id', children: 'children' }" tree-default-expand-all
|
||||||
<el-input v-model="form.title" clearable></el-input>
|
show-search placeholder="请选择所属字典" allow-clear tree-node-filter-prop="title" />
|
||||||
</el-form-item>
|
</a-form-item>
|
||||||
<el-form-item label="键值" prop="values">
|
<a-form-item label="项名称" name="title">
|
||||||
<el-input v-model="form.values" clearable></el-input>
|
<a-input v-model:value="form.title" placeholder="请输入项名称" allow-clear />
|
||||||
</el-form-item>
|
</a-form-item>
|
||||||
<el-form-item label="排序" prop="sort">
|
<a-form-item label="键值" name="values">
|
||||||
<el-input v-model="form.sort" clearable></el-input>
|
<a-input v-model:value="form.values" placeholder="请输入键值" allow-clear />
|
||||||
</el-form-item>
|
</a-form-item>
|
||||||
<el-form-item label="是否有效" prop="status">
|
<a-form-item label="排序" name="sort">
|
||||||
<el-switch v-model="form.status" :active-value="'1'" :inactive-value="'0'"></el-switch>
|
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" placeholder="请输入排序" />
|
||||||
</el-form-item>
|
</a-form-item>
|
||||||
</el-form>
|
<a-form-item label="是否有效" name="status">
|
||||||
<template #footer>
|
<a-radio-group v-model:value="form.status">
|
||||||
<el-button @click="visible=false" >取 消</el-button>
|
<a-radio :value="1">是</a-radio>
|
||||||
<el-button type="primary" :loading="isSaveing" @click="submit()">保 存</el-button>
|
<a-radio :value="0">否</a-radio>
|
||||||
</template>
|
</a-radio-group>
|
||||||
</el-dialog>
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<template #footer>
|
||||||
|
<a-button @click="handleCancel">取 消</a-button>
|
||||||
|
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, reactive } from 'vue'
|
||||||
emits: ['success', 'closed'],
|
import { message } from 'ant-design-vue'
|
||||||
data() {
|
import systemApi from '@/api/system'
|
||||||
return {
|
|
||||||
mode: "add",
|
const emit = defineEmits(['success', 'closed'])
|
||||||
titleMap: {
|
|
||||||
add: '新增项',
|
const mode = ref('add')
|
||||||
edit: '编辑项'
|
const titleMap = {
|
||||||
},
|
add: '新增项',
|
||||||
visible: false,
|
edit: '编辑项'
|
||||||
isSaveing: false,
|
}
|
||||||
form: {id: "", group_id: "", title: "", values: "", sort: 0, status: "1"},
|
const visible = ref(false)
|
||||||
rules: {
|
const isSaveing = ref(false)
|
||||||
group_id: [{required: true, message: '请选择所属字典'}],
|
|
||||||
title: [{required: true, message: '请输入项名称'}],
|
// 表单数据
|
||||||
values: [{required: true, message: '请输入键值'}]
|
const form = reactive({
|
||||||
},
|
id: '',
|
||||||
dic: [],
|
group_id: '',
|
||||||
dicProps: {
|
title: '',
|
||||||
value: "id",
|
values: '',
|
||||||
label: "title",
|
sort: 0,
|
||||||
emitPath: false,
|
status: 1
|
||||||
checkStrictly: true
|
})
|
||||||
}
|
|
||||||
}
|
// 表单引用
|
||||||
},
|
const dialogForm = ref()
|
||||||
mounted() {
|
|
||||||
if(this.params){
|
// 验证规则
|
||||||
this.form.dic = this.params.code
|
const rules = {
|
||||||
}
|
group_id: [
|
||||||
this.getDic()
|
{ required: true, message: '请选择所属字典', trigger: 'change' }
|
||||||
},
|
],
|
||||||
methods: {
|
title: [
|
||||||
//显示
|
{ required: true, message: '请输入项名称', trigger: 'blur' }
|
||||||
open(mode='add'){
|
],
|
||||||
this.mode = mode;
|
values: [
|
||||||
this.visible = true;
|
{ required: true, message: '请输入键值', trigger: 'blur' }
|
||||||
return this;
|
]
|
||||||
},
|
}
|
||||||
//获取字典列表
|
|
||||||
async getDic(){
|
// 字典树数据
|
||||||
var res = await this.$API.system.dictionary.category.get({is_tree: 1});
|
const dicData = ref([])
|
||||||
this.dic = res.data;
|
|
||||||
},
|
// 显示对话框
|
||||||
//表单提交方法
|
const open = (openMode = 'add') => {
|
||||||
submit(){
|
mode.value = openMode
|
||||||
this.$refs.dialogForm.validate(async (valid) => {
|
visible.value = true
|
||||||
if (valid) {
|
loadDic()
|
||||||
this.isSaveing = true;
|
return {
|
||||||
var res;
|
setData,
|
||||||
if(this.mode == 'add'){
|
open,
|
||||||
res = await this.$API.system.dictionary.add.post(this.form);
|
close
|
||||||
}else{
|
|
||||||
res = await this.$API.system.dictionary.edit.post(this.form);
|
|
||||||
}
|
|
||||||
this.isSaveing = false;
|
|
||||||
if(res.code == 1){
|
|
||||||
this.$emit('success', this.form, this.mode)
|
|
||||||
this.visible = false;
|
|
||||||
this.$message.success("操作成功")
|
|
||||||
}else{
|
|
||||||
this.$alert(res.message, "提示", {type: 'error'})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
//表单注入数据
|
|
||||||
setData(data){
|
|
||||||
this.form.id = data.id
|
|
||||||
this.form.title = data.title
|
|
||||||
this.form.values = data.values
|
|
||||||
this.form.sort = data.sort || 0
|
|
||||||
this.form.status = data.status+"" || "1"
|
|
||||||
this.form.group_id = parseInt(data.group_id)
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载字典列表
|
||||||
|
const loadDic = async () => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.dictionary.category.get({ is_tree: 1 })
|
||||||
|
if (res.code === 1) {
|
||||||
|
dicData.value = res.data || []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载字典列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单提交方法
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await dialogForm.value.validate()
|
||||||
|
isSaveing.value = true
|
||||||
|
let res
|
||||||
|
|
||||||
|
if (mode.value === 'add') {
|
||||||
|
res = await systemApi.dictionary.add.post(form)
|
||||||
|
} else {
|
||||||
|
res = await systemApi.dictionary.edit.post(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaveing.value = false
|
||||||
|
if (res.code === 1) {
|
||||||
|
emit('success', form, mode.value)
|
||||||
|
visible.value = false
|
||||||
|
message.success('操作成功')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isSaveing.value = false
|
||||||
|
console.error('表单验证失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单注入数据
|
||||||
|
const setData = (data) => {
|
||||||
|
if (data.dic_type) {
|
||||||
|
form.dic_type = data.dic_type
|
||||||
|
}
|
||||||
|
form.id = data.id
|
||||||
|
form.title = data.title
|
||||||
|
form.values = data.values
|
||||||
|
form.sort = data.sort || 0
|
||||||
|
form.status = data.status ?? 1
|
||||||
|
form.group_id = data.group_id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setData,
|
||||||
|
close
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped lang="scss">
|
||||||
</style>
|
// 弹窗样式可根据需要添加
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,53 +2,309 @@
|
|||||||
<div class="pages log-page">
|
<div class="pages log-page">
|
||||||
<div class="left-box">
|
<div class="left-box">
|
||||||
<a-card>
|
<a-card>
|
||||||
<a-tree show-line showIcon switcherIcon default-expand-all :tree-data="treeData "@select="onSelect" />
|
<a-tree v-model:selectedKeys="selectedKeys" show-line switcher-icon default-expand-all
|
||||||
|
:tree-data="treeData" @select="onSelect">
|
||||||
|
<template #icon="{ dataRef }">
|
||||||
|
<folder-outlined v-if="dataRef.children" />
|
||||||
|
<file-outlined v-else />
|
||||||
|
</template>
|
||||||
|
</a-tree>
|
||||||
</a-card>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-box">
|
<div class="right-box">
|
||||||
<div class="right-warp">
|
<div class="tool-bar">
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="dataList.data" :loading="loading"></scTable>
|
<div class="left-panel">
|
||||||
|
<a-form layout="inline" :model="searchForm">
|
||||||
|
<a-form-item>
|
||||||
|
<a-input v-model:value="searchForm.title" placeholder="请输入请求名称" allow-clear
|
||||||
|
style="width: 160px" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-range-picker v-model:value="searchForm.created_at" :placeholder="['开始时间', '结束时间']"
|
||||||
|
show-time format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 340px" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><search-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><redo-outlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<a-button danger @click="handleClearLog" :disabled="!selectedRowKeys.length">
|
||||||
|
<template #icon><delete-outlined /></template>
|
||||||
|
清空日志
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-content">
|
||||||
|
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
||||||
|
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
||||||
|
:row-selection="rowSelection">
|
||||||
|
<template #method="{ record }">
|
||||||
|
<a-tag :color="getMethodColor(record.method)">{{ record.method }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template #action="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
|
||||||
|
<a-popconfirm title="确定删除该日志吗?" @confirm="handleDelete(record)">
|
||||||
|
<a-button type="link" size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</scTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志详情弹窗 -->
|
||||||
|
<log-info-dialog v-if="dialog.info" ref="infoDialogRef" @closed="dialog.info = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { SearchOutlined, RedoOutlined, DeleteOutlined, FolderOutlined, FileOutlined } from '@ant-design/icons-vue'
|
||||||
import scTable from '@/components/scTable/index.vue'
|
import scTable from '@/components/scTable/index.vue'
|
||||||
import system from '@/api/system'
|
import logInfoDialog from './info.vue'
|
||||||
import { onMounted, ref } from 'vue';
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'systemLog'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格引用
|
||||||
|
const tableRef = ref(null)
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const dialog = reactive({
|
||||||
|
info: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗引用
|
||||||
|
const infoDialogRef = ref(null)
|
||||||
|
|
||||||
|
// 树数据
|
||||||
const treeData = ref([
|
const treeData = ref([
|
||||||
{title: '请求分类', key: 'request', children: [
|
{
|
||||||
{title: 'GET请求', key: 'get'},
|
title: '请求分类',
|
||||||
{title: 'POST请求', key: 'post'},
|
key: 'request',
|
||||||
{title: 'PUT请求', key: 'put'},
|
children: [
|
||||||
{title: 'DELETE请求', key: 'delete'}
|
{ title: 'GET请求', key: 'get' },
|
||||||
]},
|
{ title: 'POST请求', key: 'post' },
|
||||||
{title: '响应状态', key: 'response', children: []}
|
{ title: 'PUT请求', key: 'put' },
|
||||||
|
{ title: 'DELETE请求', key: 'delete' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '响应状态',
|
||||||
|
key: 'response',
|
||||||
|
children: [
|
||||||
|
{ title: '成功 (2xx)', key: 'success' },
|
||||||
|
{ title: '重定向 (3xx)', key: 'redirect' },
|
||||||
|
{ title: '客户端错误 (4xx)', key: 'client_error' },
|
||||||
|
{ title: '服务器错误 (5xx)', key: 'server_error' }
|
||||||
|
]
|
||||||
|
}
|
||||||
])
|
])
|
||||||
const onSelect = (selectedKeys, info) => {
|
|
||||||
console.log(selectedKeys, info)
|
// 选中的树节点
|
||||||
|
const selectedKeys = ref([])
|
||||||
|
|
||||||
|
// 选中的行
|
||||||
|
const selectedRowKeys = ref([])
|
||||||
|
|
||||||
|
// 行选择配置
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
selectedRowKeys: selectedRowKeys.value,
|
||||||
|
onChange: (selectedKeys) => {
|
||||||
|
selectedRowKeys.value = selectedKeys
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 搜索表单
|
||||||
|
const searchForm = reactive({
|
||||||
|
title: '',
|
||||||
|
method: null,
|
||||||
|
status: null,
|
||||||
|
created_at: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格数据
|
||||||
|
const tableData = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
pageSizeOptions: ['20', '50', '100', '200']
|
||||||
|
})
|
||||||
|
|
||||||
|
// 行key
|
||||||
|
const rowKey = 'id'
|
||||||
|
|
||||||
|
// 获取请求方法颜色
|
||||||
|
const getMethodColor = (method) => {
|
||||||
|
const colorMap = {
|
||||||
|
'GET': 'green',
|
||||||
|
'POST': 'blue',
|
||||||
|
'PUT': 'orange',
|
||||||
|
'DELETE': 'red'
|
||||||
|
}
|
||||||
|
return colorMap[method] || 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
let loading = ref(true)
|
// 表格列配置
|
||||||
let search = ref({page: 1, limit: 30})
|
const columns = [
|
||||||
let dataList = ref({})
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||||
const columns = ref([
|
{ title: '请求名称', dataIndex: 'title', key: 'title', width: 200 },
|
||||||
{dataIndex: 'id', title: 'ID', width: 100},
|
{ title: '请求类型', dataIndex: 'method', key: 'method', width: 100, slot: 'method' },
|
||||||
{dataIndex: 'title', title: '请求名称'},
|
{ title: '请求地址', dataIndex: 'url', key: 'url', ellipsis: true },
|
||||||
{dataIndex: 'method', title: '请求类型', width: 100},
|
{ title: '状态码', dataIndex: 'status', key: 'status', width: 100 },
|
||||||
{dataIndex: 'url', title: '请求地址'},
|
{ title: '客户端IP', dataIndex: 'client_ip', key: 'client_ip', width: 140 },
|
||||||
{dataIndex: 'created_at', title: '请求时间', width: 180},
|
{ title: '请求时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||||
])
|
{ title: '操作', dataIndex: 'action', key: 'action', width: 150, align: 'center', slot: 'action', fixed: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加载日志列表数据
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
let res = await system.log.list.get(search.value)
|
loading.value = true
|
||||||
if (res.code == 1) {
|
try {
|
||||||
dataList.value = res.data
|
const params = {
|
||||||
loading = false
|
page: pagination.current,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
title: searchForm.title,
|
||||||
|
method: searchForm.method,
|
||||||
|
status: searchForm.status,
|
||||||
|
created_at: searchForm.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await systemApi.log.list.get(params)
|
||||||
|
if (res.code === 1) {
|
||||||
|
tableData.value = res.data?.data || []
|
||||||
|
pagination.total = res.data?.total || 0
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '加载数据失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载日志列表失败:', error)
|
||||||
|
message.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分页变化处理
|
||||||
|
const handlePaginationChange = ({ page, pageSize }) => {
|
||||||
|
pagination.current = page
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 树节点选择事件
|
||||||
|
const onSelect = (selectedKeys, info) => {
|
||||||
|
const key = selectedKeys[0]
|
||||||
|
if (key) {
|
||||||
|
// 根据选中的树节点过滤数据
|
||||||
|
if (['get', 'post', 'put', 'delete'].includes(key)) {
|
||||||
|
searchForm.method = key.toUpperCase()
|
||||||
|
} else if (['success', 'redirect', 'client_error', 'server_error'].includes(key)) {
|
||||||
|
const statusMap = {
|
||||||
|
'success': '2xx',
|
||||||
|
'redirect': '3xx',
|
||||||
|
'client_error': '4xx',
|
||||||
|
'server_error': '5xx'
|
||||||
|
}
|
||||||
|
searchForm.status = statusMap[key]
|
||||||
|
} else {
|
||||||
|
searchForm.method = null
|
||||||
|
searchForm.status = null
|
||||||
|
}
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.title = ''
|
||||||
|
searchForm.method = null
|
||||||
|
searchForm.status = null
|
||||||
|
searchForm.timeRange = []
|
||||||
|
selectedKeys.value = []
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看日志详情
|
||||||
|
const handleView = (record) => {
|
||||||
|
dialog.info = true
|
||||||
|
setTimeout(() => {
|
||||||
|
infoDialogRef.value?.open().setData(record)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除日志
|
||||||
|
const handleDelete = async (record) => {
|
||||||
|
try {
|
||||||
|
const res = await systemApi.log.delete.post({ id: record.id })
|
||||||
|
if (res.code === 1) {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除日志失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空日志
|
||||||
|
const handleClearLog = () => {
|
||||||
|
if (selectedRowKeys.value.length === 0) {
|
||||||
|
message.warning('请先选择要删除的日志')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认清空日志',
|
||||||
|
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条日志吗?`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
// 批量删除
|
||||||
|
const promises = selectedRowKeys.value.map(id => systemApi.log.delete.post({ id }))
|
||||||
|
await Promise.all(promises)
|
||||||
|
message.success('清空成功')
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清空日志失败:', error)
|
||||||
|
message.error('清空失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
@@ -56,18 +312,41 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.log-page {
|
.log-page {
|
||||||
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
.left-box {
|
.left-box {
|
||||||
width: 240px;
|
width: 260px;
|
||||||
height: 100%;
|
border-right: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(.ant-card) {
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-box {
|
.right-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
.right-warp{
|
overflow: hidden;
|
||||||
|
|
||||||
|
.table-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,114 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<a-modal :open="visible" :title="'日志详情'" :width="700" :footer="null" @cancel="handleCancel">
|
||||||
<el-main style="padding:0 20px;">
|
<a-descriptions :column="1" bordered size="small">
|
||||||
<el-descriptions :column="1" border>
|
<a-descriptions-item label="操作人">{{ data.user?.nickname || '-' }}</a-descriptions-item>
|
||||||
<el-descriptions-item label="操作人">{{data.user?.nickname}}</el-descriptions-item>
|
<a-descriptions-item label="客户端IP">{{ data.client_ip || '-' }}</a-descriptions-item>
|
||||||
<el-descriptions-item label="客户端IP">{{data.client_ip}}</el-descriptions-item>
|
<a-descriptions-item label="请求接口">{{ data.url || '-' }}</a-descriptions-item>
|
||||||
<el-descriptions-item label="请求接口">{{data.url}}</el-descriptions-item>
|
<a-descriptions-item label="请求方法">
|
||||||
<el-descriptions-item label="请求方法">{{data.method}}</el-descriptions-item>
|
<a-tag :color="getMethodColor(data.method)">{{ data.method }}</a-tag>
|
||||||
<el-descriptions-item label="状态代码">{{data.status}}</el-descriptions-item>
|
</a-descriptions-item>
|
||||||
<el-descriptions-item label="日志名">{{data.title}}</el-descriptions-item>
|
<a-descriptions-item label="状态代码">
|
||||||
<el-descriptions-item label="日志时间">{{data.created_at}}</el-descriptions-item>
|
<a-tag :color="getStatusColor(data.status)">{{ data.status }}</a-tag>
|
||||||
</el-descriptions>
|
</a-descriptions-item>
|
||||||
<el-collapse v-model="activeNames" style="margin-top: 20px;">
|
<a-descriptions-item label="日志名">{{ data.title || '-' }}</a-descriptions-item>
|
||||||
<el-collapse-item title="请求参数" name="1">
|
<a-descriptions-item label="日志时间">{{ data.created_at || '-' }}</a-descriptions-item>
|
||||||
<div class="code">{{data.data}}</div>
|
</a-descriptions>
|
||||||
</el-collapse-item>
|
<a-collapse v-model:activeKey="activeNames" style="margin-top: 20px" ghost>
|
||||||
<el-collapse-item title="详细" name="2">
|
<a-collapse-panel key="1" header="请求参数">
|
||||||
<div class="code">
|
<div class="code">{{ data.data || '无' }}</div>
|
||||||
{{data.browser}}
|
</a-collapse-panel>
|
||||||
</div>
|
<a-collapse-panel key="2" header="浏览器信息">
|
||||||
</el-collapse-item>
|
<div class="code">{{ data.browser || '-' }}</div>
|
||||||
</el-collapse>
|
</a-collapse-panel>
|
||||||
</el-main>
|
</a-collapse>
|
||||||
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, reactive } from 'vue'
|
||||||
data() {
|
|
||||||
return {
|
const emit = defineEmits(['closed'])
|
||||||
data: {},
|
|
||||||
activeNames: ['1'],
|
const visible = ref(false)
|
||||||
typeMap: {
|
const activeNames = ref(['1'])
|
||||||
'info': "info",
|
|
||||||
'warn': "warning",
|
const data = reactive({
|
||||||
'error': "error"
|
user: {},
|
||||||
}
|
client_ip: '',
|
||||||
}
|
url: '',
|
||||||
},
|
method: '',
|
||||||
methods: {
|
status: '',
|
||||||
setData(data){
|
title: '',
|
||||||
this.data = data
|
created_at: '',
|
||||||
}
|
data: '',
|
||||||
}
|
browser: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取请求方法颜色
|
||||||
|
const getMethodColor = (method) => {
|
||||||
|
const colorMap = {
|
||||||
|
'GET': 'green',
|
||||||
|
'POST': 'blue',
|
||||||
|
'PUT': 'orange',
|
||||||
|
'DELETE': 'red'
|
||||||
}
|
}
|
||||||
|
return colorMap[method] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态码颜色
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
if (!status) return 'default'
|
||||||
|
if (status >= 200 && status < 300) return 'success'
|
||||||
|
if (status >= 300 && status < 400) return 'warning'
|
||||||
|
if (status >= 400 && status < 500) return 'error'
|
||||||
|
if (status >= 500) return 'error'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示对话框
|
||||||
|
const open = () => {
|
||||||
|
visible.value = true
|
||||||
|
return {
|
||||||
|
setData,
|
||||||
|
open,
|
||||||
|
close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('closed')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置数据
|
||||||
|
const setData = (logData) => {
|
||||||
|
Object.assign(data, logData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setData,
|
||||||
|
close
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.code {background: #848484;padding:15px;color: #fff;font-size: 12px;border-radius: 4px;}
|
.code {
|
||||||
|
background: #2d2d2d;
|
||||||
|
padding: 15px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,135 +1,211 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pages system-setting">
|
<div class="pages system-setting">
|
||||||
|
<!-- 头部 -->
|
||||||
<!-- Tab 页签 -->
|
<div class="page-header">
|
||||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
<div class="header-left">
|
||||||
<template #rightExtra>
|
<a-typography-title :level="4" class="page-title">
|
||||||
|
<SettingOutlined />
|
||||||
|
系统设置
|
||||||
|
</a-typography-title>
|
||||||
|
<a-typography-text type="secondary" class="page-subtitle">
|
||||||
|
管理系统各项配置参数
|
||||||
|
</a-typography-text>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
<a-button type="primary" @click="handleAddConfig">
|
<a-button type="primary" @click="handleAddConfig">
|
||||||
<PlusOutlined />
|
<template #icon><PlusOutlined /></template>
|
||||||
{{ $t('common.addConfig') }}
|
新增配置
|
||||||
</a-button>
|
</a-button>
|
||||||
</template>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-tab-pane v-for="category in categories" :key="category.name" :tab="category.title">
|
<!-- 主要内容区域 -->
|
||||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 16 }" class="setting-form">
|
<div class="page-content">
|
||||||
<a-form-item v-for="field in fields.filter(f => f.category === category.name)" :key="field.name"
|
<a-card :bordered="false" class="setting-card">
|
||||||
:label="field.title">
|
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange" class="setting-tabs">
|
||||||
<div class="form-item-content">
|
<a-tab-pane v-for="category in categories" :key="category.name" :tab="category.title">
|
||||||
<div class="form-input-wrapper">
|
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 16 }" class="setting-form">
|
||||||
<!-- 文本输入 -->
|
<a-form-item
|
||||||
<a-input v-if="field.type === 'text'" v-model:value="formData[field.name]"
|
v-for="field in fields.filter(f => f.category === category.name)"
|
||||||
:placeholder="field.placeholder || $t('common.pleaseEnter')" />
|
:key="field.name"
|
||||||
<!-- 文本域 -->
|
:label="field.title"
|
||||||
<a-textarea v-else-if="field.type === 'textarea'"
|
:required="field.required"
|
||||||
v-model:value="formData[field.name]"
|
>
|
||||||
:placeholder="field.placeholder || $t('common.pleaseEnter')" :rows="4" />
|
<div class="form-item-content">
|
||||||
<!-- 数字输入 -->
|
<div class="form-input-wrapper">
|
||||||
<a-input-number v-else-if="field.type === 'number'"
|
<!-- 文本输入 -->
|
||||||
v-model:value="formData[field.name]"
|
<a-input
|
||||||
:placeholder="field.placeholder || $t('common.pleaseEnter')"
|
v-if="field.type === 'text'"
|
||||||
style="width: 100%" />
|
v-model:value="formData[field.name]"
|
||||||
<!-- 开关 -->
|
:placeholder="field.placeholder || '请输入'"
|
||||||
<a-switch v-else-if="field.type === 'switch'"
|
allow-clear
|
||||||
v-model:checked="formData[field.name]" />
|
/>
|
||||||
<!-- 下拉选择 -->
|
<!-- 文本域 -->
|
||||||
<a-select v-else-if="field.type === 'select'" v-model:value="formData[field.name]"
|
<a-textarea
|
||||||
:placeholder="field.placeholder || $t('common.pleaseSelect')"
|
v-else-if="field.type === 'textarea'"
|
||||||
style="width: 100%">
|
v-model:value="formData[field.name]"
|
||||||
<a-select-option v-for="option in field.options" :key="option.value"
|
:placeholder="field.placeholder || '请输入'"
|
||||||
:value="option.value">
|
:rows="4"
|
||||||
{{ option.label }}
|
:maxlength="field.maxLength"
|
||||||
</a-select-option>
|
:show-count="field.maxLength > 0"
|
||||||
</a-select>
|
allow-clear
|
||||||
<!-- 多选 -->
|
/>
|
||||||
<a-select v-else-if="field.type === 'multiselect'"
|
<!-- 数字输入 -->
|
||||||
v-model:value="formData[field.name]"
|
<a-input-number
|
||||||
:placeholder="field.placeholder || $t('common.pleaseSelect')" mode="multiple"
|
v-else-if="field.type === 'number'"
|
||||||
style="width: 100%">
|
v-model:value="formData[field.name]"
|
||||||
<a-select-option v-for="option in field.options" :key="option.value"
|
:placeholder="field.placeholder || '请输入'"
|
||||||
:value="option.value">
|
:min="field.min"
|
||||||
{{ option.label }}
|
:max="field.max"
|
||||||
</a-select-option>
|
:precision="field.precision || 0"
|
||||||
</a-select>
|
style="width: 100%"
|
||||||
<!-- 日期时间 -->
|
/>
|
||||||
<a-date-picker v-else-if="field.type === 'datetime'"
|
<!-- 开关 -->
|
||||||
v-model:value="formData[field.name]"
|
<a-switch
|
||||||
:placeholder="field.placeholder || $t('common.pleaseSelect')"
|
v-else-if="field.type === 'switch'"
|
||||||
style="width: 100%" show-time format="YYYY-MM-DD HH:mm:ss" />
|
v-model:checked="formData[field.name]"
|
||||||
<!-- 颜色选择器 -->
|
:checked-children="启用"
|
||||||
<a-input v-else-if="field.type === 'color'" v-model:value="formData[field.name]"
|
:un-checked-children="禁用"
|
||||||
type="color" style="width: 100px" />
|
/>
|
||||||
<!-- 图片上传 -->
|
<!-- 下拉选择 -->
|
||||||
<div v-else-if="field.type === 'image'" class="image-uploader">
|
<a-select
|
||||||
<img v-if="formData[field.name]" :src="formData[field.name]" class="image-preview" />
|
v-else-if="field.type === 'select'"
|
||||||
<a-button v-else type="dashed" @click="handleUpload(field)">
|
v-model:value="formData[field.name]"
|
||||||
<UploadOutlined />
|
:placeholder="field.placeholder || '请选择'"
|
||||||
{{ $t('common.uploadImage') }}
|
style="width: 100%"
|
||||||
</a-button>
|
allow-clear
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="option in field.options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<!-- 多选 -->
|
||||||
|
<a-select
|
||||||
|
v-else-if="field.type === 'multiselect'"
|
||||||
|
v-model:value="formData[field.name]"
|
||||||
|
:placeholder="field.placeholder || '请选择'"
|
||||||
|
mode="multiple"
|
||||||
|
style="width: 100%"
|
||||||
|
allow-clear
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="option in field.options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<!-- 日期时间 -->
|
||||||
|
<a-date-picker
|
||||||
|
v-else-if="field.type === 'datetime'"
|
||||||
|
v-model:value="formData[field.name]"
|
||||||
|
:placeholder="field.placeholder || '请选择'"
|
||||||
|
style="width: 100%"
|
||||||
|
show-time
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<!-- 颜色选择器 -->
|
||||||
|
<div v-else-if="field.type === 'color'" class="color-picker-wrapper">
|
||||||
|
<div class="color-preview" :style="{ backgroundColor: formData[field.name] }"></div>
|
||||||
|
<a-input
|
||||||
|
v-model:value="formData[field.name]"
|
||||||
|
placeholder="请输入颜色值(如:#ff0000)"
|
||||||
|
allow-clear
|
||||||
|
class="color-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 图片上传 -->
|
||||||
|
<div v-else-if="field.type === 'image'" class="image-uploader-wrapper">
|
||||||
|
<sc-upload
|
||||||
|
v-model="formData[field.name]"
|
||||||
|
:max-count="1"
|
||||||
|
:tip="field.tip"
|
||||||
|
upload-text="上传图片"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 默认文本输入 -->
|
||||||
|
<a-input
|
||||||
|
v-else
|
||||||
|
v-model:value="formData[field.name]"
|
||||||
|
:placeholder="field.placeholder || '请输入'"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a-tooltip title="编辑配置项">
|
||||||
|
<EditOutlined class="action-icon edit-icon" @click="handleEditField(field)" />
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 默认文本输入 -->
|
<div v-if="field.tip" class="field-tip">
|
||||||
<a-input v-else v-model:value="formData[field.name]"
|
<InfoCircleOutlined class="tip-icon" />
|
||||||
:placeholder="field.placeholder || $t('common.pleaseEnter')" />
|
{{ field.tip }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
</a-form-item>
|
||||||
<EditOutlined class="action-icon edit-icon" :title="$t('common.edit')"
|
</a-form>
|
||||||
@click="handleEditField(field)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="field.tip" class="field-tip">{{ field.tip }}</div>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<a-empty v-if="fields.filter(f => f.category === category.name).length === 0"
|
<a-empty
|
||||||
:description="$t('common.noConfig')" />
|
v-if="fields.filter(f => f.category === category.name).length === 0"
|
||||||
</a-tab-pane>
|
description="暂无配置项"
|
||||||
</a-tabs>
|
>
|
||||||
|
<a-button type="primary" @click="handleAddConfig">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
添加配置
|
||||||
|
</a-button>
|
||||||
|
</a-empty>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 底部保存按钮 -->
|
<!-- 底部操作栏 -->
|
||||||
<div class="save-actions">
|
<div class="page-footer">
|
||||||
<a-space>
|
<a-space :size="16">
|
||||||
<a-button @click="handleReset">
|
<a-button size="large" @click="handleReset">
|
||||||
{{ $t('common.reset') }}
|
<template #icon><RedoOutlined /></template>
|
||||||
|
重置
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" :loading="saving" @click="handleSave">
|
<a-button type="primary" size="large" :loading="saving" @click="handleSave">
|
||||||
<SaveOutlined />
|
<template #icon><SaveOutlined /></template>
|
||||||
{{ $t('common.save') }}
|
保存配置
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 配置弹窗 -->
|
<!-- 配置弹窗 -->
|
||||||
<ConfigModal v-model:visible="modalVisible" :is-edit="isEditMode" :categories="categories"
|
<ConfigModal
|
||||||
:initial-data="currentEditData" @confirm="handleModalConfirm" />
|
v-model:visible="modalVisible"
|
||||||
|
:is-edit="isEditMode"
|
||||||
|
:categories="categories"
|
||||||
|
:initial-data="currentEditData"
|
||||||
|
@confirm="handleModalConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { SettingOutlined, PlusOutlined, EditOutlined, SaveOutlined, UploadOutlined } from '@ant-design/icons-vue'
|
import scUpload from '@/components/scUpload/index.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import systemApi from '@/api/system'
|
import systemApi from '@/api/system'
|
||||||
import ConfigModal from './components/ConfigModal.vue'
|
import ConfigModal from './components/ConfigModal.vue'
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'SystemSetting',
|
name: 'SystemSetting'
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const activeTab = ref('basic')
|
const activeTab = ref('basic')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
// 配置分类
|
// 配置分类
|
||||||
const categories = ref([
|
const categories = ref([])
|
||||||
{ name: 'basic', title: '基础设置' },
|
|
||||||
{ name: 'security', title: '安全设置' },
|
|
||||||
{ name: 'upload', title: '上传设置' },
|
|
||||||
{ name: 'email', title: '邮件设置' },
|
|
||||||
{ name: 'sms', title: '短信设置' },
|
|
||||||
])
|
|
||||||
|
|
||||||
// 配置字段
|
// 配置字段
|
||||||
const fields = ref([])
|
const fields = ref([])
|
||||||
@@ -147,20 +223,18 @@ const fetchFields = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await systemApi.setting.fields.get()
|
const res = await systemApi.setting.fields.get()
|
||||||
if (res.code === 1) {
|
if (res.code === 1) {
|
||||||
// 根据返回的数据提取配置项
|
|
||||||
const configData = res.data || []
|
const configData = res.data || []
|
||||||
|
|
||||||
// 根据 group 字段提取分类
|
// 根据 group 字段提取分类
|
||||||
const groupMap = new Map()
|
const groupMap = new Map()
|
||||||
configData.forEach(item => {
|
configData.forEach((item) => {
|
||||||
if (item.group && !groupMap.has(item.group)) {
|
if (item.group && !groupMap.has(item.group)) {
|
||||||
// 将 group 名称转换为中文标题
|
|
||||||
const groupTitles = {
|
const groupTitles = {
|
||||||
'base': '基础设置',
|
base: '基础设置',
|
||||||
'upload': '上传设置',
|
upload: '上传设置',
|
||||||
'email': '邮件设置',
|
email: '邮件设置',
|
||||||
'sms': '短信设置',
|
sms: '短信设置',
|
||||||
'security': '安全设置',
|
security: '安全设置'
|
||||||
}
|
}
|
||||||
groupMap.set(item.group, {
|
groupMap.set(item.group, {
|
||||||
name: item.group,
|
name: item.group,
|
||||||
@@ -171,7 +245,7 @@ const fetchFields = async () => {
|
|||||||
categories.value = Array.from(groupMap.values())
|
categories.value = Array.from(groupMap.values())
|
||||||
|
|
||||||
// 将配置项转换为前端需要的格式
|
// 将配置项转换为前端需要的格式
|
||||||
fields.value = configData.map(item => ({
|
fields.value = configData.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
title: item.title || item.label,
|
title: item.title || item.label,
|
||||||
@@ -180,13 +254,26 @@ const fetchFields = async () => {
|
|||||||
category: item.group,
|
category: item.group,
|
||||||
placeholder: item.options?.placeholder || item.remark,
|
placeholder: item.options?.placeholder || item.remark,
|
||||||
tip: item.remark,
|
tip: item.remark,
|
||||||
|
required: item.required || false,
|
||||||
options: mapFieldOptions(item.type, item.options?.options || []),
|
options: mapFieldOptions(item.type, item.options?.options || []),
|
||||||
sort: item.sort
|
sort: item.sort,
|
||||||
|
min: item.options?.min,
|
||||||
|
max: item.options?.max,
|
||||||
|
precision: item.options?.precision,
|
||||||
|
maxLength: item.options?.maxLength
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 初始化表单数据
|
// 初始化表单数据
|
||||||
fields.value.forEach(field => {
|
fields.value.forEach((field) => {
|
||||||
formData[field.name] = field.value || ''
|
if (field.type === 'number') {
|
||||||
|
formData[field.name] = field.value ? Number(field.value) : 0
|
||||||
|
} else if (field.type === 'switch') {
|
||||||
|
formData[field.name] = field.value === '1' || field.value === true
|
||||||
|
} else if (field.type === 'multiselect') {
|
||||||
|
formData[field.name] = field.value ? (Array.isArray(field.value) ? field.value : field.value.split(',')) : []
|
||||||
|
} else {
|
||||||
|
formData[field.name] = field.value || ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 设置第一个 tab 为默认激活
|
// 设置第一个 tab 为默认激活
|
||||||
@@ -195,7 +282,7 @@ const fetchFields = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(t('common.fetchConfigFailed'))
|
message.error('获取配置字段失败')
|
||||||
console.error('获取配置字段失败:', error)
|
console.error('获取配置字段失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,21 +290,21 @@ const fetchFields = async () => {
|
|||||||
// 映射字段类型
|
// 映射字段类型
|
||||||
const mapFieldType = (backendType) => {
|
const mapFieldType = (backendType) => {
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
'string': 'text',
|
string: 'text',
|
||||||
'text': 'text',
|
text: 'text',
|
||||||
'textarea': 'textarea',
|
textarea: 'textarea',
|
||||||
'number': 'number',
|
number: 'number',
|
||||||
'boolean': 'switch',
|
boolean: 'switch',
|
||||||
'switch': 'switch',
|
switch: 'switch',
|
||||||
'select': 'select',
|
select: 'select',
|
||||||
'radio': 'select',
|
radio: 'select',
|
||||||
'multiselect': 'multiselect',
|
multiselect: 'multiselect',
|
||||||
'checkbox': 'multiselect',
|
checkbox: 'multiselect',
|
||||||
'datetime': 'datetime',
|
datetime: 'datetime',
|
||||||
'date': 'datetime',
|
date: 'datetime',
|
||||||
'color': 'color',
|
color: 'color',
|
||||||
'image': 'image',
|
image: 'image',
|
||||||
'file': 'file',
|
file: 'file'
|
||||||
}
|
}
|
||||||
return typeMap[backendType] || 'text'
|
return typeMap[backendType] || 'text'
|
||||||
}
|
}
|
||||||
@@ -225,7 +312,7 @@ const mapFieldType = (backendType) => {
|
|||||||
// 映射字段选项
|
// 映射字段选项
|
||||||
const mapFieldOptions = (type, options) => {
|
const mapFieldOptions = (type, options) => {
|
||||||
if (options && options.length > 0) {
|
if (options && options.length > 0) {
|
||||||
return options.map(opt => ({
|
return options.map((opt) => ({
|
||||||
label: opt.label || opt.name || opt,
|
label: opt.label || opt.name || opt,
|
||||||
value: opt.value || opt.key || opt
|
value: opt.value || opt.key || opt
|
||||||
}))
|
}))
|
||||||
@@ -248,6 +335,7 @@ const handleAddConfig = () => {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
value: '',
|
value: '',
|
||||||
tip: '',
|
tip: '',
|
||||||
|
required: false
|
||||||
}
|
}
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -265,7 +353,8 @@ const handleEditField = (field) => {
|
|||||||
tip: field.tip || '',
|
tip: field.tip || '',
|
||||||
remark: field.tip || '',
|
remark: field.tip || '',
|
||||||
placeholder: field.placeholder,
|
placeholder: field.placeholder,
|
||||||
options: field.options || []
|
options: field.options || [],
|
||||||
|
required: field.required || false
|
||||||
}
|
}
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -279,17 +368,17 @@ const handleModalConfirm = async (values) => {
|
|||||||
res = await systemApi.setting.edit.post({
|
res = await systemApi.setting.edit.post({
|
||||||
id: currentEditData.value.id,
|
id: currentEditData.value.id,
|
||||||
...values,
|
...values,
|
||||||
name: currentEditData.value.name,
|
name: currentEditData.value.name
|
||||||
})
|
})
|
||||||
if (res.code === 1) {
|
if (res.code === 1) {
|
||||||
message.success(t('common.editSuccess'))
|
message.success('编辑成功')
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
|
|
||||||
// 更新表单数据
|
// 更新表单数据
|
||||||
formData[currentEditData.value.name] = values.value
|
formData[currentEditData.value.name] = values.value
|
||||||
|
|
||||||
// 更新字段信息
|
// 更新字段信息
|
||||||
const fieldIndex = fields.value.findIndex(f => f.name === currentEditData.value.name)
|
const fieldIndex = fields.value.findIndex((f) => f.name === currentEditData.value.name)
|
||||||
if (fieldIndex > -1) {
|
if (fieldIndex > -1) {
|
||||||
fields.value[fieldIndex].title = values.title
|
fields.value[fieldIndex].title = values.title
|
||||||
fields.value[fieldIndex].value = values.value
|
fields.value[fieldIndex].value = values.value
|
||||||
@@ -301,7 +390,7 @@ const handleModalConfirm = async (values) => {
|
|||||||
// 添加模式
|
// 添加模式
|
||||||
res = await systemApi.setting.add.post(values)
|
res = await systemApi.setting.add.post(values)
|
||||||
if (res.code === 1) {
|
if (res.code === 1) {
|
||||||
message.success(t('common.addSuccess'))
|
message.success('添加成功')
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
// 重新获取配置字段
|
// 重新获取配置字段
|
||||||
await fetchFields()
|
await fetchFields()
|
||||||
@@ -311,7 +400,7 @@ const handleModalConfirm = async (values) => {
|
|||||||
if (error.errorFields) {
|
if (error.errorFields) {
|
||||||
return // 表单验证错误
|
return // 表单验证错误
|
||||||
}
|
}
|
||||||
message.error(isEditMode.value ? t('common.editFailed') : t('common.addFailed'))
|
message.error(isEditMode.value ? '编辑失败' : '添加失败')
|
||||||
console.error(isEditMode.value ? '编辑配置失败:' : '添加配置失败:', error)
|
console.error(isEditMode.value ? '编辑配置失败:' : '添加配置失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,12 +409,27 @@ const handleModalConfirm = async (values) => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const res = await systemApi.setting.save.post(formData)
|
// 处理多选和开关类型的值
|
||||||
|
const saveData = {}
|
||||||
|
Object.keys(formData).forEach((key) => {
|
||||||
|
const field = fields.value.find((f) => f.name === key)
|
||||||
|
if (field) {
|
||||||
|
if (field.type === 'multiselect') {
|
||||||
|
saveData[key] = Array.isArray(formData[key]) ? formData[key].join(',') : formData[key]
|
||||||
|
} else if (field.type === 'switch') {
|
||||||
|
saveData[key] = formData[key] ? '1' : '0'
|
||||||
|
} else {
|
||||||
|
saveData[key] = formData[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await systemApi.setting.save.post(saveData)
|
||||||
if (res.code === 1) {
|
if (res.code === 1) {
|
||||||
message.success(t('common.saveSuccess'))
|
message.success('保存成功')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(t('common.saveFailed'))
|
message.error('保存失败')
|
||||||
console.error('保存配置失败:', error)
|
console.error('保存配置失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -334,19 +438,18 @@ const handleSave = async () => {
|
|||||||
|
|
||||||
// 重置配置
|
// 重置配置
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
Object.keys(formData).forEach(key => {
|
fields.value.forEach((field) => {
|
||||||
const field = fields.value.find(f => f.name === key)
|
if (field.type === 'number') {
|
||||||
if (field) {
|
formData[field.name] = field.value ? Number(field.value) : 0
|
||||||
formData[key] = field.value || ''
|
} else if (field.type === 'switch') {
|
||||||
|
formData[field.name] = field.value === '1' || field.value === true
|
||||||
|
} else if (field.type === 'multiselect') {
|
||||||
|
formData[field.name] = field.value ? (Array.isArray(field.value) ? field.value : field.value.split(',')) : []
|
||||||
|
} else {
|
||||||
|
formData[field.name] = field.value || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
message.info(t('common.resetSuccess'))
|
message.info('已重置')
|
||||||
}
|
|
||||||
|
|
||||||
// 处理图片上传
|
|
||||||
const handleUpload = (field) => {
|
|
||||||
message.info('图片上传功能待实现')
|
|
||||||
// TODO: 实现图片上传逻辑
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -356,72 +459,210 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.system-setting {
|
.system-setting {
|
||||||
.page-title {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-form {
|
.header-left {
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
.form-item-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 4px;
|
||||||
|
|
||||||
.form-input-wrapper {
|
.page-title {
|
||||||
flex: 1;
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.page-subtitle {
|
||||||
.action-icon {
|
font-size: 13px;
|
||||||
font-size: 16px;
|
color: #8c8c8c;
|
||||||
color: #8c8c8c;
|
}
|
||||||
cursor: pointer;
|
}
|
||||||
transition: color 0.2s;
|
}
|
||||||
|
|
||||||
&:hover {
|
.page-content {
|
||||||
color: #1890ff;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 16px 24px 0;
|
||||||
|
|
||||||
|
.setting-card {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-tabs {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
:deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-tabs-content) {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #d9d9d9;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #bfbfbf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-tabs-tabpane) {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-tip {
|
.setting-form {
|
||||||
margin-top: 4px;
|
.form-item-content {
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
color: #8c8c8c;
|
align-items: flex-start;
|
||||||
line-height: 1.4;
|
gap: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.image-uploader {
|
.form-input-wrapper {
|
||||||
.image-preview {
|
flex: 1;
|
||||||
max-width: 200px;
|
}
|
||||||
max-height: 200px;
|
|
||||||
border: 1px solid #d9d9d9;
|
.form-actions {
|
||||||
border-radius: 4px;
|
display: flex;
|
||||||
padding: 4px;
|
align-items: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
.tip-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-uploader-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-actions {
|
.page-footer {
|
||||||
margin-top: 30px;
|
background: #fff;
|
||||||
padding-top: 20px;
|
padding: 16px 24px;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-form-item) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-form-item-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-form-item-required::before {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-tabs-tab) {
|
:deep(.ant-tabs-tab) {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
font-weight: 500;
|
||||||
|
|
||||||
:deep(.ant-form-item) {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user