修改保险后端代码,政府前端代码
This commit is contained in:
362
government-admin/src/components/Layout.vue
Normal file
362
government-admin/src/components/Layout.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<!-- 侧边栏 -->
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible>
|
||||
<div class="logo">
|
||||
<h2 v-if="!collapsed">政府管理系统</h2>
|
||||
<h2 v-else>政府</h2>
|
||||
</div>
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
:items="menus"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
</a-layout-sider>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<a-layout>
|
||||
<!-- 头部 -->
|
||||
<a-layout-header style="background: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center">
|
||||
<div>
|
||||
<menu-unfold-outlined
|
||||
v-if="collapsed"
|
||||
class="trigger"
|
||||
@click="() => (collapsed = !collapsed)"
|
||||
/>
|
||||
<menu-fold-outlined
|
||||
v-else
|
||||
class="trigger"
|
||||
@click="() => (collapsed = !collapsed)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<a-dropdown>
|
||||
<a class="ant-dropdown-link" @click.prevent>
|
||||
<user-outlined />
|
||||
{{ userStore.userInfo.real_name || '管理员' }}
|
||||
<down-outlined />
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile">
|
||||
<user-outlined />
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<logout-outlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<a-layout-content style="margin: 16px">
|
||||
<div :style="{ padding: '24px', background: '#fff', minHeight: '360px' }">
|
||||
<router-view />
|
||||
</div>
|
||||
</a-layout-content>
|
||||
|
||||
<!-- 底部 -->
|
||||
<a-layout-footer style="text-align: center">
|
||||
政府端后台管理系统 ©2024
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
UserOutlined,
|
||||
DownOutlined,
|
||||
LogoutOutlined,
|
||||
DashboardOutlined,
|
||||
UserAddOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleOutlined,
|
||||
LineChartOutlined,
|
||||
FileOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ShoppingOutlined,
|
||||
FolderOutlined,
|
||||
BarChartOutlined,
|
||||
PieChartOutlined,
|
||||
ShoppingCartOutlined,
|
||||
FileTextOutlined,
|
||||
DatabaseOutlined,
|
||||
HomeOutlined,
|
||||
ShopOutlined,
|
||||
MessageOutlined,
|
||||
BookOutlined,
|
||||
VideoCameraOutlined,
|
||||
EnvironmentOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const selectedKeys = ref([route.name])
|
||||
const menus = ref([])
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
DashboardOutlined: () => h(DashboardOutlined),
|
||||
UserAddOutlined: () => h(UserAddOutlined),
|
||||
EyeOutlined: () => h(EyeOutlined),
|
||||
CheckCircleOutlined: () => h(CheckCircleOutlined),
|
||||
LineChartOutlined: () => h(LineChartOutlined),
|
||||
FileOutlined: () => h(FileOutlined),
|
||||
TeamOutlined: () => h(TeamOutlined),
|
||||
SettingOutlined: () => h(SettingOutlined),
|
||||
MedicineBoxOutlined: () => h(MedicineBoxOutlined),
|
||||
ShoppingOutlined: () => h(ShoppingOutlined),
|
||||
FolderOutlined: () => h(FolderOutlined),
|
||||
BarChartOutlined: () => h(BarChartOutlined),
|
||||
PieChartOutlined: () => h(PieChartOutlined),
|
||||
ShoppingCartOutlined: () => h(ShoppingCartOutlined),
|
||||
FileTextOutlined: () => h(FileTextOutlined),
|
||||
DatabaseOutlined: () => h(DatabaseOutlined),
|
||||
HomeOutlined: () => h(HomeOutlined),
|
||||
ShopOutlined: () => h(ShopOutlined),
|
||||
MessageOutlined: () => h(MessageOutlined),
|
||||
BookOutlined: () => h(BookOutlined),
|
||||
VideoCameraOutlined: () => h(VideoCameraOutlined),
|
||||
ShopOutlined: () => h(ShopOutlined),
|
||||
EnvironmentOutlined: () => h(EnvironmentOutlined)
|
||||
};
|
||||
|
||||
// 格式化菜单数据
|
||||
const formatMenuItems = (menuList) => {
|
||||
return menuList.map(menu => {
|
||||
const menuItem = {
|
||||
key: menu.key,
|
||||
label: menu.label,
|
||||
path: menu.path
|
||||
};
|
||||
|
||||
// 添加图标
|
||||
if (menu.icon && iconMap[menu.icon]) {
|
||||
menuItem.icon = iconMap[menu.icon];
|
||||
}
|
||||
|
||||
// 添加子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menuItem.children = formatMenuItems(menu.children);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
});
|
||||
};
|
||||
|
||||
// 获取菜单数据
|
||||
const fetchMenus = async () => {
|
||||
try {
|
||||
// 这里可以根据实际情况从API获取菜单数据
|
||||
// 由于没有实际的API,这里提供默认菜单作为备用
|
||||
menus.value = [
|
||||
{
|
||||
key: 'DataCenter',
|
||||
icon: 'DatabaseOutlined',
|
||||
label: '数据览仓',
|
||||
path: '/index/data_center'
|
||||
},
|
||||
{
|
||||
key: 'MarketPrice',
|
||||
icon: 'BarChartOutlined',
|
||||
label: '市场行情',
|
||||
path: '/price/price_list'
|
||||
},
|
||||
{
|
||||
key: 'PersonnelManagement',
|
||||
icon: 'TeamOutlined',
|
||||
label: '人员管理',
|
||||
path: '/personnel'
|
||||
},
|
||||
{
|
||||
key: 'FarmerManagement',
|
||||
icon: 'UserAddOutlined',
|
||||
label: '养殖户管理',
|
||||
path: '/farmer'
|
||||
},
|
||||
{
|
||||
key: 'SmartWarehouse',
|
||||
icon: 'FolderOutlined',
|
||||
label: '智能仓库',
|
||||
path: '/smart-warehouse'
|
||||
},
|
||||
{
|
||||
key: 'BreedImprovement',
|
||||
icon: 'SettingOutlined',
|
||||
label: '品种改良管理',
|
||||
path: '/breed-improvement'
|
||||
},
|
||||
{
|
||||
key: 'PaperlessService',
|
||||
icon: 'FileTextOutlined',
|
||||
label: '无纸化服务',
|
||||
path: '/paperless'
|
||||
},
|
||||
{
|
||||
key: 'SlaughterHarmless',
|
||||
icon: 'EnvironmentOutlined',
|
||||
label: '屠宰无害化',
|
||||
path: '/slaughter'
|
||||
},
|
||||
{
|
||||
key: 'FinanceInsurance',
|
||||
icon: 'ShoppingOutlined',
|
||||
label: '金融保险',
|
||||
path: '/finance'
|
||||
},
|
||||
{
|
||||
key: 'ProductCertification',
|
||||
icon: 'CheckCircleOutlined',
|
||||
label: '生资认证',
|
||||
path: '/examine/index'
|
||||
},
|
||||
{
|
||||
key: 'ProductTrade',
|
||||
icon: 'ShoppingCartOutlined',
|
||||
label: '生资交易',
|
||||
path: '/shengzijiaoyi'
|
||||
},
|
||||
{
|
||||
key: 'CommunicationCommunity',
|
||||
icon: 'MessageOutlined',
|
||||
label: '交流社区',
|
||||
path: '/community'
|
||||
},
|
||||
{
|
||||
key: 'OnlineConsultation',
|
||||
icon: 'EyeOutlined',
|
||||
label: '线上问诊',
|
||||
path: '/consultation'
|
||||
},
|
||||
{
|
||||
key: 'CattleAcademy',
|
||||
icon: 'BookOutlined',
|
||||
label: '养牛学院',
|
||||
path: '/academy'
|
||||
},
|
||||
{
|
||||
key: 'MessageNotification',
|
||||
icon: 'VideoCameraOutlined',
|
||||
label: '消息通知',
|
||||
path: '/notification'
|
||||
},
|
||||
{
|
||||
key: 'UserManagement',
|
||||
icon: 'UserAddOutlined',
|
||||
label: '用户管理',
|
||||
path: '/users'
|
||||
},
|
||||
{
|
||||
key: 'WarehouseManagement',
|
||||
icon: 'MedicineBoxOutlined',
|
||||
label: '仓库管理',
|
||||
path: '/warehouse'
|
||||
},
|
||||
{
|
||||
key: 'FileManagement',
|
||||
icon: 'FolderOutlined',
|
||||
label: '文件管理',
|
||||
path: '/files'
|
||||
},
|
||||
{
|
||||
key: 'ServiceManagement',
|
||||
icon: 'SettingOutlined',
|
||||
label: '服务管理',
|
||||
path: '/service'
|
||||
},
|
||||
{
|
||||
key: 'ApprovalProcess',
|
||||
icon: 'CheckCircleOutlined',
|
||||
label: '审批流程',
|
||||
path: '/approval'
|
||||
},
|
||||
{
|
||||
key: 'EpidemicManagement',
|
||||
icon: 'LineChartOutlined',
|
||||
label: '防疫管理',
|
||||
path: '/epidemic'
|
||||
},
|
||||
{
|
||||
key: 'SupervisionDashboard',
|
||||
icon: 'EyeOutlined',
|
||||
label: '监管大屏',
|
||||
path: '/supervision'
|
||||
},
|
||||
{
|
||||
key: 'VisualAnalysis',
|
||||
icon: 'PieChartOutlined',
|
||||
label: '数据分析',
|
||||
path: '/visualization'
|
||||
},
|
||||
{
|
||||
key: 'LogManagement',
|
||||
icon: 'FileOutlined',
|
||||
label: '日志管理',
|
||||
path: '/log'
|
||||
}
|
||||
];
|
||||
|
||||
// 应用图标映射
|
||||
menus.value = formatMenuItems(menus.value);
|
||||
} catch (error) {
|
||||
console.error('获取菜单失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = (e) => {
|
||||
const menuItem = menus.value.find(item => item.key === e.key);
|
||||
if (menuItem && menuItem.path) {
|
||||
router.push(menuItem.path);
|
||||
}
|
||||
};
|
||||
|
||||
// 退出登录处理
|
||||
const handleLogout = () => {
|
||||
userStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
// 组件挂载时获取菜单
|
||||
onMounted(() => {
|
||||
fetchMenus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 32px;
|
||||
margin: 16px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
line-height: 64px;
|
||||
padding: 0 24px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-header-content">
|
||||
<div class="page-header-main">
|
||||
<div class="page-header-title">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="description" class="page-header-description">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.extra" class="page-header-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="page-header-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.page-header-content {
|
||||
.page-header-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.page-header-title {
|
||||
flex: 1;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.page-header-description {
|
||||
margin: 4px 0 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-extra {
|
||||
flex-shrink: 0;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-body {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<a-button
|
||||
v-if="hasPermission"
|
||||
v-bind="$attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
|
||||
const props = defineProps({
|
||||
// 权限码
|
||||
permission: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 角色
|
||||
role: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 权限列表(任一权限)
|
||||
permissions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 是否需要全部权限
|
||||
requireAll: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 角色列表
|
||||
roles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
// 检查是否有权限
|
||||
const hasPermission = computed(() => {
|
||||
// 如果没有设置任何权限要求,默认显示
|
||||
if (!props.permission && !props.role && props.permissions.length === 0 && props.roles.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查单个权限
|
||||
if (props.permission) {
|
||||
return permissionStore.hasPermission(props.permission)
|
||||
}
|
||||
|
||||
// 检查单个角色
|
||||
if (props.role) {
|
||||
return permissionStore.hasRole(props.role)
|
||||
}
|
||||
|
||||
// 检查权限列表
|
||||
if (props.permissions.length > 0) {
|
||||
return props.requireAll
|
||||
? permissionStore.hasAllPermissions(props.permissions)
|
||||
: permissionStore.hasAnyPermission(props.permissions)
|
||||
}
|
||||
|
||||
// 检查角色列表
|
||||
if (props.roles.length > 0) {
|
||||
return props.roles.some(role => permissionStore.hasRole(role))
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const handleClick = (event) => {
|
||||
emit('click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PermissionButton',
|
||||
inheritAttrs: false
|
||||
}
|
||||
</script>
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<div class="bar-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
xAxisData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1']
|
||||
},
|
||||
horizontal: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
stack: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
grid: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
top: '10%',
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const series = props.data.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'bar',
|
||||
data: item.data,
|
||||
stack: props.stack,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: props.color[index % props.color.length] },
|
||||
{ offset: 1, color: echarts.color.lift(props.color[index % props.color.length], -0.3) }
|
||||
]),
|
||||
borderRadius: props.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: props.color[index % props.color.length]
|
||||
}
|
||||
},
|
||||
barWidth: '60%'
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.data.map(item => item.name),
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
grid: props.grid,
|
||||
xAxis: {
|
||||
type: props.horizontal ? 'value' : 'category',
|
||||
data: props.horizontal ? null : props.xAxisData,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666',
|
||||
rotate: props.horizontal ? 0 : (props.xAxisData.length > 6 ? 45 : 0)
|
||||
},
|
||||
splitLine: props.horizontal ? {
|
||||
lineStyle: {
|
||||
color: '#f0f0f0'
|
||||
}
|
||||
} : null
|
||||
},
|
||||
yAxis: {
|
||||
type: props.horizontal ? 'category' : 'value',
|
||||
data: props.horizontal ? props.xAxisData : null,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666'
|
||||
},
|
||||
splitLine: props.horizontal ? null : {
|
||||
lineStyle: {
|
||||
color: '#f0f0f0'
|
||||
}
|
||||
}
|
||||
},
|
||||
series
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => [props.data, props.xAxisData], () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bar-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<div class="gauge-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: '%'
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
[0.2, '#67e0e3'],
|
||||
[0.8, '#37a2da'],
|
||||
[1, '#fd666d']
|
||||
]
|
||||
},
|
||||
radius: {
|
||||
type: String,
|
||||
default: '75%'
|
||||
},
|
||||
center: {
|
||||
type: Array,
|
||||
default: () => ['50%', '60%']
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
formatter: '{a} <br/>{b} : {c}' + props.unit
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.title || '指标',
|
||||
type: 'gauge',
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
radius: props.radius,
|
||||
center: props.center,
|
||||
splitNumber: 10,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: props.color,
|
||||
width: 20,
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
textStyle: {
|
||||
fontWeight: 'bolder',
|
||||
color: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
length: 15,
|
||||
lineStyle: {
|
||||
color: 'auto',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
length: 25,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
pointer: {
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 5
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'bolder',
|
||||
fontSize: 20,
|
||||
fontStyle: 'italic',
|
||||
color: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
backgroundColor: 'rgba(30,144,255,0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff',
|
||||
shadowColor: '#fff',
|
||||
shadowBlur: 5,
|
||||
offsetCenter: [0, '50%'],
|
||||
textStyle: {
|
||||
fontWeight: 'bolder',
|
||||
color: '#fff'
|
||||
},
|
||||
formatter: function(value) {
|
||||
return value + props.unit
|
||||
}
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.value,
|
||||
name: props.title || '完成度'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => [props.value, props.max, props.min], () => {
|
||||
updateChart()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gauge-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -1,200 +0,0 @@
|
||||
<template>
|
||||
<div class="line-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
xAxisData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1']
|
||||
},
|
||||
smooth: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showArea: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSymbol: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
grid: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
top: '10%',
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const series = props.data.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: item.data,
|
||||
smooth: props.smooth,
|
||||
symbol: props.showSymbol ? 'circle' : 'none',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: props.color[index % props.color.length]
|
||||
},
|
||||
itemStyle: {
|
||||
color: props.color[index % props.color.length]
|
||||
},
|
||||
areaStyle: props.showArea ? {
|
||||
opacity: 0.3,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: props.color[index % props.color.length] },
|
||||
{ offset: 1, color: 'rgba(255, 255, 255, 0)' }
|
||||
])
|
||||
} : null
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.data.map(item => item.name),
|
||||
bottom: 10,
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
grid: props.grid,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.xAxisData,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e8e8e8'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666'
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#f0f0f0'
|
||||
}
|
||||
}
|
||||
},
|
||||
series
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => [props.data, props.xAxisData], () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -1,185 +0,0 @@
|
||||
<template>
|
||||
<div class="map-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
mapName: {
|
||||
type: String,
|
||||
default: 'china'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#313695', '#4575b4', '#74add1', '#abd9e9', '#e0f3f8', '#ffffcc', '#fee090', '#fdae61', '#f46d43', '#d73027', '#a50026']
|
||||
},
|
||||
visualMapMin: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
visualMapMax: {
|
||||
type: Number,
|
||||
default: 1000
|
||||
},
|
||||
roam: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = async () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
|
||||
// 注册地图(这里需要根据实际情况加载地图数据)
|
||||
// 示例:加载中国地图数据
|
||||
try {
|
||||
// 这里应该加载实际的地图JSON数据
|
||||
// const mapData = await import('@/assets/maps/china.json')
|
||||
// echarts.registerMap(props.mapName, mapData.default)
|
||||
|
||||
// 临时使用内置地图
|
||||
updateChart()
|
||||
} catch (error) {
|
||||
console.warn('地图数据加载失败,使用默认配置')
|
||||
updateChart()
|
||||
}
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function(params) {
|
||||
if (params.data) {
|
||||
return `${params.name}<br/>${params.seriesName}: ${params.data.value}`
|
||||
}
|
||||
return `${params.name}<br/>暂无数据`
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
min: props.visualMapMin,
|
||||
max: props.visualMapMax,
|
||||
left: 'left',
|
||||
top: 'bottom',
|
||||
text: ['高', '低'],
|
||||
inRange: {
|
||||
color: props.color
|
||||
},
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.title || '数据分布',
|
||||
type: 'map',
|
||||
map: props.mapName,
|
||||
roam: props.roam,
|
||||
data: props.data,
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff'
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#389BB7',
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
areaColor: '#eee'
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
fontSize: 12,
|
||||
color: '#333'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => props.data, () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.map-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<div class="pie-chart" :style="{ width: width, height: height }">
|
||||
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
color: {
|
||||
type: Array,
|
||||
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#eb2f96', '#13c2c2', '#fa8c16']
|
||||
},
|
||||
radius: {
|
||||
type: Array,
|
||||
default: () => ['40%', '70%']
|
||||
},
|
||||
center: {
|
||||
type: Array,
|
||||
default: () => ['50%', '50%']
|
||||
},
|
||||
roseType: {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showLabelLine: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance) return
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#ccc',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle',
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
},
|
||||
formatter: function(name) {
|
||||
const item = props.data.find(d => d.name === name)
|
||||
return item ? `${name}: ${item.value}` : name
|
||||
}
|
||||
},
|
||||
color: props.color,
|
||||
series: [
|
||||
{
|
||||
name: props.title || '数据统计',
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
center: props.center,
|
||||
roseType: props.roseType,
|
||||
data: props.data,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
},
|
||||
label: {
|
||||
show: props.showLabel,
|
||||
position: 'outside',
|
||||
formatter: '{b}: {d}%',
|
||||
fontSize: 12,
|
||||
color: '#666'
|
||||
},
|
||||
labelLine: {
|
||||
show: props.showLabelLine,
|
||||
length: 15,
|
||||
length2: 10,
|
||||
lineStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 8,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
const resizeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
watch(() => props.data, () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: resizeChart
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pie-chart {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +0,0 @@
|
||||
// 图表组件统一导出
|
||||
import LineChart from './LineChart.vue'
|
||||
import BarChart from './BarChart.vue'
|
||||
import PieChart from './PieChart.vue'
|
||||
import GaugeChart from './GaugeChart.vue'
|
||||
import MapChart from './MapChart.vue'
|
||||
|
||||
export {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
GaugeChart,
|
||||
MapChart
|
||||
}
|
||||
|
||||
export default {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
GaugeChart,
|
||||
MapChart
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
<template>
|
||||
<div class="data-table">
|
||||
<div v-if="showToolbar" class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<slot name="toolbar-left">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="showAdd"
|
||||
type="primary"
|
||||
@click="$emit('add')"
|
||||
>
|
||||
<PlusOutlined />
|
||||
{{ addText || '新增' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="showBatchDelete && selectedRowKeys.length > 0"
|
||||
danger
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
批量删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<slot name="toolbar-right">
|
||||
<a-space>
|
||||
<a-tooltip title="刷新">
|
||||
<a-button @click="$emit('refresh')">
|
||||
<ReloadOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="列设置">
|
||||
<a-button @click="showColumnSetting = true">
|
||||
<SettingOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="visibleColumns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="paginationConfig"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="scroll"
|
||||
:size="size"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData"></slot>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 列设置弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showColumnSetting"
|
||||
title="列设置"
|
||||
@ok="handleColumnSettingOk"
|
||||
>
|
||||
<a-checkbox-group v-model:value="selectedColumns" class="column-setting">
|
||||
<div v-for="column in columns" :key="column.key || column.dataIndex" class="column-item">
|
||||
<a-checkbox :value="column.key || column.dataIndex">
|
||||
{{ column.title }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
dataSource: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
pagination: {
|
||||
type: [Object, Boolean],
|
||||
default: () => ({})
|
||||
},
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showAdd: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
addText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showBatchDelete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
rowKey: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
scroll: {
|
||||
type: Object,
|
||||
default: () => ({ x: 'max-content' })
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'middle'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'add',
|
||||
'refresh',
|
||||
'change',
|
||||
'batchDelete',
|
||||
'selectionChange'
|
||||
])
|
||||
|
||||
const selectedRowKeys = ref([])
|
||||
const showColumnSetting = ref(false)
|
||||
const selectedColumns = ref([])
|
||||
|
||||
// 初始化选中的列
|
||||
const initSelectedColumns = () => {
|
||||
selectedColumns.value = props.columns
|
||||
.filter(col => col.key || col.dataIndex)
|
||||
.map(col => col.key || col.dataIndex)
|
||||
}
|
||||
|
||||
// 可见的列
|
||||
const visibleColumns = computed(() => {
|
||||
return props.columns.filter(col => {
|
||||
const key = col.key || col.dataIndex
|
||||
return !key || selectedColumns.value.includes(key)
|
||||
})
|
||||
})
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = computed(() => {
|
||||
if (!props.showBatchDelete) return null
|
||||
|
||||
return {
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys, rows) => {
|
||||
selectedRowKeys.value = keys
|
||||
emit('selectionChange', keys, rows)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const paginationConfig = computed(() => {
|
||||
if (props.pagination === false) return false
|
||||
|
||||
return {
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
...props.pagination
|
||||
}
|
||||
})
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
emit('change', { pagination, filters, sorter })
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
message.warning('请选择要删除的数据')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条数据吗?`,
|
||||
onOk: () => {
|
||||
emit('batchDelete', selectedRowKeys.value)
|
||||
selectedRowKeys.value = []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 列设置确认
|
||||
const handleColumnSettingOk = () => {
|
||||
showColumnSetting.value = false
|
||||
message.success('列设置已保存')
|
||||
}
|
||||
|
||||
// 监听列变化,重新初始化选中的列
|
||||
watch(
|
||||
() => props.columns,
|
||||
() => {
|
||||
initSelectedColumns()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data-table {
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.column-setting {
|
||||
.column-item {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.data-table {
|
||||
.table-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<component v-if="icon" :is="icon" />
|
||||
<InboxOutlined v-else />
|
||||
</div>
|
||||
<div class="empty-title">{{ title || '暂无数据' }}</div>
|
||||
<div v-if="description" class="empty-description">{{ description }}</div>
|
||||
<div v-if="showAction" class="empty-action">
|
||||
<a-button type="primary" @click="$emit('action')">
|
||||
{{ actionText || '重新加载' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { InboxOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: [String, Object],
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showAction: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
color: #262626;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 24px;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,83 +0,0 @@
|
||||
<template>
|
||||
<div class="loading-spinner" :class="{ 'full-screen': fullScreen }">
|
||||
<div class="spinner-container">
|
||||
<div class="spinner" :style="{ width: size + 'px', height: size + 'px' }">
|
||||
<div class="spinner-inner"></div>
|
||||
</div>
|
||||
<div v-if="text" class="loading-text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
size: {
|
||||
type: Number,
|
||||
default: 40
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fullScreen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
|
||||
&.full-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.spinner {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, #1890ff, #40a9ff, #69c0ff, #91d5ff, transparent);
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
.spinner-inner {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-title">
|
||||
<component v-if="icon" :is="icon" class="title-icon" />
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<div v-if="description" class="header-description">{{ description }}</div>
|
||||
</div>
|
||||
<div v-if="$slots.extra" class="header-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.tabs" class="header-tabs">
|
||||
<slot name="tabs"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: [String, Object],
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px;
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.title-icon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.header-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.header-extra {
|
||||
flex-shrink: 0;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
padding: 0 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.header-extra {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,210 +0,0 @@
|
||||
<template>
|
||||
<div class="search-form">
|
||||
<a-form
|
||||
:model="formData"
|
||||
layout="inline"
|
||||
@finish="handleSearch"
|
||||
@reset="handleReset"
|
||||
>
|
||||
<template v-for="field in fields" :key="field.key">
|
||||
<!-- 输入框 -->
|
||||
<a-form-item
|
||||
v-if="field.type === 'input'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
:style="{ width: field.width || '200px' }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 选择框 -->
|
||||
<a-form-item
|
||||
v-else-if="field.type === 'select'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || `请选择${field.label}`"
|
||||
:style="{ width: field.width || '200px' }"
|
||||
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-form-item>
|
||||
|
||||
<!-- 日期选择 -->
|
||||
<a-form-item
|
||||
v-else-if="field.type === 'date'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-date-picker
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || `请选择${field.label}`"
|
||||
:style="{ width: field.width || '200px' }"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 日期范围选择 -->
|
||||
<a-form-item
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
>
|
||||
<a-range-picker
|
||||
v-model:value="formData[field.key]"
|
||||
:placeholder="field.placeholder || ['开始日期', '结束日期']"
|
||||
:style="{ width: field.width || '300px' }"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">
|
||||
<SearchOutlined />
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button html-type="reset">
|
||||
<ReloadOutlined />
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="showToggle && fields.length > 3"
|
||||
type="link"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
{{ expanded ? '收起' : '展开' }}
|
||||
<UpOutlined v-if="expanded" />
|
||||
<DownOutlined v-else />
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { SearchOutlined, ReloadOutlined, UpOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showToggle: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
initialValues: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search', 'reset'])
|
||||
|
||||
const expanded = ref(false)
|
||||
const formData = reactive({})
|
||||
|
||||
// 初始化表单数据
|
||||
const initFormData = () => {
|
||||
props.fields.forEach(field => {
|
||||
formData[field.key] = props.initialValues[field.key] || field.defaultValue || undefined
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const searchData = { ...formData }
|
||||
// 过滤空值
|
||||
Object.keys(searchData).forEach(key => {
|
||||
if (searchData[key] === undefined || searchData[key] === null || searchData[key] === '') {
|
||||
delete searchData[key]
|
||||
}
|
||||
})
|
||||
emit('search', searchData)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
props.fields.forEach(field => {
|
||||
formData[field.key] = field.defaultValue || undefined
|
||||
})
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
const toggleExpanded = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
// 监听初始值变化
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
() => {
|
||||
initFormData()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 监听字段变化
|
||||
watch(
|
||||
() => props.fields,
|
||||
() => {
|
||||
initFormData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-form {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.search-form {
|
||||
padding: 16px;
|
||||
|
||||
:deep(.ant-form) {
|
||||
.ant-form-item {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-form-item-control-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,551 +0,0 @@
|
||||
<template>
|
||||
<div class="tabs-view">
|
||||
<!-- 标签页导航 -->
|
||||
<div class="tabs-nav" ref="tabsNavRef">
|
||||
<div class="tabs-nav-scroll" :style="{ transform: `translateX(${scrollOffset}px)` }">
|
||||
<div
|
||||
v-for="tab in tabsStore.openTabs"
|
||||
:key="tab.path"
|
||||
:class="[
|
||||
'tab-item',
|
||||
{ 'active': tab.active },
|
||||
{ 'closable': tab.closable }
|
||||
]"
|
||||
@click="handleTabClick(tab)"
|
||||
@contextmenu.prevent="handleTabContextMenu(tab, $event)"
|
||||
>
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<CloseOutlined
|
||||
v-if="tab.closable"
|
||||
class="tab-close"
|
||||
@click.stop="handleTabClose(tab)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 滚动控制按钮 -->
|
||||
<div class="tabs-nav-controls">
|
||||
<LeftOutlined
|
||||
:class="['nav-btn', { 'disabled': scrollOffset >= 0 }]"
|
||||
@click="scrollTabs('left')"
|
||||
/>
|
||||
<RightOutlined
|
||||
:class="['nav-btn', { 'disabled': scrollOffset <= maxScrollOffset }]"
|
||||
@click="scrollTabs('right')"
|
||||
/>
|
||||
<MoreOutlined
|
||||
class="nav-btn"
|
||||
@click="showTabsMenu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页内容 -->
|
||||
<div class="tabs-content">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive :include="tabsStore.cachedViews">
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
v-if="Component"
|
||||
/>
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
:class="['context-menu']"
|
||||
:style="{
|
||||
left: contextMenu.x + 'px',
|
||||
top: contextMenu.y + 'px'
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<div class="menu-item" @click="refreshTab(contextMenu.tab)">
|
||||
<ReloadOutlined />
|
||||
刷新页面
|
||||
</div>
|
||||
<div
|
||||
v-if="contextMenu.tab.closable"
|
||||
class="menu-item"
|
||||
@click="closeTab(contextMenu.tab)"
|
||||
>
|
||||
<CloseOutlined />
|
||||
关闭标签
|
||||
</div>
|
||||
<div class="menu-divider"></div>
|
||||
<div class="menu-item" @click="closeOtherTabs(contextMenu.tab)">
|
||||
<CloseCircleOutlined />
|
||||
关闭其他
|
||||
</div>
|
||||
<div class="menu-item" @click="closeLeftTabs(contextMenu.tab)">
|
||||
<VerticalLeftOutlined />
|
||||
关闭左侧
|
||||
</div>
|
||||
<div class="menu-item" @click="closeRightTabs(contextMenu.tab)">
|
||||
<VerticalRightOutlined />
|
||||
关闭右侧
|
||||
</div>
|
||||
<div class="menu-item" @click="closeAllTabs">
|
||||
<CloseSquareOutlined />
|
||||
关闭全部
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页列表菜单 -->
|
||||
<a-dropdown
|
||||
v-model:open="tabsMenuVisible"
|
||||
:trigger="['click']"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-for="tab in tabsStore.openTabs"
|
||||
:key="tab.path"
|
||||
@click="handleTabClick(tab)"
|
||||
>
|
||||
<span :class="{ 'active-tab': tab.active }">
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 遮罩层,用于关闭右键菜单 -->
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="context-menu-overlay"
|
||||
@click="hideContextMenu"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
import {
|
||||
CloseOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
MoreOutlined,
|
||||
ReloadOutlined,
|
||||
CloseCircleOutlined,
|
||||
VerticalLeftOutlined,
|
||||
VerticalRightOutlined,
|
||||
CloseSquareOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
// 标签页导航引用
|
||||
const tabsNavRef = ref(null)
|
||||
|
||||
// 滚动偏移量
|
||||
const scrollOffset = ref(0)
|
||||
|
||||
// 最大滚动偏移量
|
||||
const maxScrollOffset = computed(() => {
|
||||
if (!tabsNavRef.value) return 0
|
||||
const navWidth = tabsNavRef.value.clientWidth - 120 // 减去控制按钮宽度
|
||||
const scrollWidth = tabsNavRef.value.querySelector('.tabs-nav-scroll')?.scrollWidth || 0
|
||||
return Math.min(0, navWidth - scrollWidth)
|
||||
})
|
||||
|
||||
// 右键菜单状态
|
||||
const contextMenu = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
tab: null
|
||||
})
|
||||
|
||||
// 标签页菜单显示状态
|
||||
const tabsMenuVisible = ref(false)
|
||||
|
||||
/**
|
||||
* 处理标签页点击
|
||||
*/
|
||||
const handleTabClick = (tab) => {
|
||||
if (tab.path !== route.path) {
|
||||
router.push(tab.path)
|
||||
}
|
||||
tabsStore.setActiveTab(tab.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签页关闭
|
||||
*/
|
||||
const handleTabClose = (tab) => {
|
||||
if (!tab.closable) return
|
||||
|
||||
tabsStore.removeTab(tab.path)
|
||||
|
||||
// 如果关闭的是当前标签页,跳转到其他标签页
|
||||
if (tab.active && tabsStore.openTabs.length > 0) {
|
||||
const activeTab = tabsStore.openTabs.find(t => t.active)
|
||||
if (activeTab) {
|
||||
router.push(activeTab.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签页右键菜单
|
||||
*/
|
||||
const handleTabContextMenu = (tab, event) => {
|
||||
contextMenu.visible = true
|
||||
contextMenu.x = event.clientX
|
||||
contextMenu.y = event.clientY
|
||||
contextMenu.tab = tab
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏右键菜单
|
||||
*/
|
||||
const hideContextMenu = () => {
|
||||
contextMenu.visible = false
|
||||
contextMenu.tab = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新标签页
|
||||
*/
|
||||
const refreshTab = (tab) => {
|
||||
tabsStore.refreshTab(tab.path)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭标签页
|
||||
*/
|
||||
const closeTab = (tab) => {
|
||||
handleTabClose(tab)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭其他标签页
|
||||
*/
|
||||
const closeOtherTabs = (tab) => {
|
||||
tabsStore.closeOtherTabs(tab.path)
|
||||
if (tab.path !== route.path) {
|
||||
router.push(tab.path)
|
||||
}
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭左侧标签页
|
||||
*/
|
||||
const closeLeftTabs = (tab) => {
|
||||
tabsStore.closeLeftTabs(tab.path)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭右侧标签页
|
||||
*/
|
||||
const closeRightTabs = (tab) => {
|
||||
tabsStore.closeRightTabs(tab.path)
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有标签页
|
||||
*/
|
||||
const closeAllTabs = () => {
|
||||
tabsStore.closeAllTabs()
|
||||
const activeTab = tabsStore.openTabs[0]
|
||||
if (activeTab && activeTab.path !== route.path) {
|
||||
router.push(activeTab.path)
|
||||
}
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动标签页
|
||||
*/
|
||||
const scrollTabs = (direction) => {
|
||||
const step = 200
|
||||
if (direction === 'left') {
|
||||
scrollOffset.value = Math.min(0, scrollOffset.value + step)
|
||||
} else {
|
||||
scrollOffset.value = Math.max(maxScrollOffset.value, scrollOffset.value - step)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示标签页菜单
|
||||
*/
|
||||
const showTabsMenu = () => {
|
||||
tabsMenuVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听路由变化,添加标签页
|
||||
*/
|
||||
const addCurrentRouteTab = () => {
|
||||
const { path, meta, name } = route
|
||||
|
||||
if (meta.hidden) return
|
||||
|
||||
const tab = {
|
||||
path,
|
||||
title: meta.title || name || '未命名页面',
|
||||
name: name,
|
||||
closable: meta.closable !== false
|
||||
}
|
||||
|
||||
tabsStore.addTab(tab)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听点击事件,关闭右键菜单
|
||||
*/
|
||||
const handleDocumentClick = () => {
|
||||
if (contextMenu.visible) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听窗口大小变化,调整滚动偏移量
|
||||
*/
|
||||
const handleWindowResize = () => {
|
||||
nextTick(() => {
|
||||
if (scrollOffset.value < maxScrollOffset.value) {
|
||||
scrollOffset.value = maxScrollOffset.value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 添加当前路由标签页
|
||||
addCurrentRouteTab()
|
||||
|
||||
// 监听文档点击事件
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
// 监听路由变化
|
||||
router.afterEach((to) => {
|
||||
if (!to.meta.hidden) {
|
||||
const tab = {
|
||||
path: to.path,
|
||||
title: to.meta.title || to.name || '未命名页面',
|
||||
name: to.name,
|
||||
closable: to.meta.closable !== false
|
||||
}
|
||||
|
||||
tabsStore.addTab(tab)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabs-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tabs-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.tabs-nav-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
transition: transform 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
margin: 4px 2px;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
max-width: 200px;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #fff;
|
||||
|
||||
.tab-close {
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
margin-left: 8px;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
min-width: 120px;
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.active-tab {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.tabs-nav {
|
||||
.tab-item {
|
||||
min-width: 60px;
|
||||
max-width: 120px;
|
||||
padding: 0 8px;
|
||||
|
||||
.tab-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
min-width: 100px;
|
||||
|
||||
.menu-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,312 +0,0 @@
|
||||
<template>
|
||||
<div class="sidebar-menu">
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
:inline-collapsed="collapsed"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<a-menu-item
|
||||
v-if="!item.children"
|
||||
:key="item.key"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="item.icon" />
|
||||
</template>
|
||||
<span>{{ item.title }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-sub-menu
|
||||
v-else
|
||||
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="item.icon" />
|
||||
</template>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<!-- :key="item.key" -->
|
||||
<a-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
:disabled="child.disabled"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="child.icon" />
|
||||
</template>
|
||||
<span>{{ child.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
</a-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
HomeOutlined,
|
||||
MonitorOutlined,
|
||||
AuditOutlined,
|
||||
LinkOutlined,
|
||||
AlertOutlined,
|
||||
FileTextOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
SafetyOutlined,
|
||||
TeamOutlined,
|
||||
DatabaseOutlined,
|
||||
KeyOutlined,
|
||||
SolutionOutlined,
|
||||
MedicineBoxOutlined,
|
||||
CustomerServiceOutlined,
|
||||
EyeOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
|
||||
// 菜单配置
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
key: '/dashboard',
|
||||
title: '仪表盘',
|
||||
icon: DashboardOutlined,
|
||||
path: '/dashboard'
|
||||
},
|
||||
{
|
||||
key: '/breeding',
|
||||
title: '养殖管理',
|
||||
icon: HomeOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/breeding/farms',
|
||||
title: '养殖场管理',
|
||||
icon: HomeOutlined,
|
||||
path: '/breeding/farms'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/monitoring',
|
||||
title: '健康监控',
|
||||
icon: MonitorOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/monitoring/health',
|
||||
title: '动物健康监控',
|
||||
icon: SafetyOutlined,
|
||||
path: '/monitoring/health'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/inspection',
|
||||
title: '检查管理',
|
||||
icon: AuditOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/inspection/management',
|
||||
title: '检查管理',
|
||||
icon: AuditOutlined,
|
||||
path: '/inspection/management'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/traceability',
|
||||
title: '溯源系统',
|
||||
icon: LinkOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/traceability/system',
|
||||
title: '产品溯源',
|
||||
icon: LinkOutlined,
|
||||
path: '/traceability/system'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/emergency',
|
||||
title: '应急响应',
|
||||
icon: AlertOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/emergency/response',
|
||||
title: '应急响应',
|
||||
icon: AlertOutlined,
|
||||
path: '/emergency/response'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/policy',
|
||||
title: '政策管理',
|
||||
icon: FileTextOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/policy/management',
|
||||
title: '政策管理',
|
||||
icon: FileTextOutlined,
|
||||
path: '/policy/management'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/statistics',
|
||||
title: '数据统计',
|
||||
icon: BarChartOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/statistics/data',
|
||||
title: '数据统计',
|
||||
icon: BarChartOutlined,
|
||||
path: '/statistics/data'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/reports',
|
||||
title: '报表中心',
|
||||
icon: FileTextOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/reports/center',
|
||||
title: '报表中心',
|
||||
icon: FileTextOutlined,
|
||||
path: '/reports/center'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
title: '系统设置',
|
||||
icon: SettingOutlined,
|
||||
children: [
|
||||
{
|
||||
key: '/settings/system',
|
||||
title: '系统设置',
|
||||
icon: SettingOutlined,
|
||||
path: '/settings/system'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const findMenuItem = (items, targetKey) => {
|
||||
for (const item of items) {
|
||||
if (item.key === targetKey) {
|
||||
return item
|
||||
}
|
||||
if (item.children) {
|
||||
const found = findMenuItem(item.children, targetKey)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const menuItem = findMenuItem(menuItems.value, key)
|
||||
if (menuItem && menuItem.path) {
|
||||
router.push(menuItem.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前路由设置选中状态
|
||||
const updateSelectedKeys = () => {
|
||||
const currentPath = route.path
|
||||
selectedKeys.value = [currentPath]
|
||||
|
||||
// 自动展开父级菜单
|
||||
const findParentKey = (items, targetPath, parentKey = null) => {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
if (child.path === targetPath) {
|
||||
return item.key
|
||||
}
|
||||
}
|
||||
const found = findParentKey(item.children, targetPath, item.key)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return parentKey
|
||||
}
|
||||
|
||||
const parentKey = findParentKey(menuItems.value, currentPath)
|
||||
if (parentKey && !openKeys.value.includes(parentKey)) {
|
||||
openKeys.value = [...openKeys.value, parentKey]
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(route, updateSelectedKeys, { immediate: true })
|
||||
|
||||
// 监听折叠状态变化
|
||||
watch(() => props.collapsed, (collapsed) => {
|
||||
if (collapsed) {
|
||||
openKeys.value = []
|
||||
} else {
|
||||
updateSelectedKeys()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-menu {
|
||||
height: 100%;
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-right: none;
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu-title {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background-color: #1890ff !important;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu-selected {
|
||||
.ant-menu-submenu-title {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item-icon,
|
||||
.ant-menu-submenu-title .ant-menu-item-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,271 +0,0 @@
|
||||
<template>
|
||||
<div class="tabs-view">
|
||||
<a-tabs
|
||||
v-model:activeKey="activeKey"
|
||||
type="editable-card"
|
||||
hide-add
|
||||
@edit="onEdit"
|
||||
@change="onChange"
|
||||
>
|
||||
<a-tab-pane
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:tab="tab.title"
|
||||
:closable="tab.closable"
|
||||
>
|
||||
<template #tab>
|
||||
<span class="tab-title">
|
||||
<component v-if="tab.icon" :is="tab.icon" class="tab-icon" />
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<a-dropdown
|
||||
v-model:open="contextMenuVisible"
|
||||
:trigger="['contextmenu']"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<div ref="contextMenuTarget" class="context-menu-target"></div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleContextMenu">
|
||||
<a-menu-item key="refresh">
|
||||
<ReloadOutlined />
|
||||
刷新页面
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="close">
|
||||
<CloseOutlined />
|
||||
关闭标签
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeOthers">
|
||||
<CloseCircleOutlined />
|
||||
关闭其他
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeAll">
|
||||
<CloseSquareOutlined />
|
||||
关闭全部
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="closeLeft">
|
||||
<VerticalLeftOutlined />
|
||||
关闭左侧
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeRight">
|
||||
<VerticalRightOutlined />
|
||||
关闭右侧
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTabsStore } from '@/stores/tabs'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
CloseCircleOutlined,
|
||||
CloseSquareOutlined,
|
||||
VerticalLeftOutlined,
|
||||
VerticalRightOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
const activeKey = ref('')
|
||||
const contextMenuVisible = ref(false)
|
||||
const contextMenuTarget = ref(null)
|
||||
const currentContextTab = ref(null)
|
||||
|
||||
// 标签页列表
|
||||
const tabs = computed(() => tabsStore.tabs)
|
||||
|
||||
// 处理标签页变化
|
||||
const onChange = (key) => {
|
||||
const tab = tabs.value.find(t => t.key === key)
|
||||
if (tab) {
|
||||
router.push(tab.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理标签页编辑(关闭)
|
||||
const onEdit = (targetKey, action) => {
|
||||
if (action === 'remove') {
|
||||
closeTab(targetKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭标签页
|
||||
const closeTab = (key) => {
|
||||
const tab = tabs.value.find(t => t.key === key)
|
||||
if (tab && tab.closable) {
|
||||
tabsStore.removeTab(key)
|
||||
|
||||
// 如果关闭的是当前标签,跳转到最后一个标签
|
||||
if (key === activeKey.value && tabs.value.length > 0) {
|
||||
const lastTab = tabs.value[tabs.value.length - 1]
|
||||
router.push(lastTab.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右键菜单处理
|
||||
const handleContextMenu = ({ key }) => {
|
||||
const currentTab = currentContextTab.value
|
||||
if (!currentTab) return
|
||||
|
||||
switch (key) {
|
||||
case 'refresh':
|
||||
// 刷新当前页面
|
||||
router.go(0)
|
||||
break
|
||||
case 'close':
|
||||
closeTab(currentTab.key)
|
||||
break
|
||||
case 'closeOthers':
|
||||
tabsStore.closeOtherTabs(currentTab.key)
|
||||
break
|
||||
case 'closeAll':
|
||||
tabsStore.closeAllTabs()
|
||||
router.push('/dashboard')
|
||||
break
|
||||
case 'closeLeft':
|
||||
tabsStore.closeLeftTabs(currentTab.key)
|
||||
break
|
||||
case 'closeRight':
|
||||
tabsStore.closeRightTabs(currentTab.key)
|
||||
break
|
||||
}
|
||||
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
// 监听路由变化,添加标签页
|
||||
watch(route, (newRoute) => {
|
||||
if (newRoute.meta && newRoute.meta.title) {
|
||||
const tab = {
|
||||
key: newRoute.path,
|
||||
path: newRoute.path,
|
||||
title: newRoute.meta.title,
|
||||
icon: newRoute.meta.icon,
|
||||
closable: !newRoute.meta.affix
|
||||
}
|
||||
|
||||
tabsStore.addTab(tab)
|
||||
activeKey.value = newRoute.path
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听标签页变化
|
||||
watch(tabs, (newTabs) => {
|
||||
if (newTabs.length === 0) {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 添加右键菜单事件监听
|
||||
const addContextMenuListener = () => {
|
||||
nextTick(() => {
|
||||
const tabsContainer = document.querySelector('.ant-tabs-nav')
|
||||
if (tabsContainer) {
|
||||
tabsContainer.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 查找被右键点击的标签
|
||||
const tabElement = e.target.closest('.ant-tabs-tab')
|
||||
if (tabElement) {
|
||||
const tabKey = tabElement.getAttribute('data-node-key')
|
||||
const tab = tabs.value.find(t => t.key === tabKey)
|
||||
if (tab) {
|
||||
currentContextTab.value = tab
|
||||
contextMenuVisible.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载后添加事件监听
|
||||
watch(tabs, addContextMenuListener, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabs-view {
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
:deep(.ant-tabs) {
|
||||
.ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
|
||||
.ant-tabs-nav-wrap {
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin-right: 4px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
|
||||
.tab-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-remove {
|
||||
margin-left: 8px;
|
||||
color: #999;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-target {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
103
government-admin/src/components/modal/SupervisionEntityModal.vue
Normal file
103
government-admin/src/components/modal/SupervisionEntityModal.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
width={600}
|
||||
@cancel="handleClose"
|
||||
centered
|
||||
>
|
||||
<supervision-entity-form
|
||||
ref="formRef"
|
||||
:entity-id="entityId"
|
||||
:entity-types="entityTypes"
|
||||
@success="handleFormSuccess"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="js" setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import SupervisionEntityForm from '@/views/supervision-entities/Form.vue'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
entityId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
entityTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create'
|
||||
}
|
||||
})
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits([
|
||||
'cancel',
|
||||
'submit'
|
||||
])
|
||||
|
||||
// 表单组件引用
|
||||
const formRef = ref<any>(null)
|
||||
|
||||
// 计算弹窗标题
|
||||
const title = computed(() => {
|
||||
return props.mode === 'create' ? '新增监管实体' : '编辑监管实体'
|
||||
})
|
||||
|
||||
// 监听 visible 变化,当弹窗显示时重置表单
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && formRef.value) {
|
||||
// 短暂延迟确保表单组件已更新 entityId
|
||||
setTimeout(() => {
|
||||
// 如果是创建模式,重置表单
|
||||
if (props.mode === 'create' && !props.entityId) {
|
||||
formRef.value.resetForm()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 处理关闭弹窗
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 处理表单成功提交
|
||||
const handleFormSuccess = () => {
|
||||
message.success(props.mode === 'create' ? '创建成功' : '更新成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件调用
|
||||
defineExpose({
|
||||
resetForm: () => {
|
||||
if (formRef.value) {
|
||||
formRef.value.resetForm()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 弹窗样式可以根据需要调整 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user