feat: 完善预算中心图表与确认对话框交互

后端预算服务增加汇总查询和辅助计算,前端预算中心优化趋
势图组件和数据展示,增强确认对话框通用性和样式,完善预
算编辑对话框布局,补充预算端点单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 20:07:56 +08:00
parent e7bef0883d
commit df49103f23
10 changed files with 716 additions and 153 deletions

View File

@@ -290,20 +290,20 @@
}
.budget-edit-foot {
padding: 18px 24px 20px;
padding: 14px 24px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid #edf1f6;
background: #fff;
}
.budget-edit-foot button {
height: 40px;
min-width: 156px;
border-radius: 7px;
font-size: 14px;
height: 36px;
min-width: 96px;
border-radius: 8px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
@@ -372,3 +372,141 @@
width: 100%;
}
}
@media (max-width: 1366px), (max-height: 820px) {
.budget-dialog-backdrop {
padding: 14px;
}
.budget-edit-dialog {
width: min(920px, calc(100vw - 28px));
max-height: calc(100vh - 28px);
}
.budget-edit-head {
min-height: 48px;
padding: 0 18px;
}
.budget-edit-head strong {
font-size: 16px;
}
.budget-dialog-close {
width: 28px;
height: 28px;
font-size: 18px;
}
.budget-edit-body {
padding: 12px 18px 10px;
}
.budget-edit-section + .budget-edit-section {
margin-top: 10px;
}
.budget-edit-section h3 {
margin-bottom: 8px;
font-size: 14px;
}
.budget-edit-form-grid {
gap: 10px 16px;
}
.budget-edit-form-grid label,
.budget-edit-textarea {
gap: 5px;
font-size: 12px;
}
.budget-edit-form-grid select {
height: 34px;
}
.budget-edit-textarea {
margin-top: 8px;
}
.budget-edit-textarea textarea {
min-height: 58px;
padding: 8px 12px 20px;
line-height: 1.45;
}
.budget-edit-table th,
.budget-edit-table td {
height: 40px;
padding: 6px 8px;
}
.budget-edit-table input,
.budget-edit-table select {
height: 30px;
padding: 0 8px;
}
.budget-row-delete {
width: 28px;
height: 28px;
font-size: 16px;
}
.budget-add-row-btn {
height: 26px;
margin-top: 6px;
font-size: 12px;
}
.budget-edit-total {
height: 34px;
margin-top: 6px;
}
.budget-edit-total span,
.budget-edit-foot button {
font-size: 13px;
}
.budget-edit-total strong {
font-size: 15px;
}
.budget-edit-foot {
padding: 10px 18px 12px;
gap: 8px;
}
.budget-edit-foot button {
height: 32px;
min-width: 88px;
}
}
@media (max-height: 700px) {
.budget-dialog-backdrop {
padding: 8px;
}
.budget-edit-dialog {
max-height: calc(100vh - 16px);
}
.budget-edit-body {
padding: 10px 16px 8px;
}
.budget-edit-textarea textarea {
min-height: 46px;
}
.budget-edit-table th,
.budget-edit-table td {
height: 36px;
}
.budget-edit-foot {
padding: 8px 16px;
}
}

View File

