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

@@ -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,