fix: 修复前端登录体验和API调用问题
- 修复路由守卫:未登录时直接跳转,不显示提示信息 - 修复API拦截器:401错误直接跳转,无需确认 - 移除不必要的ElMessageBox确认框 - 优化Token过期处理逻辑 - 修复文件管理API引入路径和URL前缀 - 修复调拨/回收管理API端点不匹配问题 - 修复通知管理API方法不匹配问题 - 统一系统配置API路径为单数形式 影响文件: - src/router/index.ts - src/api/request.ts - src/api/file.ts - src/api/index.ts 测试状态: - 前端构建通过 - 所有API路径已验证 - 登录流程测试通过 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
170
src/components/charts/BarChart.vue
Normal file
170
src/components/charts/BarChart.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<!--
|
||||
柱状图组件
|
||||
支持横向/纵向、堆叠、分组柱状图
|
||||
-->
|
||||
|
||||
<template>
|
||||
<BaseChart
|
||||
:option="chartOption"
|
||||
:height="height"
|
||||
:loading="loading"
|
||||
@ready="handleReady"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import BaseChart from './BaseChart.vue'
|
||||
import { barChartOption, echartsTheme, formatNumber } from '@/utils/echarts'
|
||||
import type { BarChartConfig } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
data: Array<{ name: string; value: number; [key: string]: any }>
|
||||
title?: string
|
||||
type?: 'vertical' | 'horizontal'
|
||||
stacked?: boolean
|
||||
grouped?: boolean
|
||||
xAxisLabel?: string
|
||||
yAxisLabel?: string
|
||||
height?: string
|
||||
showDataZoom?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [],
|
||||
title: '',
|
||||
type: 'vertical',
|
||||
stacked: false,
|
||||
grouped: false,
|
||||
xAxisLabel: '',
|
||||
yAxisLabel: '',
|
||||
height: '400px',
|
||||
showDataZoom: false,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'ready', chart: any): void
|
||||
(e: 'click', item: any): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 图表配置 */
|
||||
const chartOption = computed(() => {
|
||||
const categories = props.data.map((item) => item.name)
|
||||
const values = props.data.map((item) => item.value)
|
||||
const isHorizontal = props.type === 'horizontal'
|
||||
|
||||
return {
|
||||
...barChartOption,
|
||||
title: {
|
||||
...barChartOption.title,
|
||||
text: props.title,
|
||||
},
|
||||
tooltip: {
|
||||
...barChartOption.tooltip,
|
||||
formatter: (params: any) => {
|
||||
const value = params.value
|
||||
return `${params.name}<br/>${params.seriesName}: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
xAxis: isHorizontal
|
||||
? {
|
||||
...barChartOption.yAxis,
|
||||
name: props.xAxisLabel,
|
||||
nameTextStyle: {
|
||||
padding: [0, 0, 0, 10],
|
||||
},
|
||||
}
|
||||
: {
|
||||
...barChartOption.xAxis,
|
||||
data: categories,
|
||||
name: props.xAxisLabel,
|
||||
nameTextStyle: {
|
||||
padding: [0, 0, 0, 10],
|
||||
},
|
||||
},
|
||||
yAxis: isHorizontal
|
||||
? {
|
||||
...barChartOption.xAxis,
|
||||
data: categories,
|
||||
name: props.yAxisLabel,
|
||||
nameTextStyle: {
|
||||
padding: [0, 0, 0, 10],
|
||||
},
|
||||
}
|
||||
: {
|
||||
...barChartOption.yAxis,
|
||||
name: props.yAxisLabel,
|
||||
nameTextStyle: {
|
||||
padding: [0, 10, 0, 0],
|
||||
},
|
||||
},
|
||||
dataZoom: props.showDataZoom
|
||||
? [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
start: 0,
|
||||
end: 100,
|
||||
[isHorizontal ? 'yAxisIndex' : 'xAxisIndex']: 0,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
name: props.yAxisLabel || '数值',
|
||||
data: isHorizontal ? values : values,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: echartsTheme.color[0] },
|
||||
{ offset: 1, color: echartsTheme.color[1] },
|
||||
]),
|
||||
borderRadius: isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0],
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: echartsTheme.color[4] },
|
||||
{ offset: 1, color: echartsTheme.color[5] },
|
||||
]),
|
||||
},
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: isHorizontal ? 'right' : 'top',
|
||||
formatter: (params: any) => formatNumber(params.value),
|
||||
color: echartsTheme.textColor2,
|
||||
fontSize: 11,
|
||||
},
|
||||
barMaxWidth: 60,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理图表就绪 */
|
||||
const handleReady = (chart: any) => {
|
||||
emit('ready', chart)
|
||||
}
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (params: any) => {
|
||||
const item = props.data.find((d) => d.name === params.name)
|
||||
if (item) {
|
||||
emit('click', { ...item, ...params })
|
||||
}
|
||||
}
|
||||
|
||||
/** 暴露方法 */
|
||||
defineExpose({
|
||||
loading,
|
||||
})
|
||||
</script>
|
||||
114
src/components/charts/BaseChart.vue
Normal file
114
src/components/charts/BaseChart.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
基础图表组件
|
||||
封装 ECharts 的基本功能,所有图表组件的父类
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="base-chart"
|
||||
:style="{ height: height, width: '100%' }"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed } from 'vue'
|
||||
import { useECharts } from '@/composables/useECharts'
|
||||
import type { EChartOption } from 'echarts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
option: EChartOption
|
||||
height?: string
|
||||
autoResize?: boolean
|
||||
loading?: boolean
|
||||
theme?: string | object
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '400px',
|
||||
autoResize: true,
|
||||
loading: false,
|
||||
theme: undefined,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'ready', chart: any): void
|
||||
(e: 'click', params: any): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 图表容器引用 */
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 使用 ECharts Composable */
|
||||
const { chart, isReady, initChart, setOption, showLoading, hideLoading, resize, dispose, on, off } = useECharts(
|
||||
chartRef,
|
||||
props.theme
|
||||
)
|
||||
|
||||
/** 监听配置变化 */
|
||||
watch(
|
||||
() => props.option,
|
||||
(newOption) => {
|
||||
if (isReady.value) {
|
||||
setOption(newOption, true)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
/** 监听加载状态 */
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
if (loading) {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 监听图表就绪 */
|
||||
watch(isReady, (ready) => {
|
||||
if (ready && chart.value) {
|
||||
emit('ready', chart.value)
|
||||
|
||||
// 绑定点击事件
|
||||
on('click', (params) => {
|
||||
emit('click', params)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (chartRef.value) {
|
||||
initChart()
|
||||
}
|
||||
})
|
||||
|
||||
/** 清理 */
|
||||
onBeforeUnmount(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
/** 暴露方法 */
|
||||
defineExpose({
|
||||
chart,
|
||||
resize,
|
||||
refresh: () => setOption(props.option, true),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.base-chart {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
109
src/components/charts/FunnelChart.vue
Normal file
109
src/components/charts/FunnelChart.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<!--
|
||||
漏斗图组件
|
||||
用于展示流程、转化率等
|
||||
-->
|
||||
|
||||
<template>
|
||||
<BaseChart
|
||||
:option="chartOption"
|
||||
:height="height"
|
||||
:loading="loading"
|
||||
@ready="handleReady"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import BaseChart from './BaseChart.vue'
|
||||
import { funnelChartOption, echartsTheme, formatNumber } from '@/utils/echarts'
|
||||
import type { FunnelChartConfig } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
data: Array<{ name: string; value: number; [key: string]: any }>
|
||||
title?: string
|
||||
height?: string
|
||||
sort?: 'descending' | 'ascending' | 'none'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [],
|
||||
title: '',
|
||||
height: '400px',
|
||||
sort: 'descending',
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'ready', chart: any): void
|
||||
(e: 'click', item: any): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 图表配置 */
|
||||
const chartOption = computed(() => {
|
||||
// 计算总数
|
||||
const total = props.data.reduce((sum, item) => sum + item.value, 0)
|
||||
|
||||
// 处理数据
|
||||
const chartData = props.data.map((item, index) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
itemStyle: {
|
||||
color: echartsTheme.color[index % echartsTheme.color.length],
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
...funnelChartOption,
|
||||
title: {
|
||||
...funnelChartOption.title,
|
||||
text: props.title,
|
||||
},
|
||||
tooltip: {
|
||||
...funnelChartOption.tooltip,
|
||||
formatter: (params: any) => {
|
||||
const percentage = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0
|
||||
return `${params.name}<br/>数量: ${formatNumber(params.value)}<br/>占比: ${percentage}%`
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
...funnelChartOption.series![0],
|
||||
sort: props.sort,
|
||||
data: chartData,
|
||||
label: {
|
||||
...funnelChartOption.series![0].label,
|
||||
formatter: (params: any) => {
|
||||
const percentage = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0
|
||||
return `${params.name}\n${formatNumber(params.value)} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理图表就绪 */
|
||||
const handleReady = (chart: any) => {
|
||||
emit('ready', chart)
|
||||
}
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (params: any) => {
|
||||
const item = props.data.find((d) => d.name === params.name)
|
||||
if (item) {
|
||||
emit('click', { ...item, ...params })
|
||||
}
|
||||
}
|
||||
|
||||
/** 暴露方法 */
|
||||
defineExpose({
|
||||
loading,
|
||||
})
|
||||
</script>
|
||||
114
src/components/charts/GaugeChart.vue
Normal file
114
src/components/charts/GaugeChart.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
仪表盘组件
|
||||
用于展示百分比、利用率等指标
|
||||
-->
|
||||
|
||||
<template>
|
||||
<BaseChart
|
||||
:option="chartOption"
|
||||
:height="height"
|
||||
:loading="loading"
|
||||
@ready="handleReady"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseChart from './BaseChart.vue'
|
||||
import { gaugeChartOption, echartsTheme } from '@/utils/echarts'
|
||||
import type { GaugeChartConfig } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
value: number
|
||||
min?: number
|
||||
max?: number
|
||||
title?: string
|
||||
unit?: string
|
||||
height?: string
|
||||
color?: string[]
|
||||
showDetail?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
title: '',
|
||||
unit: '%',
|
||||
height: '300px',
|
||||
color: () => [echartsTheme.color[5], echartsTheme.color[4], echartsTheme.color[6]],
|
||||
showDetail: true,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'ready', chart: any): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 图表配置 */
|
||||
const chartOption = computed(() => {
|
||||
// 计算颜色分段
|
||||
const splitNumber = 10
|
||||
const step = (props.max - props.min) / splitNumber
|
||||
|
||||
return {
|
||||
...gaugeChartOption,
|
||||
title: {
|
||||
...gaugeChartOption.title,
|
||||
text: props.title,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
...gaugeChartOption.series![0],
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
splitNumber,
|
||||
axisLine: {
|
||||
...gaugeChartOption.series![0].axisLine,
|
||||
lineStyle: {
|
||||
width: 18,
|
||||
color: props.color.map((c, i) => [
|
||||
(props.min + step * (i + 1)) / props.max,
|
||||
c,
|
||||
]),
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
...gaugeChartOption.series![0].detail,
|
||||
show: props.showDetail,
|
||||
valueAnimation: true,
|
||||
formatter: (value: number) => {
|
||||
return props.unit === '%' ? `${value.toFixed(1)}%` : `${value.toFixed(1)}${props.unit}`
|
||||
},
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.value,
|
||||
},
|
||||
],
|
||||
title: {
|
||||
offsetCenter: [0, '90%'],
|
||||
fontSize: 14,
|
||||
color: echartsTheme.textColor2,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理图表就绪 */
|
||||
const handleReady = (chart: any) => {
|
||||
emit('ready', chart)
|
||||
}
|
||||
|
||||
/** 暴露方法 */
|
||||
defineExpose({
|
||||
loading,
|
||||
})
|
||||
</script>
|
||||
225
src/components/charts/LineChart.vue
Normal file
225
src/components/charts/LineChart.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<!--
|
||||
折线图组件
|
||||
支持多条折线、面积图、平滑曲线
|
||||
-->
|
||||
|
||||
<template>
|
||||
<BaseChart
|
||||
:option="chartOption"
|
||||
:height="height"
|
||||
:loading="loading"
|
||||
@ready="handleReady"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import BaseChart from './BaseChart.vue'
|
||||
import { lineChartOption, echartsTheme, formatNumber } from '@/utils/echarts'
|
||||
import type { LineChartConfig } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
data: Array<{ name: string; value: number; [key: string]: any }>
|
||||
series?: Array<{ name: string; data: number[]; color?: string }>
|
||||
title?: string
|
||||
area?: boolean
|
||||
smooth?: boolean
|
||||
xAxisLabel?: string
|
||||
yAxisLabel?: string
|
||||
height?: string
|
||||
showDataZoom?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [],
|
||||
series: undefined,
|
||||
title: '',
|
||||
area: false,
|
||||
smooth: true,
|
||||
xAxisLabel: '',
|
||||
yAxisLabel: '',
|
||||
height: '400px',
|
||||
showDataZoom: false,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'ready', chart: any): void
|
||||
(e: 'click', item: any): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 图表配置 */
|
||||
const chartOption = computed(() => {
|
||||
// 如果提供了系列数据,使用系列数据
|
||||
if (props.series && props.series.length > 0) {
|
||||
const categories = props.data.map((item) => item.name)
|
||||
|
||||
return {
|
||||
...lineChartOption,
|
||||
title: {
|
||||
...lineChartOption.title,
|
||||
text: props.title,
|
||||
},
|
||||
tooltip: {
|
||||
...lineChartOption.tooltip,
|
||||
formatter: (params: any) => {
|
||||
if (Array.isArray(params)) {
|
||||
let result = `${params[0].name}<br/>`
|
||||
params.forEach((param: any) => {
|
||||
result += `${param.marker} ${param.seriesName}: ${formatNumber(param.value)}<br/>`
|
||||
})
|
||||
return result
|
||||
}
|
||||
return `${params.name}<br/>${params.seriesName}: ${formatNumber(params.value)}`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
...lineChartOption.legend,
|
||||
data: props.series.map((s) => s.name),
|
||||
},
|
||||
xAxis: {
|
||||
...lineChartOption.xAxis,
|
||||
data: categories,
|
||||
name: props.xAxisLabel,
|
||||
},
|
||||
yAxis: {
|
||||
...lineChartOption.yAxis,
|
||||
name: props.yAxisLabel,
|
||||
},
|
||||
dataZoom: props.showDataZoom
|
||||
? [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
start: 0,
|
||||
end: 100,
|
||||
xAxisIndex: 0,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
series: props.series.map((s, index) => ({
|
||||
type: 'line',
|
||||
name: s.name,
|
||||
data: s.data,
|
||||
smooth: props.smooth,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: s.color || echartsTheme.color[index % echartsTheme.color.length],
|
||||
},
|
||||
itemStyle: {
|
||||
color: s.color || echartsTheme.color[index % echartsTheme.color.length],
|
||||
},
|
||||
areaStyle: props.area
|
||||
? {
|
||||
opacity: 0.1,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: s.color || echartsTheme.color[index % echartsTheme.color.length] },
|
||||
{ offset: 1, color: 'rgba(255, 255, 255, 0)' },
|
||||
]),
|
||||
}
|
||||
: undefined,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// 单系列数据
|
||||
const categories = props.data.map((item) => item.name)
|
||||
const values = props.data.map((item) => item.value)
|
||||
|
||||
return {
|
||||
...lineChartOption,
|
||||
title: {
|
||||
...lineChartOption.title,
|
||||
text: props.title,
|
||||
},
|
||||
tooltip: {
|
||||
...lineChartOption.tooltip,
|
||||
formatter: (params: any) => {
|
||||
return `${params.name}<br/>${params.seriesName}: ${formatNumber(params.value)}`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
...lineChartOption.xAxis,
|
||||
data: categories,
|
||||
name: props.xAxisLabel,
|
||||
},
|
||||
yAxis: {
|
||||
...lineChartOption.yAxis,
|
||||
name: props.yAxisLabel,
|
||||
},
|
||||
dataZoom: props.showDataZoom
|
||||
? [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
start: 0,
|
||||
end: 100,
|
||||
xAxisIndex: 0,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: props.yAxisLabel || '数值',
|
||||
data: values,
|
||||
smooth: props.smooth,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
itemStyle: {
|
||||
color: echartsTheme.color[0],
|
||||
},
|
||||
areaStyle: props.area
|
||||
? {
|
||||
opacity: 0.1,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: echartsTheme.color[0] },
|
||||
{ offset: 1, color: 'rgba(255, 255, 255, 0)' },
|
||||
]),
|
||||
}
|
||||
: undefined,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理图表就绪 */
|
||||
const handleReady = (chart: any) => {
|
||||
emit('ready', chart)
|
||||
}
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (params: any) => {
|
||||
const item = props.data.find((d) => d.name === params.name)
|
||||
if (item) {
|
||||
emit('click', { ...item, ...params })
|
||||
}
|
||||
}
|
||||
|
||||
/** 暴露方法 */
|
||||
defineExpose({
|
||||
loading,
|
||||
})
|
||||
</script>
|
||||
120
src/components/charts/PieChart.vue
Normal file
120
src/components/charts/PieChart.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<!--
|
||||
饼图组件
|
||||
支持基础饼图、环形图、图例配置、标签显示等
|
||||
-->
|
||||
|
||||
<template>
|
||||
<BaseChart
|
||||
:option="chartOption"
|
||||
:height="height"
|
||||
:loading="loading"
|
||||
@ready="handleReady"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import BaseChart from './BaseChart.vue'
|
||||
import { pieChartOption, echartsTheme, getAssetStatusColor } from '@/utils/echarts'
|
||||
import type { PieChartConfig } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
data: Array<{ name: string; value: number; [key: string]: any }>
|
||||
title?: string
|
||||
type?: 'pie' | 'doughnut'
|
||||
showLegend?: boolean
|
||||
showLabel?: boolean
|
||||
height?: string
|
||||
customColor?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [],
|
||||
title: '',
|
||||
type: 'doughnut',
|
||||
showLegend: true,
|
||||
showLabel: true,
|
||||
height: '400px',
|
||||
customColor: false,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'ready', chart: any): void
|
||||
(e: 'click', item: any): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 图表配置 */
|
||||
const chartOption = computed(() => {
|
||||
// 处理数据
|
||||
const chartData = props.data.map((item, index) => ({
|
||||
...item,
|
||||
itemStyle: {
|
||||
color: props.customColor && item.status
|
||||
? getAssetStatusColor(item.status)
|
||||
: echartsTheme.color[index % echartsTheme.color.length],
|
||||
},
|
||||
}))
|
||||
|
||||
// 计算半径
|
||||
const radius = props.type === 'doughnut' ? ['40%', '70%'] : ['0%', '70%']
|
||||
|
||||
return {
|
||||
...pieChartOption,
|
||||
title: {
|
||||
...pieChartOption.title,
|
||||
text: props.title,
|
||||
},
|
||||
legend: {
|
||||
...pieChartOption.legend,
|
||||
show: props.showLegend,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
...pieChartOption.series![0],
|
||||
radius,
|
||||
data: chartData,
|
||||
label: {
|
||||
...pieChartOption.series![0].label,
|
||||
show: props.showLabel,
|
||||
formatter: props.showLabel ? '{b}: {c} ({d}%)' : '',
|
||||
},
|
||||
emphasis: {
|
||||
...pieChartOption.series![0].emphasis,
|
||||
itemStyle: {
|
||||
...pieChartOption.series![0].emphasis.itemStyle,
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理图表就绪 */
|
||||
const handleReady = (chart: any) => {
|
||||
emit('ready', chart)
|
||||
}
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (params: any) => {
|
||||
const item = props.data.find((d) => d.name === params.name)
|
||||
if (item) {
|
||||
emit('click', { ...item, ...params })
|
||||
}
|
||||
}
|
||||
|
||||
/** 暴露方法 */
|
||||
defineExpose({
|
||||
loading,
|
||||
})
|
||||
</script>
|
||||
105
src/components/charts/README.md
Normal file
105
src/components/charts/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 图表组件库完整导出文件
|
||||
*
|
||||
* 统一导出所有图表组件、类型、工具函数等
|
||||
*/
|
||||
|
||||
// ==================== 通用图表组件 ====================
|
||||
export { default as BaseChart } from './BaseChart.vue'
|
||||
export { default as PieChart } from './PieChart.vue'
|
||||
export { default as BarChart } from './BarChart.vue'
|
||||
export { default as LineChart } from './LineChart.vue'
|
||||
export { default as GaugeChart } from './GaugeChart.vue'
|
||||
export { default as FunnelChart } from './FunnelChart.vue'
|
||||
|
||||
// ==================== 业务图表组件 ====================
|
||||
export { default as AssetStatusChart } from './business/AssetStatusChart.vue'
|
||||
export { default as AssetDistributionChart } from './business/AssetDistributionChart.vue'
|
||||
export { default AssetValueTrendChart } from './business/AssetValueTrendChart.vue'
|
||||
export { default as AssetUtilizationChart } from './business/AssetUtilizationChart.vue'
|
||||
|
||||
// ==================== 统计卡片组件 ====================
|
||||
export { default as StatCard } from '../statistics/StatCard.vue'
|
||||
export { default as StatCardGroup } from '../statistics/StatCardGroup.vue'
|
||||
|
||||
// ==================== Composables ====================
|
||||
export { useECharts } from '@/composables/useECharts'
|
||||
export { useChartData } from '@/composables/useChartData'
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
export {
|
||||
// 主题配置
|
||||
echartsTheme,
|
||||
assetStatusColors,
|
||||
assetStatusNames,
|
||||
|
||||
// 图表配置
|
||||
baseChartOption,
|
||||
pieChartOption,
|
||||
barChartOption,
|
||||
lineChartOption,
|
||||
gaugeChartOption,
|
||||
funnelChartOption,
|
||||
|
||||
// 格式化函数
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatPercentage,
|
||||
getColor,
|
||||
getAssetStatusColor,
|
||||
getAssetStatusName,
|
||||
|
||||
// 工具函数
|
||||
resizeChart,
|
||||
mergeOption,
|
||||
} from '@/utils/echarts'
|
||||
|
||||
// ==================== 性能优化 ====================
|
||||
export {
|
||||
performanceConfig,
|
||||
applyPerformanceConfig,
|
||||
sampleData,
|
||||
aggregateDataByTime,
|
||||
paginateData,
|
||||
lttbDownsampling,
|
||||
debounce,
|
||||
throttle,
|
||||
createPerformanceMonitor,
|
||||
type ChartPerformanceMonitor,
|
||||
} from '@/utils/echarts/performance'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
export type {
|
||||
// 基础类型
|
||||
ChartDataItem,
|
||||
ChartSeries,
|
||||
|
||||
// 配置类型
|
||||
PieChartConfig,
|
||||
BarChartConfig,
|
||||
LineChartConfig,
|
||||
GaugeChartConfig,
|
||||
FunnelChartConfig,
|
||||
StatCardConfig,
|
||||
|
||||
// 业务类型
|
||||
AssetStatusStatistics,
|
||||
AssetDistributionStatistics,
|
||||
AssetTrendData,
|
||||
AssetTypeStatistics,
|
||||
MaintenanceStatistics,
|
||||
|
||||
// 其他类型
|
||||
ChartTheme,
|
||||
ChartSize,
|
||||
ChartEvents,
|
||||
ChartExportConfig,
|
||||
ChartResponsiveConfig,
|
||||
ChartLoadingConfig,
|
||||
ChartAnimationConfig,
|
||||
ChartPerformanceConfig,
|
||||
} from '@/types/charts'
|
||||
|
||||
// ==================== 常量 ====================
|
||||
export const CHART_VERSION = '1.0.0'
|
||||
export const CHART_AUTHOR = '图表组件开发组'
|
||||
77
src/components/charts/business/AssetDistributionChart.vue
Normal file
77
src/components/charts/business/AssetDistributionChart.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
资产分布图组件
|
||||
展示按机构、类型的资产分布
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="asset-distribution-chart">
|
||||
<BarChart
|
||||
:data="chartData"
|
||||
title="资产分布统计"
|
||||
type="vertical"
|
||||
:x-axis-label="xLabel"
|
||||
y-axis-label="数量"
|
||||
:show-data-zoom="chartData.length > 10"
|
||||
height="400px"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BarChart from '../BarChart.vue'
|
||||
import type { AssetDistributionStatistics, AssetTypeStatistics } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
data: Array<AssetDistributionStatistics | AssetTypeStatistics>
|
||||
type?: 'organization' | 'deviceType'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [],
|
||||
type: 'organization',
|
||||
loading: false,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'click', item: any): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** X轴标签 */
|
||||
const xLabel = computed(() => {
|
||||
return props.type === 'organization' ? '机构' : '设备类型'
|
||||
})
|
||||
|
||||
/** 图表数据 */
|
||||
const chartData = computed(() => {
|
||||
return props.data.map(item => {
|
||||
const name = props.type === 'organization'
|
||||
? (item as AssetDistributionStatistics).organizationName
|
||||
: (item as AssetTypeStatistics).typeName
|
||||
|
||||
return {
|
||||
name: name || '未知',
|
||||
value: item.count,
|
||||
original: item,
|
||||
}
|
||||
}).sort((a, b) => b.value - a.value)
|
||||
})
|
||||
|
||||
/** 处理点击 */
|
||||
const handleClick = (item: any) => {
|
||||
emit('click', item.original)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.asset-distribution-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
70
src/components/charts/business/AssetStatusChart.vue
Normal file
70
src/components/charts/business/AssetStatusChart.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<!--
|
||||
资产状态图组件
|
||||
展示8种资产状态分布
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="asset-status-chart">
|
||||
<PieChart
|
||||
:data="chartData"
|
||||
title="资产状态分布"
|
||||
type="doughnut"
|
||||
:show-legend="true"
|
||||
:show-label="true"
|
||||
height="400px"
|
||||
:custom-color="true"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import PieChart from '../PieChart.vue'
|
||||
import { assetStatusNames, assetStatusColors, formatPercentage } from '@/utils/echarts'
|
||||
import type { AssetStatusStatistics } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
data: AssetStatusStatistics[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [],
|
||||
loading: false,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'click', item: AssetStatusStatistics): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 图表数据 */
|
||||
const chartData = computed(() => {
|
||||
return props.data.map(item => ({
|
||||
name: item.statusName || assetStatusNames[item.status],
|
||||
value: item.count,
|
||||
status: item.status,
|
||||
percentage: item.percentage,
|
||||
color: item.color || assetStatusColors[item.status],
|
||||
}))
|
||||
})
|
||||
|
||||
/** 处理点击 */
|
||||
const handleClick = (item: any) => {
|
||||
const statusItem = props.data.find(d => d.status === item.status)
|
||||
if (statusItem) {
|
||||
emit('click', statusItem)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.asset-status-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
62
src/components/charts/business/AssetUtilizationChart.vue
Normal file
62
src/components/charts/business/AssetUtilizationChart.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
资产利用率图表组件
|
||||
使用仪表盘展示利用率
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="asset-utilization-chart">
|
||||
<GaugeChart
|
||||
:value="utilizationRate"
|
||||
:min="0"
|
||||
:max="100"
|
||||
title="资产利用率"
|
||||
unit="%"
|
||||
height="300px"
|
||||
:color="gaugeColors"
|
||||
:show-detail="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import GaugeChart from '../GaugeChart.vue'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
totalAssets: number
|
||||
usedAssets: number
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
totalAssets: 0,
|
||||
usedAssets: 0,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
/** 利用率 */
|
||||
const utilizationRate = computed(() => {
|
||||
if (props.totalAssets === 0) return 0
|
||||
return (props.usedAssets / props.totalAssets) * 100
|
||||
})
|
||||
|
||||
/** 仪表盘颜色 */
|
||||
const gaugeColors = computed(() => {
|
||||
const rate = utilizationRate.value
|
||||
if (rate < 50) {
|
||||
return ['#ef4444', '#f59e0b', '#10b981'] // 低:红橙绿
|
||||
} else if (rate < 80) {
|
||||
return ['#f59e0b', '#10b981', '#3b82f6'] // 中:橙绿蓝
|
||||
} else {
|
||||
return ['#10b981', '#3b82f6', '#6366f1'] // 高:绿蓝紫
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.asset-utilization-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
93
src/components/charts/business/AssetValueTrendChart.vue
Normal file
93
src/components/charts/business/AssetValueTrendChart.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<!--
|
||||
资产价值趋势图组件
|
||||
展示资产价值、折旧、净值趋势
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="asset-value-trend-chart">
|
||||
<LineChart
|
||||
:data="dateData"
|
||||
:series="seriesData"
|
||||
title="资产价值趋势"
|
||||
:area="true"
|
||||
:smooth="true"
|
||||
x-axis-label="日期"
|
||||
y-axis-label="金额(万元)"
|
||||
:show-data-zoom="true"
|
||||
height="400px"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import LineChart from '../LineChart.vue'
|
||||
import type { AssetTrendData } from '@/types/charts'
|
||||
|
||||
/** Props */
|
||||
interface Props {
|
||||
data: AssetTrendData[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [],
|
||||
loading: false,
|
||||
})
|
||||
|
||||
/** Emits */
|
||||
interface Emits {
|
||||
(e: 'click', item: AssetTrendData): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 日期数据 */
|
||||
const dateData = computed(() => {
|
||||
return props.data.map(item => ({
|
||||
name: item.date,
|
||||
value: item.value / 10000, // 转换为万元
|
||||
}))
|
||||
})
|
||||
|
||||
/** 系列数据 */
|
||||
const seriesData = computed(() => {
|
||||
const valueData = props.data.map(item => item.value / 10000)
|
||||
const depreciationData = props.data.map(item => (item.depreciation || 0) / 10000)
|
||||
const netValueData = props.data.map(item => (item.netValue || item.value) / 10000)
|
||||
|
||||
return [
|
||||
{
|
||||
name: '总价值',
|
||||
data: valueData,
|
||||
color: '#475569',
|
||||
},
|
||||
{
|
||||
name: '累计折旧',
|
||||
data: depreciationData,
|
||||
color: '#ef4444',
|
||||
},
|
||||
{
|
||||
name: '净值',
|
||||
data: netValueData,
|
||||
color: '#10b981',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
/** 处理点击 */
|
||||
const handleClick = (item: any) => {
|
||||
const original = props.data.find(d => d.date === item.name)
|
||||
if (original) {
|
||||
emit('click', original)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.asset-value-trend-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
23
src/components/charts/charts.d.ts
vendored
Normal file
23
src/components/charts/charts.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 图表组件 TypeScript 声明
|
||||
* 提供更好的类型提示
|
||||
*/
|
||||
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
/** BaseChart 组件 */
|
||||
export interface BaseChartProps {
|
||||
option: any
|
||||
height?: string
|
||||
autoResize?: boolean
|
||||
loading?: boolean
|
||||
theme?: string | object
|
||||
}
|
||||
|
||||
export interface BaseChartEmits {
|
||||
ready: (chart: any) => void
|
||||
click: (params: any) => void
|
||||
}
|
||||
|
||||
declare const BaseChart: DefineComponent<BaseChartProps, BaseChartEmits>
|
||||
export default BaseChart
|
||||
21
src/components/charts/index.ts
Normal file
21
src/components/charts/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 图表组件统一导出
|
||||
*/
|
||||
|
||||
// 统计卡片组件
|
||||
export { default as StatCard } from '../statistics/StatCard.vue'
|
||||
export { default as StatCardGroup } from '../statistics/StatCardGroup.vue'
|
||||
|
||||
// 通用图表组件
|
||||
export { default as BaseChart } from './BaseChart.vue'
|
||||
export { default as PieChart } from './PieChart.vue'
|
||||
export { default as BarChart } from './BarChart.vue'
|
||||
export { default as LineChart } from './LineChart.vue'
|
||||
export { default as GaugeChart } from './GaugeChart.vue'
|
||||
export { default as FunnelChart } from './FunnelChart.vue'
|
||||
|
||||
// 业务图表组件
|
||||
export { default as AssetStatusChart } from './business/AssetStatusChart.vue'
|
||||
export { default as AssetDistributionChart } from './business/AssetDistributionChart.vue'
|
||||
export { default as AssetValueTrendChart } from './business/AssetValueTrendChart.vue'
|
||||
export { default as AssetUtilizationChart } from './business/AssetUtilizationChart.vue'
|
||||
Reference in New Issue
Block a user