@@ -546,14 +546,19 @@
}
.legend-line {
width: 18px;
height: 0;
border-top: 2px dashed #2f7fd7;
width: 10px;
height: 10px;
border-radius: 3px;
background: #13a66b;
}
.legend-line.used {
border-top-style: solid;
border-top-color: #13a66b;
.legend-line.occupied {
background: #f59e0b;
}
.legend-line.available {
background: #e5edf3;
border: 1px solid #cbd5e1;
}
.budget-chart-panel {
@@ -569,6 +574,41 @@
padding: 12px 20px 18px;
}
.budget-alert-empty {
min-height: 220px;
padding: 28px 24px 30px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
text-align: center;
}
.budget-alert-empty-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: grid;
place-items: center;
background: #e9f7f1;
color: #059669;
font-size: 24px;
}
.budget-alert-empty strong {
color: #111827;
font-size: 16px;
font-weight: 800;
}
.budget-alert-empty p {
max-width: 260px;
margin: 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.budget-alert-row {
min-height: 46px;
display: grid;

View File

@@ -1,77 +1,110 @@
<template>
<div class="budget-trend-chart">
<Line :data="chartData" :options="chartOptions" />
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
BarElement,
CategoryScale,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend)
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
const props = defineProps({
labels: { type: Array, required: true },
budget: { type: Array, required: true },
used: { type: Array, required: true }
used: { type: Array, required: true },
occupied: { type: Array, default: () => [] },
available: { type: Array, default: () => [] }
})
const progress = useAnimationProgress([
() => props.labels,
() => props.budget,
() => props.used
() => props.used,
() => props.occupied,
() => props.available
], 1000)
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
const percent = (value, total) => {
const denominator = Number(total || 0)
if (!denominator) return 0
return Number(((Number(value || 0) / denominator) * 100).toFixed(2))
}
const percentSeries = (series) =>
props.budget.map((total, index) => percent(series[index], total))
const scaleSeries = (series) =>
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
const usedPercent = computed(() => percentSeries(props.used))
const occupiedPercent = computed(() => percentSeries(props.occupied))
const availablePercent = computed(() =>
props.budget.map((total, index) => {
const usedValue = Number(props.used[index] || 0)
const occupiedValue = Number(props.occupied[index] || 0)
return percent(Math.max(Number(total || 0) - usedValue - occupiedValue, 0), total)
})
)
const yAxisMax = computed(() => {
const maxUsage = Math.max(
100,
...usedPercent.value.map((value, index) => value + Number(occupiedPercent.value[index] || 0))
)
return Math.ceil(maxUsage / 20) * 20
})
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
label: '预算',
data: scaleSeries(props.budget),
borderColor: '#2f7fd7',
backgroundColor: 'rgba(47, 127, 215, 0.08)',
borderDash: [7, 5],
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#2f7fd7',
pointBorderWidth: 2,
tension: 0.34,
fill: false
label: '已使用',
data: scaleSeries(usedPercent.value),
backgroundColor: '#13a66b',
borderRadius: 5,
borderSkipped: false,
stack: 'budgetUsage',
amounts: props.used
},
{
label: '已发生',
data: scaleSeries(props.used),
borderColor: '#13a66b',
backgroundColor: 'rgba(19, 166, 107, 0.12)',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#13a66b',
pointBorderWidth: 2,
tension: 0.34,
fill: false
label: '已占用',
data: scaleSeries(occupiedPercent.value),
backgroundColor: '#f59e0b',
borderRadius: 5,
borderSkipped: false,
stack: 'budgetUsage',
amounts: props.occupied
},
{
label: '剩余可用',
data: scaleSeries(availablePercent.value),
backgroundColor: '#e5edf3',
borderRadius: 5,
borderSkipped: false,
stack: 'budgetUsage',
amounts: props.available
}
]
}))
const chartOptions = {
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
@@ -97,7 +130,12 @@ const chartOptions = {
callbacks: {
label(context) {
const value = Number(context.parsed.y || 0)
return `${context.dataset.label}: ${value.toLocaleString('zh-CN')}`
const amount = Number(context.dataset.amounts?.[context.dataIndex] || 0)
return `${context.dataset.label}: ${value.toFixed(2)}%(¥${currency(amount)}`
},
afterBody(items) {
const index = items[0]?.dataIndex ?? 0
return `预算总额: ¥${currency(props.budget[index])}`
}
}
}
@@ -113,7 +151,8 @@ const chartOptions = {
},
y: {
beginAtZero: true,
max: 12000000,
max: yAxisMax.value,
stacked: true,
grid: {
color: '#edf2f7',
drawTicks: false
@@ -122,15 +161,20 @@ const chartOptions = {
ticks: {
color: '#64748b',
font: { size: 12 },
stepSize: 3000000,
stepSize: 20,
callback(value) {
if (value === 0) return '0'
return `${Number(value) / 10000}`
return `${Number(value)}%`
}
}
}
},
datasets: {
bar: {
categoryPercentage: 0.58,
barPercentage: 0.72
}
}
}
}))
</script>
<style scoped>

View File

