更新仪表盘

This commit is contained in:
2026-01-22 23:45:11 +08:00
parent 0c2ebc8501
commit 1963ea7244
27 changed files with 7341 additions and 2316 deletions

View File

@@ -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",

View File

@@ -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>

View 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>

View 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>

View 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)

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
} }
} }
} }

View File

@@ -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>

View File

@@ -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>

3151
yarn.lock

File diff suppressed because it is too large Load Diff