更新政府端和银行端

This commit is contained in:
2025-09-17 18:04:28 +08:00
parent f35ceef31f
commit e4287b83fe
185 changed files with 78320 additions and 189 deletions

View File

@@ -0,0 +1,76 @@
<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>

View File

@@ -0,0 +1,89 @@
<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>

View File

@@ -0,0 +1,196 @@
<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>

View File

@@ -0,0 +1,205 @@
<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>

View File

@@ -0,0 +1,200 @@
<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>

View File

@@ -0,0 +1,185 @@
<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>

View File

@@ -0,0 +1,179 @@
<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>

View File

@@ -0,0 +1,22 @@
// 图表组件统一导出
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
}

View File

@@ -0,0 +1,272 @@
<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>

View File

@@ -0,0 +1,80 @@
<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>

View File

@@ -0,0 +1,83 @@
<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>

View File

@@ -0,0 +1,106 @@
<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>

View File

@@ -0,0 +1,210 @@
<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>

View File

@@ -0,0 +1,551 @@
<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>

View File

@@ -0,0 +1,312 @@
<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>

View File

@@ -0,0 +1,271 @@
<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>