feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

View File

@@ -1,9 +1,7 @@
<template>
<div class="trend-chart">
<div class="chart-legend">
<span><i :style="{ background: chartColors.primary }"></i>申请量</span>
<span><i :style="{ background: chartColors.blue }"></i>审批完成量</span>
<span><i :style="{ background: chartColors.purple }"></i>平均审批时长小时</span>
<span><i :style="{ background: activeColor }"></i>{{ legendLabel }}</span>
</div>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
@@ -23,22 +21,44 @@ use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasR
const props = defineProps({
labels: { type: Array, required: true },
applications: { type: Array, required: true },
approved: { type: Array, required: true },
avgHours: { type: Array, required: true }
mode: { type: String, default: 'amount' },
claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] },
applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] },
avgHours: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
purple: themeColors.value.chartPurple
blue: themeColors.value.chartBlue
}))
const isCountMode = computed(() => props.mode === 'count')
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 activeColor = computed(() => (
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
))
const legendLabel = computed(() => (
isCountMode.value ? '报销数量(单)' : '报销金额(元)'
))
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}申请${props.applications[index] || 0}单,审批${props.approved[index] || 0}单,平均${props.avgHours[index] || 0}小时`
isCountMode.value
? `${label}报销${claimCountSeries.value[index] || 0}`
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
)).join('')
)
@@ -51,7 +71,7 @@ const chartOptions = computed(() => ({
animationEasingUpdate: 'linear',
grid: {
top: 18,
right: 38,
right: 24,
bottom: 22,
left: 36,
containLabel: true
@@ -83,72 +103,46 @@ const chartOptions = computed(() => ({
fontWeight: 700
}
},
yAxis: [
{
type: 'value',
min: 0,
max: 250,
splitNumber: 5,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
yAxis: {
type: 'value',
min: 0,
max: Math.ceil(maxValue.value * 1.2),
splitNumber: 5,
name: isCountMode.value ? '单' : '元',
nameTextStyle: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
{
type: 'value',
min: 0,
max: 15,
splitNumber: 5,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { show: false }
}
],
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700,
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
series: [
{
name: '申请量(单)',
type: 'bar',
data: props.applications,
barWidth: 12,
barGap: '28%',
itemStyle: {
color: chartColors.value.primary,
borderRadius: [4, 4, 0, 0]
}
},
{
name: '审批完成量(单)',
type: 'bar',
data: props.approved,
barWidth: 12,
itemStyle: {
color: chartColors.value.blue,
borderRadius: [4, 4, 0, 0]
}
},
{
name: '平均审批时长(小时)',
type: 'line',
yAxisIndex: 1,
data: props.avgHours,
smooth: true,
symbol: 'circle',
name: legendLabel.value,
type: isCountMode.value ? 'line' : 'bar',
data: activeSeries.value,
barWidth: 16,
smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.purple
color: activeColor.value
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.purple,
borderWidth: 2.5
color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value,
borderWidth: isCountMode.value ? 2.5 : 0,
borderRadius: [4, 4, 0, 0]
},
areaStyle: {
opacity: isCountMode.value ? 1 : 0,
color: {
type: 'linear',
x: 0,
@@ -156,10 +150,15 @@ const chartOptions = computed(() => ({
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(chartColors.value.purple, 0.14) },
{ offset: 1, color: toRgba(chartColors.value.purple, 0.02) }
{ 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)
)
}
}
]
@@ -178,6 +177,20 @@ function toRgba(color, alpha) {
}
return `rgba(58, 124, 165, ${alpha})`
}
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>

View File

@@ -38,19 +38,36 @@
</button>
</div>
<EnterpriseSelect
v-if="showPageSize"
class="page-size-select"
:model-value="pageSize"
:options="pageSizeOptions"
size="small"
@change="setPageSize"
/>
<div class="page-tools">
<EnterpriseSelect
v-if="showPageSize"
class="page-size-select"
:model-value="pageSize"
:options="pageSizeOptions"
size="small"
@change="setPageSize"
/>
<div class="page-jump">
<span>跳至</span>
<input
:value="pageInput"
type="text"
inputmode="numeric"
pattern="[0-9]*"
aria-label="输入页码跳转"
@blur="commitPageInput"
@input="updatePageInput"
@keydown.enter.prevent="commitPageInput"
/>
<span></span>
</div>
</div>
</footer>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import EnterpriseSelect from './EnterpriseSelect.vue'
@@ -73,12 +90,15 @@ const props = defineProps({
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
const pageInput = ref(String(props.currentPage || 1))
const pageItems = computed(() => {
if (props.pages.length) {
return props.pages
const total = Math.max(1, Number(props.totalPages) || 1)
if (total <= 4) {
return Array.from({ length: total }, (_, index) => index + 1)
}
return Array.from({ length: props.totalPages }, (_, index) => index + 1)
return [1, 2, 3, 'ellipsis', total]
})
const summaryText = computed(() => {
@@ -104,4 +124,21 @@ function setPageSize(size) {
emit('update:pageSize', size)
emit('page-size-change', size)
}
function updatePageInput(event) {
pageInput.value = String(event.target.value || '').replace(/\D/g, '')
}
function commitPageInput() {
const nextPage = Math.min(Math.max(Number(pageInput.value) || props.currentPage || 1, 1), props.totalPages)
pageInput.value = String(nextPage)
setPage(nextPage)
}
watch(
() => props.currentPage,
(page) => {
pageInput.value = String(page || 1)
}
)
</script>