Files
X-Financial/web/src/components/charts/TrendChart.vue
caoxiaozhu 0cde1f8990 feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
2026-06-18 22:12:24 +08:00

657 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="trend-chart" :class="{ 'trend-chart-compact': compact, 'trend-chart-dark': dark }">
<div class="chart-toolbar">
<div class="chart-legend">
<span
v-for="item in legendItems"
:key="item.name"
class="legend-pill"
:title="item.title"
>
<i :style="{ background: item.color }"></i>{{ item.name }}
</span>
</div>
<span class="chart-unit">{{ unitLabel }}</span>
</div>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import {
BarChart as EChartsBarChart,
CustomChart as EChartsCustomChart,
LineChart as EChartsLineChart
} from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsCustomChart, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
mode: { type: String, default: 'amount' },
claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] },
categoryAmountSeries: { type: Array, default: () => [] },
comparisonAmount: { type: Array, default: () => [] },
primaryLabel: { type: String, default: '报销金额' },
comparisonLabel: { type: String, default: '去年同期' },
compact: { type: Boolean, default: false },
applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const isCountMode = computed(() => props.mode === 'count')
const isComparisonMode = computed(() => props.mode === 'compareAmount')
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
amber: themeColors.value.chartAmber,
purple: themeColors.value.chartPurple,
success: themeColors.value.success,
danger: themeColors.value.chartDanger
}))
const fallbackSeriesColors = computed(() => [
chartColors.value.blue,
chartColors.value.amber,
chartColors.value.purple,
chartColors.value.success,
chartColors.value.danger,
chartColors.value.primary
])
const expenseCategoryColorMap = computed(() => ({
'差旅': chartColors.value.blue,
'办公用品': chartColors.value.amber,
'业务招待': chartColors.value.purple,
'通讯': chartColors.value.success,
'培训': '#65789b',
'交通': chartColors.value.primary,
'餐饮': '#9a7b4f',
'会议': '#7f6c9f'
}))
const claimCountSeries = computed(() => (
props.claimCount.length ? props.claimCount : props.applications
))
const claimAmountSeries = computed(() => (
props.claimAmount.length ? props.claimAmount : props.approved
))
const activeSeries = computed(() => (
isCountMode.value ? claimCountSeries.value : claimAmountSeries.value
))
const amountCategorySeries = computed(() => {
if (isCountMode.value) {
return []
}
return (Array.isArray(props.categoryAmountSeries) ? props.categoryAmountSeries : [])
.filter((item) => Array.isArray(item.data) && item.data.some((value) => Number(value || 0) > 0))
.slice(0, 6)
})
const stackedAmountData = computed(() => props.labels.map((_, index) => [
index,
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
]))
const activeColor = computed(() => {
return isCountMode.value ? chartColors.value.primary : chartColors.value.blue
})
const comparisonColor = computed(() => '#cbd5e1')
const legendLabel = computed(() => (
isCountMode.value ? '报销数量' : (isComparisonMode.value ? props.primaryLabel : '报销金额')
))
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
const legendItems = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
color: activeColor.value,
title: `${props.primaryLabel} ${unitLabel.value}`
},
{
name: props.comparisonLabel,
color: comparisonColor.value,
title: `${props.comparisonLabel} ${unitLabel.value}`
}
]
}
if (amountCategorySeries.value.length) {
return amountCategorySeries.value.map((item, index) => ({
name: item.name || `费用类型 ${index + 1}`,
color: resolveCategoryColor(item, index),
title: `${item.name || `费用类型 ${index + 1}`} ${formatCurrency(item.total || 0)}`
}))
}
return [{
name: legendLabel.value,
color: activeColor.value,
title: `${legendLabel.value} ${unitLabel.value}`
}]
})
const comparisonSeries = computed(() => (
Array.isArray(props.comparisonAmount) ? props.comparisonAmount : []
))
const maxValue = computed(() => {
const values = [
...activeSeries.value.map((value) => Number(value || 0)),
...(isComparisonMode.value ? comparisonSeries.value.map((value) => Number(value || 0)) : [])
]
const rawMax = Math.max(...values, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
const compactScale = computed(() => ({
axisLabelSize: props.compact ? 12 : 11,
comparisonLineWidth: props.compact ? 3 : 2.5,
comparisonSymbolSize: props.compact ? 7.5 : 6,
defaultLineWidth: props.compact ? 3 : 2.5,
defaultSymbolSize: props.compact ? 8 : 7,
gridBottom: props.compact ? 18 : 22,
gridLeft: props.compact ? 42 : 36,
gridRight: props.compact ? 28 : 24,
gridTop: props.compact ? 10 : 12,
primaryLineWidth: props.compact ? 3.8 : 3,
primarySymbolSize: props.compact ? 8.5 : 7
}))
const chartGrid = computed(() => ({
top: compactScale.value.gridTop,
right: compactScale.value.gridRight,
bottom: compactScale.value.gridBottom,
left: compactScale.value.gridLeft,
containLabel: true
}))
const stackedMaxValue = computed(() => {
if (isComparisonMode.value || !amountCategorySeries.value.length) {
return maxValue.value
}
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
const rawMax = Math.max(...dailyTotals, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
function getFormattedMax(val, isCount) {
if (isCount) {
const base = Math.max(val, 4)
if (base <= 4) return 4
if (base <= 6) return 6
if (base <= 10) return 10
return Math.ceil(base / 2) * 2
} else {
const base = Math.max(val, 100)
if (base <= 100) return 100
if (base <= 200) return 200
if (base <= 500) return 500
if (base <= 1000) return 1000
if (base <= 2000) return 2000
if (base <= 5000) return 5000
return Math.ceil(base / 1000) * 1000
}
}
const yAxisMax = computed(() => {
const calculatedMax = Math.ceil(stackedMaxValue.value * 1.18)
return getFormattedMax(calculatedMax, isCountMode.value)
})
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
isComparisonMode.value
? `${label}${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
: isCountMode.value
? `${label}报销${claimCountSeries.value[index] || 0}`
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
)).join('')
)
const chartSeries = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
type: 'line',
data: claimAmountSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.primarySymbolSize,
lineStyle: {
width: compactScale.value.primaryLineWidth,
color: activeColor.value
},
itemStyle: {
color: '#ffffff',
borderColor: activeColor.value,
borderWidth: props.compact ? 3 : 2.5
},
areaStyle: {
opacity: 1,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(activeColor.value, 0.12) },
{ offset: 1, color: toRgba(activeColor.value, 0.01) }
]
}
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
},
{
name: props.comparisonLabel,
type: 'line',
data: comparisonSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.comparisonSymbolSize,
lineStyle: {
width: compactScale.value.comparisonLineWidth,
color: comparisonColor.value,
type: 'dashed'
},
itemStyle: {
color: '#ffffff',
borderColor: comparisonColor.value,
borderWidth: props.compact ? 2.5 : 2
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
}
]
}
if (!isCountMode.value && amountCategorySeries.value.length) {
return [{
name: '费用类型占比',
type: 'custom',
data: stackedAmountData.value,
renderItem: renderStackedAmountBar,
animationDelay: (index) => index * 18,
tooltip: {
formatter: (params) => formatStackedTooltip(params)
}
}]
}
return [{
name: legendLabel.value,
type: isCountMode.value ? 'line' : 'bar',
data: activeSeries.value,
barWidth: 16,
smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: compactScale.value.defaultSymbolSize,
lineStyle: {
width: compactScale.value.defaultLineWidth,
color: activeColor.value
},
itemStyle: {
color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value,
borderWidth: isCountMode.value ? (props.compact ? 3 : 2.5) : 0,
borderRadius: [4, 4, 0, 0]
},
areaStyle: {
opacity: isCountMode.value ? 1 : 0,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
{ offset: 1, color: toRgba(activeColor.value, 0.02) }
]
}
},
tooltip: {
valueFormatter: (value) => (
isCountMode.value ? `${Number(value || 0)}` : formatCurrency(value)
)
}
}]
})
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: chartGrid.value,
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => formatTooltip(params)
},
xAxis: {
type: 'category',
data: props.labels,
boundaryGap: true,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: compactScale.value.axisLabelSize,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: yAxisMax.value,
interval: props.compact ? (yAxisMax.value / 2) : undefined,
splitNumber: props.compact ? 2 : 5,
name: '',
axisLabel: {
color: '#64748b',
fontSize: compactScale.value.axisLabelSize,
fontWeight: 700,
margin: props.compact ? 12 : 8,
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
series: chartSeries.value
}))
useEcharts(chartElement, chartOptions)
function toRgba(color, alpha) {
const normalized = String(color || '').trim()
const hex = normalized.replace('#', '')
if (/^[\da-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
return `rgba(58, 124, 165, ${alpha})`
}
function resolveCategoryColor(item, index) {
const name = String(item?.name || '').trim()
const mapped = expenseCategoryColorMap.value[name]
if (mapped) {
return mapped
}
const fallback = fallbackSeriesColors.value[index % fallbackSeriesColors.value.length]
return resolveCssColor(item?.color, fallback)
}
function renderStackedAmountBar(params, api) {
const categoryIndex = Number(api.value(0))
const zeroPoint = api.coord([categoryIndex, 0])
const xCenter = zeroPoint[0]
const zeroY = zeroPoint[1]
const categoryWidth = api.size([1, 0])?.[0] || 32
const barWidth = Math.max(12, Math.min(24, categoryWidth * 0.48))
const barX = xCenter - barWidth / 2
let accumulated = 0
const values = amountCategorySeries.value.map((_, index) => Number(api.value(index + 1) || 0))
const lastVisibleIndex = values.reduce((last, value, index) => (value > 0 ? index : last), -1)
const children = []
let topY = zeroY
values.forEach((value, index) => {
if (value <= 0) {
return
}
const lower = accumulated
const upper = accumulated + value
const lowerY = api.coord([categoryIndex, lower])[1]
const upperY = api.coord([categoryIndex, upper])[1]
const height = Math.max(1, lowerY - upperY)
topY = Math.min(topY, upperY)
accumulated = upper
children.push({
type: 'rect',
shape: {
x: barX,
y: upperY,
width: barWidth,
height,
r: index === lastVisibleIndex ? [4, 4, 0, 0] : 0
},
style: {
fill: resolveCategoryColor(amountCategorySeries.value[index], index)
}
})
})
if (!children.length) {
return {
type: 'group',
children: []
}
}
const totalHeight = Math.max(1, zeroY - topY)
return {
type: 'group',
originX: xCenter,
originY: zeroY,
scaleY: 1,
enterFrom: {
scaleY: 0
},
transition: ['scaleY'],
clipPath: {
type: 'rect',
shape: {
x: barX,
y: topY,
width: barWidth,
height: totalHeight
},
enterFrom: {
shape: {
x: barX,
y: zeroY,
width: barWidth,
height: 0
}
},
transition: ['shape']
},
children
}
}
function formatTooltip(params) {
const items = Array.isArray(params) ? params : [params]
const first = items[0]
if (!first) {
return ''
}
if (isComparisonMode.value) {
const index = Number(first.dataIndex || 0)
const label = props.labels[index] || first.axisValueLabel || first.name || ''
return [
label,
`${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}`,
`${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
].join('<br/>')
}
if (!isCountMode.value && amountCategorySeries.value.length) {
return formatStackedTooltip(first)
}
const index = Number(first.dataIndex || 0)
const label = props.labels[index] || first.axisValueLabel || first.name || ''
const value = isCountMode.value ? claimCountSeries.value[index] : activeSeries.value[index]
const displayValue = isCountMode.value ? `${Number(value || 0)}` : formatCurrency(value)
return `${label}<br/>${legendLabel.value}${displayValue}`
}
function formatStackedTooltip(params) {
const index = Number(params?.data?.[0] ?? params?.dataIndex ?? 0)
const label = props.labels[index] || params?.axisValueLabel || ''
const rows = amountCategorySeries.value
.map((item, itemIndex) => ({
name: item.name || `费用类型 ${itemIndex + 1}`,
color: resolveCategoryColor(item, itemIndex),
value: Number(item.data?.[index] || 0)
}))
.filter((item) => item.value > 0)
const total = rows.reduce((sum, item) => sum + item.value, 0)
const details = rows.map((item) => (
`<span style="display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;background:${item.color};"></span>${item.name}${formatCurrency(item.value)}`
))
return [
label,
...details,
`合计:${formatCurrency(total)}`
].join('<br/>')
}
function formatCurrency(value) {
const number = Number(value || 0)
if (number >= 1000000) return `¥${(number / 1000000).toFixed(1)}M`
if (number >= 1000) return `¥${(number / 1000).toFixed(1)}K`
return `¥${Math.round(number)}`
}
function formatAxisCurrency(value) {
const number = Number(value || 0)
if (number >= 1000000) return `${(number / 1000000).toFixed(1)}M`
if (number >= 1000) return `${(number / 1000).toFixed(0)}K`
return `${Math.round(number)}`
}
</script>
<style scoped>
.trend-chart {
height: 280px;
display: flex;
flex-direction: column;
}
.trend-chart-compact {
height: 100%;
min-height: 124px;
}
.chart-toolbar {
min-height: 30px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.chart-legend {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 12px;
color: #475569;
font-size: 12px;
line-height: 1.4;
}
.legend-pill {
max-width: 132px;
display: inline-flex;
align-items: center;
min-width: 0;
color: #475569;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-legend i {
flex: 0 0 auto;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 5px;
vertical-align: middle;
}
.chart-unit {
flex: 0 0 auto;
padding: 2px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.5;
}
.chart-body {
flex: 1;
min-height: 0;
}
.trend-chart-compact .chart-toolbar {
min-height: 28px;
margin-bottom: 6px;
}
.trend-chart-compact .chart-legend {
gap: 6px 14px;
font-size: 13px;
}
.trend-chart-compact .legend-pill {
max-width: 128px;
}
.trend-chart-compact .chart-legend i {
width: 9px;
height: 9px;
}
.trend-chart-compact .chart-unit {
padding: 2px 8px;
font-size: 12.5px;
}
.trend-chart-dark .chart-legend,
.trend-chart-dark .legend-pill {
color: #64748b;
}
.trend-chart-dark .chart-unit {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #64748b;
}
</style>