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

@@ -92,6 +92,37 @@ class BudgetTransactionRead(BaseModel):
created_at: datetime
class BudgetTrendPointRead(BaseModel):
period_key: str
label: str
total_amount: Decimal
reserved_amount: Decimal
consumed_amount: Decimal
used_amount: Decimal
available_amount: Decimal
usage_rate: Decimal
class BudgetWarningRead(BaseModel):
allocation_id: str
budget_no: str
fiscal_year: int
period_key: str
department_name: str
cost_center: str | None = None
subject_code: str
subject_name: str
total_amount: Decimal
reserved_amount: Decimal
consumed_amount: Decimal
available_amount: Decimal
usage_rate: Decimal
warning_threshold: Decimal
severity: str
message: str
occurred_at: datetime | None = None
class BudgetSummaryRead(BaseModel):
fiscal_year: int | None = None
period_key: str | None = None
@@ -102,6 +133,8 @@ class BudgetSummaryRead(BaseModel):
warning_count: int
over_budget_count: int
allocations: list[BudgetAllocationRead] = Field(default_factory=list)
trend: list[BudgetTrendPointRead] = Field(default_factory=list)
warnings: list[BudgetWarningRead] = Field(default_factory=list)
class BudgetCheckRead(BaseModel):

View File

@@ -92,6 +92,7 @@ class BudgetService(BudgetSupportMixin):
over_budget_count = sum(
1 for item in allocations if item.balance.available_amount < Decimal("0.00")
)
warnings = self.build_summary_warnings(allocations)
return BudgetSummaryRead(
fiscal_year=fiscal_year,
period_key=period_key,
@@ -102,6 +103,13 @@ class BudgetService(BudgetSupportMixin):
warning_count=warning_count,
over_budget_count=over_budget_count,
allocations=allocations,
trend=self.build_summary_trend(
fiscal_year=fiscal_year,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
),
warnings=warnings,
)
def create_or_update_allocation(

View File

@@ -10,7 +10,12 @@ from sqlalchemy import select
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead
from app.schemas.budget import (
BudgetAllocationRead,
BudgetTransactionRead,
BudgetTrendPointRead,
BudgetWarningRead,
)
from app.services.budget_types import (
BUDGET_SUBJECT_LABELS,
BudgetBalance,
@@ -89,6 +94,120 @@ class BudgetSupportMixin:
).all()
return [BudgetTransactionRead.model_validate(row) for row in rows]
def build_summary_trend(
self,
*,
fiscal_year: int | None = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> list[BudgetTrendPointRead]:
stmt = (
select(BudgetAllocation)
.where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES))
.order_by(
BudgetAllocation.fiscal_year.asc(),
BudgetAllocation.period_key.asc(),
)
)
if fiscal_year is not None:
stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year)
if department_id:
stmt = stmt.where(BudgetAllocation.department_id == department_id)
if department_name:
stmt = stmt.where(BudgetAllocation.department_name == department_name)
if cost_center:
stmt = stmt.where(BudgetAllocation.cost_center == cost_center)
buckets: dict[str, dict[str, Decimal]] = {}
for allocation in self.db.scalars(stmt).all():
balance = self.get_balance(allocation)
bucket = buckets.setdefault(
allocation.period_key,
{
"total_amount": Decimal("0.00"),
"reserved_amount": Decimal("0.00"),
"consumed_amount": Decimal("0.00"),
"available_amount": Decimal("0.00"),
},
)
bucket["total_amount"] += balance.total_amount
bucket["reserved_amount"] += balance.reserved_amount
bucket["consumed_amount"] += balance.consumed_amount
bucket["available_amount"] += balance.available_amount
trend: list[BudgetTrendPointRead] = []
for period_key in sorted(buckets, key=self._period_sort_key):
bucket = buckets[period_key]
used_amount = bucket["reserved_amount"] + bucket["consumed_amount"]
usage_rate = Decimal("0.00")
if bucket["total_amount"] > Decimal("0.00"):
usage_rate = (
(used_amount / bucket["total_amount"]) * Decimal("100")
).quantize(Decimal("0.01"))
trend.append(
BudgetTrendPointRead(
period_key=period_key,
label=self._period_label(period_key),
total_amount=self._money(bucket["total_amount"]),
reserved_amount=self._money(bucket["reserved_amount"]),
consumed_amount=self._money(bucket["consumed_amount"]),
used_amount=self._money(used_amount),
available_amount=self._money(bucket["available_amount"]),
usage_rate=usage_rate,
)
)
return trend
def build_summary_warnings(self, allocations: list[BudgetAllocationRead]) -> list[BudgetWarningRead]:
warnings: list[BudgetWarningRead] = []
for allocation in allocations:
balance = allocation.balance
if balance.available_amount < Decimal("0.00"):
severity = "danger"
message = (
f"{allocation.department_name} {allocation.subject_name} 可用预算为 "
f"{balance.available_amount} 元,已超出预算。"
)
elif balance.usage_rate >= allocation.warning_threshold:
severity = "warn"
message = (
f"{allocation.department_name} {allocation.subject_name} 使用率已达 "
f"{balance.usage_rate}%,达到预警线 {allocation.warning_threshold}%。"
)
else:
continue
warnings.append(
BudgetWarningRead(
allocation_id=allocation.id,
budget_no=allocation.budget_no,
fiscal_year=allocation.fiscal_year,
period_key=allocation.period_key,
department_name=allocation.department_name,
cost_center=allocation.cost_center,
subject_code=allocation.subject_code,
subject_name=allocation.subject_name,
total_amount=balance.total_amount,
reserved_amount=balance.reserved_amount,
consumed_amount=balance.consumed_amount,
available_amount=balance.available_amount,
usage_rate=balance.usage_rate,
warning_threshold=allocation.warning_threshold,
severity=severity,
message=message,
occurred_at=allocation.updated_at,
)
)
return sorted(
warnings,
key=lambda item: (
0 if item.severity == "danger" else 1,
-float(item.usage_rate),
item.department_name,
item.subject_name,
),
)
def get_allocation_row(self, allocation_id: str) -> BudgetAllocation | None:
self.ensure_budget_ready()
return self.db.get(BudgetAllocation, allocation_id)
@@ -543,6 +662,20 @@ class BudgetSupportMixin:
quarter = ((max(1, min(month, 12)) - 1) // 3) + 1
return f"{year}Q{quarter}"
@staticmethod
def _period_label(period_key: str) -> str:
text = str(period_key or "").strip().upper()
if len(text) >= 6 and text[:4].isdigit() and text[4] == "Q":
return f"{text[:4]}{text[4:]}"
return text or "未分期"
@staticmethod
def _period_sort_key(period_key: str) -> tuple[int, int, str]:
text = str(period_key or "").strip().upper()
year = int(text[:4]) if len(text) >= 4 and text[:4].isdigit() else 0
quarter = int(text[5:]) if len(text) >= 6 and text[4] == "Q" and text[5:].isdigit() else 0
return year, quarter, text
def _period_from_claim(self, claim: ExpenseClaim) -> tuple[int, str]:
occurred_at = claim.occurred_at or claim.submitted_at or datetime.now(UTC)
return occurred_at.year, self._quarter_key(occurred_at.year, occurred_at.month)

View File

@@ -12,7 +12,7 @@ from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.budget import BudgetAllocation
from app.models.budget import BudgetAllocation, BudgetTransaction
def build_session_factory() -> sessionmaker[Session]:
@@ -123,6 +123,90 @@ def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None
payload = response.json()
assert {item["department_name"] for item in payload["allocations"]} == {"市场部", "财务部"}
assert {item["subject_code"] for item in payload["allocations"]} == {"travel", "office"}
assert payload["warnings"] == []
assert [item["period_key"] for item in payload["trend"]] == ["2026Q2"]
assert Decimal(payload["trend"][0]["total_amount"]) == Decimal("80000.00")
def test_budget_summary_returns_real_trend_and_warnings() -> None:
client, session_factory = build_client()
now = datetime.now(UTC)
with session_factory() as db:
seed_budget_allocations(db)
db.add(
BudgetAllocation(
id="budget-market-travel-q1",
budget_no="BUD-MARKET-TRAVEL-Q1",
fiscal_year=2026,
period_type="quarter",
period_key="2026Q1",
department_id="dept-market",
department_name="市场部",
cost_center="CC-4100",
project_code=None,
subject_code="travel",
subject_name="差旅费",
original_amount=Decimal("40000.00"),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="block",
created_at=now,
updated_at=now,
)
)
db.add_all(
[
BudgetTransaction(
transaction_no="BTX-MARKET-TRAVEL-Q1",
allocation_id="budget-market-travel-q1",
source_type="claim",
source_id="claim-q1",
source_no="CLM-Q1",
transaction_type="consume",
amount=Decimal("12000.00"),
before_available_amount=Decimal("40000.00"),
after_available_amount=Decimal("28000.00"),
operator="tester",
reason="Q1 真实发生额",
created_at=now,
),
BudgetTransaction(
transaction_no="BTX-MARKET-TRAVEL-Q2",
allocation_id="budget-market-travel",
source_type="claim",
source_id="claim-q2",
source_no="CLM-Q2",
transaction_type="consume",
amount=Decimal("41000.00"),
before_available_amount=Decimal("50000.00"),
after_available_amount=Decimal("9000.00"),
operator="tester",
reason="Q2 真实发生额",
created_at=now,
),
]
)
db.commit()
response = client.get(
"/api/v1/budgets/summary?year=2026&period=2026Q2&cost_center=CC-4100",
headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"},
)
assert response.status_code == 200
payload = response.json()
assert [item["subject_code"] for item in payload["allocations"]] == ["travel"]
assert [item["period_key"] for item in payload["trend"]] == ["2026Q1", "2026Q2"]
assert Decimal(payload["trend"][0]["used_amount"]) == Decimal("12000.00")
assert Decimal(payload["trend"][1]["used_amount"]) == Decimal("41000.00")
assert payload["warning_count"] == 1
assert payload["over_budget_count"] == 0
assert len(payload["warnings"]) == 1
warning = payload["warnings"][0]
assert warning["subject_code"] == "travel"
assert warning["severity"] == "warn"
assert Decimal(warning["usage_rate"]) == Decimal("82.00")
def test_budget_monitor_is_limited_to_own_department_scope() -> None:

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,