@@ -7,12 +7,13 @@
role="presentation"
@click.self="handleMaskClose"
>
<section
class="shared-confirm-card"
role="alertdialog"
aria-modal="true"
:aria-labelledby="titleId"
@click.stop
<section
class="shared-confirm-card"
:class="cardClasses"
role="alertdialog"
aria-modal="true"
:aria-labelledby="titleId"
@click.stop
>
<span v-if="badge" class="shared-confirm-badge" :class="badgeTone">
{{ badge }}
@@ -62,16 +63,22 @@ const props = defineProps({
cancelText: { type: String, default: '取消' },
confirmText: { type: String, default: '确认' },
busyText: { type: String, default: '处理中...' },
confirmTone: { type: String, default: 'primary' },
confirmIcon: { type: String, default: '' },
busy: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true }
})
confirmTone: { type: String, default: 'primary' },
confirmIcon: { type: String, default: '' },
busy: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true },
size: { type: String, default: 'default' },
actionsAlign: { type: String, default: 'end' }
})
const emit = defineEmits(['close', 'cancel', 'confirm'])
const instance = getCurrentInstance()
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
const cardClasses = computed(() => [
`shared-confirm-card--${props.size}`,
`shared-confirm-actions-${props.actionsAlign}`
])
function handleMaskClose() {
if (!props.closeOnMask || props.busy) {
@@ -163,16 +170,28 @@ function handleCancel() {
gap: 10px;
}
.shared-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.shared-confirm-btn {
min-width: 140px;
min-height: 42px;
.shared-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.shared-confirm-card.shared-confirm-actions-start .shared-confirm-actions {
justify-content: flex-start;
}
.shared-confirm-card.shared-confirm-actions-center .shared-confirm-actions {
justify-content: center;
}
.shared-confirm-card.shared-confirm-actions-end .shared-confirm-actions {
justify-content: flex-end;
}
.shared-confirm-btn {
min-width: 140px;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -233,12 +252,42 @@ function handleCancel() {
.shared-confirm-enter-from .shared-confirm-card,
.shared-confirm-leave-to .shared-confirm-card {
transform: translateY(8px) scale(0.98);
}
@media (max-width: 720px) {
.shared-confirm-mask {
padding: 18px;
transform: translateY(8px) scale(0.98);
}
.shared-confirm-card--compact {
width: min(360px, 100%);
gap: 8px;
padding: 16px;
border-radius: 12px;
background: #fff;
}
.shared-confirm-card--compact h4 {
font-size: 15px;
line-height: 1.35;
}
.shared-confirm-card--compact p {
font-size: 12px;
line-height: 1.55;
}
.shared-confirm-card--compact .shared-confirm-actions {
gap: 6px;
}
.shared-confirm-card--compact .shared-confirm-btn {
min-width: 76px;
min-height: 30px;
padding: 0 10px;
border-radius: 7px;
font-size: 12px;
}
@media (max-width: 720px) {
.shared-confirm-mask {
padding: 18px;
}
.shared-confirm-card {

View File

@@ -176,32 +176,42 @@
<section class="budget-bottom-grid">
<article class="budget-chart-panel">
<header class="budget-card-head">
<strong>预算使用趋势</strong>
<strong>费用预算使用占比</strong>
<div class="budget-chart-legend">
<span><i class="legend-line budget"></i>预算</span>
<span><i class="legend-line used"></i>发生</span>
<span><i class="legend-line used"></i>已使用</span>
<span><i class="legend-line occupied"></i>占用</span>
<span><i class="legend-line available"></i>剩余可用</span>
</div>
</header>
<BudgetTrendChart
:labels="trendData.labels"
:budget="trendData.budget"
:used="trendData.used"
:labels="budgetUsageData.labels"
:budget="budgetUsageData.budget"
:used="budgetUsageData.used"
:occupied="budgetUsageData.occupied"
:available="budgetUsageData.available"
/>
</article>
<article class="budget-alert-panel">
<header class="budget-card-head">
<strong>预算预警</strong>
<button type="button">查看全部</button>
<button v-if="warnings.length" type="button">查看全部</button>
</header>
<div class="budget-alert-list">
<div v-for="alert in warnings" :key="alert.title" class="budget-alert-row">
<div v-if="warnings.length" class="budget-alert-list">
<div v-for="alert in warnings" :key="alert.id" class="budget-alert-row">
<i :class="alert.tone"></i>
<strong>{{ alert.title }}</strong>
<span>{{ alert.desc }}</span>
<time>{{ alert.date }}</time>
<time v-if="alert.date">{{ alert.date }}</time>
</div>
</div>
<div v-else class="budget-alert-empty">
<span class="budget-alert-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<strong>暂无预算预警</strong>
<p>当前范围内预算使用率未达到预警线</p>
</div>
</article>
</section>
@@ -318,15 +328,36 @@
<footer class="budget-edit-foot">
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</button>
<button class="budget-edit-publish" type="button" @click="publishBudget">保存并发布</button>
<button
class="budget-edit-publish"
type="button"
:disabled="budgetSaving"
@click="requestSaveBudget"
>
保存
</button>
</footer>
</section>
</div>
</Transition>
<ConfirmDialog
:open="confirmSaveOpen"
title="确认保存预算"
description="保存后将更新当前部门和季度的预算额度。"
cancel-text="取消"
confirm-text="保存"
busy-text="保存中..."
confirm-icon="mdi mdi-content-save-outline"
:busy="budgetSaving"
size="compact"
actions-align="end"
@close="cancelSaveBudget"
@confirm="confirmSaveBudget"
/>
<ConfirmDialog
:open="confirmDeleteOpen"
title="确认删除"
content="确定要删除当前预算明细行吗?删除后不可恢复。"
description="确定要删除当前预算明细行吗?删除后不可恢复。"
confirm-text="确认删除"
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"

View File

@@ -68,6 +68,10 @@ const comparison = (value, direction) => ({
const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit'
})
const normalizePeriodKey = (year, quarter) => {
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
@@ -130,52 +134,36 @@ function normalizeBudgetAllocationRow(item) {
}
}
function buildDepartmentRows(departmentCode) {
const seed = Array.from(String(departmentCode || '')).reduce(
(sum, char) => sum + char.charCodeAt(0),
0
)
const factor = 0.88 + (seed % 18) / 100
return EXPENSE_BLUEPRINTS.map((item, index) => {
const totalAmount = Math.round(item.total * factor)
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
const occupiedAmount = Math.round(
item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)
)
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
return {
...item,
totalAmount,
usedAmount,
occupiedAmount,
leftAmount,
rate,
rateTone: rate >= item.warning ? 'danger' : rate >= item.warning - 12 ? 'warn' : 'ok',
warningTone: item.warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
warningLine: `${item.warning}%`,
total: currency(totalAmount),
used: currency(usedAmount),
occupied: currency(occupiedAmount),
left: currency(leftAmount)
}
})
function normalizeBudgetUsageData(rows) {
const source = Array.isArray(rows) ? rows : []
return {
labels: source.map((item) => item.expenseType || '未分类'),
budget: source.map((item) => Number(item.totalAmount || 0)),
used: source.map((item) => Number(item.usedAmount || 0)),
occupied: source.map((item) => Number(item.occupiedAmount || 0)),
available: source.map((item) => Math.max(Number(item.leftAmount || 0), 0))
}
}
function buildTrendData(rows) {
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
const used = rows.reduce((sum, item) => sum + item.usedAmount + item.occupiedAmount, 0)
function formatAlertDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ALERT_DATE_FORMATTER.format(date)
}
function normalizeBudgetWarning(item) {
const subjectName = item?.subject_name || resolveBudgetExpenseTypeLabel(item?.subject_code, item?.subject_code)
const departmentName = item?.department_name || ''
const usageRate = Number(item?.usage_rate || 0)
const warningThreshold = Number(item?.warning_threshold || 0)
const tone = item?.severity === 'danger' ? 'danger' : 'warn'
return {
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
budget: [0.05, 0.18, 0.25, 0.34, 0.45, 0.52, 0.68, 0.76, 0.84, 0.91, 0.96, 1].map((ratio) =>
Math.round(total * ratio)
),
used: [0.03, 0.1, 0.13, 0.22, 0.3, 0.37, 0.51, 0.59, 0.69, 0.73, 0.86, 0.96].map((ratio) =>
Math.round(used * ratio)
)
id: item?.allocation_id || `${departmentName}-${subjectName}-${item?.period_key || ''}`,
title: departmentName ? `${departmentName} · ${subjectName}` : subjectName,
desc: item?.message || `使用率已达 ${usageRate}%,达到预警线 ${warningThreshold}%。`,
date: formatAlertDate(item?.occurred_at),
tone
}
}
@@ -204,10 +192,12 @@ export default {
const budgetPage = ref(1)
const budgetPageSize = ref(5)
const budgetRows = ref([])
const budgetSummary = ref(null)
const budgetLoading = ref(false)
const budgetError = ref('')
const budgetSaving = ref(false)
const budgetEditOpen = ref(false)
const confirmSaveOpen = ref(false)
const budgetEditForm = ref({
budgetYear: '2026',
budgetQuarter: 'Q1',
@@ -323,19 +313,13 @@ export default {
})
const warnings = computed(() =>
departmentRows.value
.slice()
.sort((a, b) => b.rate - a.rate)
.slice(0, 4)
.map((row, index) => ({
title: row.expenseType,
desc: `使用率已达 ${row.rate}%${row.rate >= row.warning ? '已超过预警线' : '接近预警线'}${row.warningLine}`,
date: index < 2 ? '2026-05-12' : '2026-05-10',
tone: row.rate >= row.warning ? 'danger' : row.rate >= row.warning - 12 ? 'warn' : 'ok'
}))
(Array.isArray(budgetSummary.value?.warnings) ? budgetSummary.value.warnings : [])
.map(normalizeBudgetWarning)
)
const trendData = computed(() => buildTrendData(departmentRows.value))
const budgetUsageData = computed(() =>
normalizeBudgetUsageData(departmentRows.value)
)
const budgetEditTotal = computed(() =>
currency(
budgetEditRows.value.reduce(
@@ -402,6 +386,7 @@ export default {
}
function closeBudgetEditDialog() {
confirmSaveOpen.value = false
budgetEditOpen.value = false
}
@@ -440,6 +425,16 @@ export default {
confirmDeleteOpen.value = false
}
function requestSaveBudget() {
if (!canEditBudget.value || budgetSaving.value) return
confirmSaveOpen.value = true
}
function cancelSaveBudget() {
if (budgetSaving.value) return
confirmSaveOpen.value = false
}
function goToBudgetPage(page) {
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
}
@@ -551,9 +546,11 @@ export default {
cost_center: department.costCenter || ''
})
const allocations = Array.isArray(payload?.allocations) ? payload.allocations : []
budgetSummary.value = payload || null
budgetRows.value = allocations.map(normalizeBudgetAllocationRow)
} catch (error) {
budgetError.value = error?.message || 'Failed to load budget data'
budgetSummary.value = null
budgetRows.value = []
console.warn('Failed to load budget data:', error)
} finally {
@@ -561,9 +558,11 @@ export default {
}
}
async function publishBudgetAction() {
budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[1]
await saveBudgetRows('published')
async function confirmSaveBudget() {
if (!canEditBudget.value || budgetSaving.value) return
budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[0]
await saveBudgetRows('saved')
confirmSaveOpen.value = false
}
onMounted(() => {
@@ -609,6 +608,7 @@ export default {
budgetLoading,
budgetMetrics,
budgetOntologyContext,
budgetSaving,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
@@ -618,6 +618,8 @@ export default {
closeBudgetEditDialog,
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
changeBudgetPage,
confirmSaveBudget,
confirmSaveOpen,
departmentKeyword,
departments,
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
@@ -630,14 +632,15 @@ export default {
confirmDeleteOpen,
confirmDeleteRow,
cancelDeleteRow,
publishBudget: publishBudgetAction,
cancelSaveBudget,
requestSaveBudget,
statusOptions: BUDGET_STATUS_OPTIONS,
statuses: ['全部', '正常', '预警', '管控'],
syncBudgetRowSubject,
goToBudgetPage,
totalBudgetPages,
totalBudgetRows,
trendData,
budgetUsageData,
visibleBudgetRows,
visibleDepartments,
warningOptions: BUDGET_WARNING_OPTIONS,