feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<div class="trend-chart" :class="{ 'trend-chart-compact': compact, 'trend-chart-dark': dark }">
|
||||
<div class="chart-toolbar">
|
||||
<div class="chart-legend">
|
||||
<span
|
||||
@@ -39,6 +39,10 @@ const props = defineProps({
|
||||
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: () => [] }
|
||||
})
|
||||
@@ -46,6 +50,7 @@ const props = defineProps({
|
||||
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,
|
||||
@@ -93,14 +98,30 @@ const stackedAmountData = computed(() => props.labels.map((_, index) => [
|
||||
index,
|
||||
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
|
||||
]))
|
||||
const activeColor = computed(() => (
|
||||
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||
))
|
||||
const activeColor = computed(() => {
|
||||
return isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||
})
|
||||
const comparisonColor = computed(() => '#cbd5e1')
|
||||
const legendLabel = computed(() => (
|
||||
isCountMode.value ? '报销数量' : '报销金额'
|
||||
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}`,
|
||||
@@ -114,23 +135,144 @@ const legendItems = computed(() => {
|
||||
title: `${legendLabel.value} ${unitLabel.value}`
|
||||
}]
|
||||
})
|
||||
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
|
||||
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 (!amountCategorySeries.value.length) {
|
||||
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))
|
||||
return Math.max(...dailyTotals, 1)
|
||||
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) => (
|
||||
isCountMode.value
|
||||
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: '费用类型占比',
|
||||
@@ -151,15 +293,15 @@ const chartSeries = computed(() => {
|
||||
barWidth: 16,
|
||||
smooth: isCountMode.value,
|
||||
symbol: isCountMode.value ? 'circle' : 'none',
|
||||
symbolSize: 7,
|
||||
symbolSize: compactScale.value.defaultSymbolSize,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
width: compactScale.value.defaultLineWidth,
|
||||
color: activeColor.value
|
||||
},
|
||||
itemStyle: {
|
||||
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
||||
borderColor: activeColor.value,
|
||||
borderWidth: isCountMode.value ? 2.5 : 0,
|
||||
borderWidth: isCountMode.value ? (props.compact ? 3 : 2.5) : 0,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
areaStyle: {
|
||||
@@ -190,13 +332,7 @@ const chartOptions = computed(() => ({
|
||||
animationDurationUpdate: 1200,
|
||||
animationEasing: 'linear',
|
||||
animationEasingUpdate: 'linear',
|
||||
grid: {
|
||||
top: 12,
|
||||
right: 24,
|
||||
bottom: 22,
|
||||
left: 36,
|
||||
containLabel: true
|
||||
},
|
||||
grid: chartGrid.value,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
@@ -221,20 +357,22 @@ const chartOptions = computed(() => ({
|
||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontSize: compactScale.value.axisLabelSize,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: Math.ceil(stackedMaxValue.value * 1.18),
|
||||
splitNumber: 5,
|
||||
max: yAxisMax.value,
|
||||
interval: props.compact ? (yAxisMax.value / 2) : undefined,
|
||||
splitNumber: props.compact ? 2 : 5,
|
||||
name: '',
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
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)' } }
|
||||
@@ -352,6 +490,16 @@ function formatTooltip(params) {
|
||||
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)
|
||||
}
|
||||
@@ -406,6 +554,11 @@ function formatAxisCurrency(value) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trend-chart-compact {
|
||||
height: 100%;
|
||||
min-height: 124px;
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
@@ -465,4 +618,39 @@ function formatAxisCurrency(value) {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user