feat: 新增预算助手报告组件并优化报销交互细节

新增预算助手报告视图模型和组件,优化报销洞察面板和消息项
样式细节,完善预算中心页面布局和文档中心视图,增强报销创
建会话管理和提交编排器,调整 Vite 构建配置,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 12:27:17 +08:00
parent b1a9c8a194
commit 7d32eae74e
23 changed files with 1197 additions and 464 deletions

View File

@@ -43,6 +43,21 @@
pointer-events: none;
}
.el-fade-in-linear-enter-active,
.el-fade-in-linear-leave-active {
transition: opacity 200ms linear;
}
.el-fade-in-linear-enter-from,
.el-fade-in-linear-leave-to {
opacity: 0;
}
.el-fade-in-linear-enter-to,
.el-fade-in-linear-leave-from {
opacity: 1;
}
.insight-head {
display: grid;
gap: 12px;
@@ -401,11 +416,6 @@
color: #cbd5e1;
}
.flow-step-item.completed .flow-step-status.completed {
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
}
.flow-step-item.running .flow-step-rail span {
border-color: #2563eb;
background: #2563eb;
@@ -429,32 +439,6 @@
background: #fef2f2;
}
.flow-step-reveal-enter-active,
.flow-step-reveal-leave-active {
transition:
opacity 0.24s ease,
transform 0.28s ease,
filter 0.28s ease;
}
.flow-step-reveal-enter-from,
.flow-step-reveal-leave-to {
opacity: 0;
filter: blur(2px);
transform: translateY(-8px);
}
.flow-step-reveal-enter-to,
.flow-step-reveal-leave-from {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
.flow-step-reveal-move {
transition: transform 0.24s ease;
}
.flow-empty-state,
.review-side-empty,
.review-document-preview-placeholder {

View File

@@ -54,6 +54,10 @@
max-width: min(100%, 980px);
}
.message-bubble-budget-report {
max-width: min(100%, 1080px);
}
.message-bubble-review-risk-low,
.message-bubble-review-risk-medium,
.message-bubble-review-risk-high {
@@ -487,6 +491,38 @@
opacity: 0.42;
}
.application-preview-footer {
margin-top: 48px;
color: #334155;
font-size: var(--wb-fs-bubble, 13px);
font-weight: 760;
line-height: 1.7;
}
.application-preview-footer.message-answer-markdown :deep(p) {
margin: 0;
}
.message-answer-markdown :deep(a) {
color: var(--theme-primary-active, #255b7d);
font-weight: 850;
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
.message-answer-markdown :deep(a:hover) {
color: var(--theme-primary, #3a7ca5);
}
.message-answer-markdown :deep(.markdown-action-link) {
color: var(--theme-primary-active, #255b7d);
font-weight: 880;
text-decoration: underline;
text-decoration-thickness: 1.6px;
text-underline-offset: 3px;
}
.application-preview-footer-missing {
display: flex;
flex-wrap: wrap;

View File

@@ -265,6 +265,46 @@
font-weight: 800;
}
.budget-table-search {
position: relative;
width: min(260px, 42%);
min-width: 190px;
display: flex;
align-items: center;
}
.budget-table-search i {
position: absolute;
left: 11px;
color: #94a3b8;
font-size: 15px;
pointer-events: none;
}
.budget-table-search input {
width: 100%;
height: 32px;
border: 1px solid #dbe4ee;
border-radius: 6px;
padding: 0 11px 0 32px;
background: #fff;
color: #1f2937;
font-size: 13px;
font-weight: 650;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.budget-table-search input::placeholder {
color: #94a3b8;
font-weight: 500;
}
.budget-table-search input:focus {
border-color: rgba(var(--theme-primary-rgb), .48);
box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb), .1);
}
.department-search {
position: relative;
margin: 12px 14px 8px;
@@ -320,7 +360,7 @@
.budget-table-panel table {
width: 100%;
min-width: 1040px;
min-width: 1460px;
border-collapse: collapse;
}
@@ -383,29 +423,36 @@
background: var(--danger);
}
.budget-warning-red {
color: var(--danger) !important;
font-weight: 800;
.budget-threshold-cell {
padding-left: 12px !important;
padding-right: 12px !important;
}
.budget-warning-yellow {
color: var(--warning-active) !important;
font-weight: 800;
}
.budget-row-actions {
display: flex;
.budget-threshold-badge {
min-width: 58px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14px;
padding: 4px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1.2;
}
.budget-row-actions button {
border: 0;
background: transparent;
color: var(--theme-primary-active);
font-size: 14px;
font-weight: 800;
.budget-threshold-badge.reminder {
background: rgba(37, 99, 235, .1);
color: #2563eb;
}
.budget-threshold-badge.alert {
background: rgba(245, 158, 11, .14);
color: #b45309;
}
.budget-threshold-badge.risk {
background: rgba(127, 29, 29, .1);
color: #7f1d1d;
}
.budget-table-foot {

View File

@@ -29,6 +29,7 @@
}
.employee-detail {
--employee-detail-radius: 4px;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
@@ -699,6 +700,7 @@ tbody tr:last-child td {
display: grid;
gap: 18px;
padding: 18px 20px;
border-radius: var(--employee-detail-radius);
}
.hero-profile {
@@ -712,7 +714,7 @@ tbody tr:last-child td {
height: 64px;
display: grid;
place-items: center;
border-radius: 18px;
border-radius: var(--employee-detail-radius);
background: var(--theme-gradient-primary);
color: #fff;
font-size: 24px;
@@ -724,7 +726,7 @@ tbody tr:last-child td {
align-items: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
border-radius: var(--employee-detail-radius);
background: #eff6ff;
color: #2563eb;
font-size: 12px;
@@ -753,7 +755,7 @@ tbody tr:last-child td {
.hero-stat {
padding: 14px 16px;
border-radius: 12px;
border-radius: var(--employee-detail-radius);
background: linear-gradient(180deg, #ffffff, #f8fafc);
border: 1px solid #edf2f7;
}
@@ -791,6 +793,7 @@ tbody tr:last-child td {
.detail-card,
.side-card {
padding: 18px;
border-radius: var(--employee-detail-radius);
}
.card-head {
@@ -819,7 +822,7 @@ tbody tr:last-child td {
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border-radius: var(--employee-detail-radius);
background: #ecfeff;
color: #0891b2;
font-size: 12px;
@@ -847,7 +850,7 @@ tbody tr:last-child td {
.field input {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 10px;
border-radius: var(--employee-detail-radius);
background: #fff;
color: #0f172a;
font-size: 13px;
@@ -985,7 +988,7 @@ tbody tr:last-child td {
align-items: start;
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 14px;
border-radius: var(--employee-detail-radius);
background: #fbfdff;
}
@@ -1018,6 +1021,19 @@ tbody tr:last-child td {
flex-wrap: wrap;
}
.tag-list span {
display: inline-flex;
min-height: 26px;
align-items: center;
padding: 0 9px;
border: 1px solid rgba(var(--theme-primary-rgb), 0.18);
border-radius: var(--employee-detail-radius);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 760;
}
.bullet-list {
display: grid;
gap: 8px;
@@ -1108,7 +1124,7 @@ td.cell-updated {
gap: 12px;
min-height: 42px;
padding: 0 12px;
border-radius: 10px;
border-radius: var(--employee-detail-radius);
background: #f8fafc;
}
@@ -1142,7 +1158,7 @@ td.cell-updated {
justify-content: center;
gap: 6px;
padding: 0 14px;
border-radius: 8px;
border-radius: var(--employee-detail-radius);
font-size: 13px;
font-weight: 760;
}

View File

@@ -0,0 +1,452 @@
<template>
<section class="budget-assistant-report" aria-label="预算编制分析报告">
<header class="budget-report-head">
<div>
<span class="budget-report-kicker">{{ report.basePeriod }} {{ report.targetPeriod }}</span>
<h3>{{ report.title }}</h3>
<p>{{ report.departmentName }} · {{ report.subtitle }}</p>
</div>
<span class="budget-report-badge">
<i class="mdi mdi-chart-donut"></i>
分析报告
</span>
</header>
<div class="budget-report-summary-grid">
<article
v-for="(metric, index) in summaryCards"
:key="metric.label"
class="budget-report-summary-card"
:style="{ '--delay': `${index * 55}ms`, '--accent': metric.color }"
>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.hint }}</small>
</article>
</div>
<section class="budget-report-main">
<article class="budget-report-chart-panel">
<div class="budget-report-section-head">
<strong>上季度费用结构</strong>
<span>{{ report.centerLabel }}</span>
</div>
<DonutChart
:items="report.items"
:center-value="report.centerValue"
:center-label="report.centerLabel"
/>
</article>
<article class="budget-report-macro-panel">
<div class="budget-report-section-head">
<strong>总体判断</strong>
<span>宏观分析</span>
</div>
<ol class="budget-report-insight-list">
<li v-for="item in report.macroInsights" :key="item">{{ item }}</li>
</ol>
</article>
</section>
<section class="budget-report-detail-panel">
<div class="budget-report-section-head">
<strong>费用类型拆解</strong>
<span>用于编制下一季度预算</span>
</div>
<div class="budget-report-expense-list">
<article
v-for="(item, index) in report.items"
:key="item.key"
class="budget-report-expense-card"
:style="{ '--accent': item.color, '--delay': `${index * 70}ms` }"
>
<header>
<span class="budget-report-dot"></span>
<strong>{{ item.name }}</strong>
<em :class="item.trendTone">{{ item.trend }}</em>
</header>
<div class="budget-report-expense-stats">
<span>开销 {{ item.amountDisplay }}</span>
<span>占比 {{ item.share }}</span>
<span>建议 {{ item.recommendedDisplay }}</span>
</div>
<p>{{ item.risk }}</p>
<ul>
<li v-for="driver in item.drivers" :key="driver">{{ driver }}</li>
</ul>
<footer>{{ item.suggestion }}</footer>
</article>
</div>
</section>
<section class="budget-report-action-panel">
<div>
<strong>编制建议</strong>
<p v-for="item in report.recommendations" :key="item">{{ item }}</p>
</div>
<span>{{ report.generatedAt }}</span>
</section>
</section>
</template>
<script setup>
import { computed } from 'vue'
import DonutChart from '../charts/DonutChart.vue'
const props = defineProps({
report: {
type: Object,
required: true
}
})
const summaryCards = computed(() => [
{
label: '上季度预算',
value: props.report.summary?.totalBudget || '—',
hint: '作为编制基准',
color: 'var(--theme-primary)'
},
{
label: '上季度开销',
value: props.report.summary?.totalSpend || '—',
hint: '按四类预算口径汇总',
color: 'var(--chart-blue)'
},
{
label: '预算使用率',
value: props.report.summary?.usageRate || '—',
hint: '未触达风险线',
color: 'var(--chart-amber)'
},
{
label: '建议编制额',
value: props.report.summary?.recommendedTotal || '—',
hint: '含业务增长预留',
color: 'var(--chart-purple)'
}
])
</script>
<style scoped>
.budget-assistant-report {
display: grid;
gap: 12px;
margin-top: 12px;
color: #1f2937;
animation: budgetReportIn 420ms var(--ease, ease) both;
}
.budget-report-head,
.budget-report-main,
.budget-report-detail-panel,
.budget-report-action-panel,
.budget-report-summary-card {
border: 1px solid #dbe4ee;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 20px rgba(15, 23, 42, .05);
}
.budget-report-head {
padding: 14px 16px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border-left: 3px solid var(--theme-primary);
}
.budget-report-kicker,
.budget-report-head p,
.budget-report-section-head span,
.budget-report-action-panel span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.budget-report-head h3 {
margin: 4px 0 4px;
color: #0f172a;
font-size: 17px;
font-weight: 850;
line-height: 1.3;
}
.budget-report-head p,
.budget-report-action-panel p {
margin: 0;
}
.budget-report-badge {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 10px;
border-radius: 999px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.budget-report-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.budget-report-summary-card {
min-height: 86px;
padding: 10px 12px;
border-left: 3px solid var(--accent);
display: grid;
align-content: center;
gap: 4px;
animation: budgetReportItemIn 460ms var(--ease, ease) both;
animation-delay: var(--delay, 0ms);
}
.budget-report-summary-card span,
.budget-report-summary-card small {
color: #64748b;
font-size: 11px;
font-weight: 650;
}
.budget-report-summary-card strong {
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.1;
}
.budget-report-main {
display: grid;
grid-template-columns: minmax(260px, .9fr) minmax(0, 1.1fr);
gap: 14px;
padding: 14px;
}
.budget-report-chart-panel,
.budget-report-macro-panel {
min-width: 0;
}
.budget-report-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.budget-report-section-head strong,
.budget-report-action-panel strong {
color: #111827;
font-size: 14px;
font-weight: 850;
}
.budget-report-chart-panel :deep(.donut-chart) {
min-height: 224px;
}
.budget-report-chart-panel :deep(.donut-body) {
height: 138px;
margin-top: 8px;
}
.budget-report-insight-list {
display: grid;
gap: 9px;
margin: 0;
padding-left: 20px;
}
.budget-report-insight-list li {
color: #334155;
font-size: 13px;
font-weight: 650;
line-height: 1.55;
}
.budget-report-detail-panel {
padding: 14px;
}
.budget-report-expense-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.budget-report-expense-card {
min-width: 0;
padding: 12px;
border: 1px solid #e2e8f0;
border-left: 3px solid var(--accent);
border-radius: 8px;
background: #fbfdff;
animation: budgetReportItemIn 460ms var(--ease, ease) both;
animation-delay: var(--delay, 0ms);
}
.budget-report-expense-card header,
.budget-report-expense-stats {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.budget-report-expense-card header {
margin-bottom: 8px;
}
.budget-report-dot {
width: 9px;
height: 9px;
border-radius: 3px;
background: var(--accent);
flex: 0 0 auto;
}
.budget-report-expense-card header strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.budget-report-expense-card header em {
margin-left: auto;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 850;
}
.budget-report-expense-card header em.risk {
background: rgba(127, 29, 29, .09);
color: #7f1d1d;
}
.budget-report-expense-card header em.warn {
background: rgba(245, 158, 11, .14);
color: #b45309;
}
.budget-report-expense-card header em.stable {
background: rgba(22, 163, 74, .1);
color: #15803d;
}
.budget-report-expense-stats {
flex-wrap: wrap;
margin-bottom: 8px;
}
.budget-report-expense-stats span {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 6px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 750;
}
.budget-report-expense-card p,
.budget-report-expense-card li,
.budget-report-expense-card footer,
.budget-report-action-panel p {
color: #334155;
font-size: 12px;
font-weight: 620;
line-height: 1.55;
}
.budget-report-expense-card p {
margin: 0 0 8px;
}
.budget-report-expense-card ul {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.budget-report-expense-card li {
padding: 2px 7px;
border-radius: 999px;
background: #fff;
border: 1px solid #e2e8f0;
}
.budget-report-expense-card footer {
padding-top: 8px;
border-top: 1px solid #e2e8f0;
}
.budget-report-action-panel {
padding: 14px 16px;
display: flex;
justify-content: space-between;
gap: 14px;
border-left: 3px solid var(--chart-amber);
}
.budget-report-action-panel div {
display: grid;
gap: 6px;
}
@keyframes budgetReportIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes budgetReportItemIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) {
.budget-report-summary-grid,
.budget-report-main,
.budget-report-expense-list {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.budget-assistant-report,
.budget-report-summary-card,
.budget-report-expense-card {
animation: none;
}
}
</style>

View File

@@ -147,7 +147,7 @@
<TransitionGroup
v-if="(ui.visibleFlowSteps || ui.flowSteps).length"
name="flow-step-reveal"
name="el-fade-in-linear"
tag="div"
class="review-flow-list"
>
@@ -356,7 +356,7 @@
<TransitionGroup
v-if="(ui.visibleFlowSteps || ui.flowSteps).length"
name="flow-step-reveal"
name="el-fade-in-linear"
tag="div"
class="review-flow-list"
>
@@ -630,7 +630,7 @@ export default {
methods: {
resolveFlowStepStyle(index) {
return {
transitionDelay: `${Math.min(Number(index) || 0, 5) * 70}ms`
transitionDelay: `${500 + Math.min(Number(index) || 0, 5) * 80}ms`
}
}
}

View File

@@ -35,6 +35,11 @@
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<BudgetAssistantReport
v-if="message.role === 'assistant' && message.budgetReport"
:report="message.budgetReport"
/>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
@@ -427,11 +432,13 @@
</template>
<script>
import BudgetAssistantReport from './BudgetAssistantReport.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
export default {
name: 'TravelReimbursementMessageItem',
components: {
BudgetAssistantReport,
EnterpriseSelect
},
props: {

View File

@@ -264,7 +264,14 @@ export function useAppShell() {
}
async function openSmartEntry(payload = {}) {
if (smartEntryOpen.value) {
const shouldReplaceOpenEntry = Boolean(
payload?.source === 'budget'
|| payload?.sessionType
|| String(payload?.prompt || '').trim()
|| (Array.isArray(payload?.files) && payload.files.length)
|| payload?.conversation
)
if (smartEntryOpen.value && !shouldReplaceOpenEntry) {
smartEntryRevealToken.value += 1
return
}

View File

@@ -513,16 +513,9 @@ export function buildLocalApplicationPreviewMessage(preview) {
export function buildApplicationPreviewFooterMessage(preview) {
const normalized = normalizeApplicationPreview(preview)
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
if (missingFields.length) {
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
}
if (modelReviewStatus === 'fallback') {
return '当前结果仅完成规则兜底复核,暂不直接提交。请核查表格内容,或稍后重新发起模型复核。'
}
if (modelReviewStatus === 'failed') {
return '当前结果仅作为临时预览,暂不直接提交。请稍后重试,或补充更明确的信息后再提交。'
}
return '请核对表格信息无误,确认无误点击 [确认](#application-submit) 提交至审批流程。'
return '请确认上述的信息是否填写正确?如果准确无误点击 [确认](#application-submit) 进入审批环节。'
}

View File

@@ -111,7 +111,11 @@
@summary-change="documentSummary = $event"
/>
<BudgetCenterView v-else-if="activeView === 'budget'" :current-user="currentUser" />
<BudgetCenterView
v-else-if="activeView === 'budget'"
:current-user="currentUser"
@open-assistant="openSmartEntry"
/>
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />

View File

@@ -51,7 +51,7 @@
</label>
</div>
<div class="budget-action-set">
<button v-if="canEditBudget" class="budget-primary-btn" type="button" @click="openBudgetEditDialog">
<button v-if="canEditBudget" class="budget-primary-btn" type="button" @click="openBudgetAssistant">
<i class="mdi mdi-pencil-outline"></i>
<span>编辑预算</span>
</button>
@@ -88,23 +88,39 @@
<article class="budget-table-panel">
<header>
<strong>当前部门{{ activeDepartmentName }}</strong>
<label class="budget-table-search">
<i class="mdi mdi-magnify"></i>
<input
v-model="budgetTableKeyword"
type="search"
placeholder="筛选预算明细"
aria-label="筛选预算明细"
/>
</label>
</header>
<div class="budget-table-wrap">
<table>
<thead>
<tr>
<th>编制时间</th>
<th>编制人</th>
<th>审核人</th>
<th>费用类型</th>
<th>预算金额</th>
<th>已发生</th>
<th>已占用</th>
<th>剩余可用</th>
<th>使用率</th>
<th>预警线</th>
<th>控制动作</th>
<th>提醒阈值</th>
<th>告警阈值</th>
<th>风险阈值</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleBudgetRows" :key="row.expenseType">
<td>{{ row.compiledAt }}</td>
<td>{{ row.compiler }}</td>
<td>{{ row.reviewer }}</td>
<td>{{ row.expenseType }}</td>
<td>{{ row.total }}</td>
<td>{{ row.used }}</td>
@@ -116,8 +132,15 @@
<div><em :class="row.rateTone" :style="{ width: `${Math.min(row.rate, 100)}%` }"></em></div>
</div>
</td>
<td :class="row.warningTone">{{ row.warningLine }}</td>
<td>{{ row.action }}</td>
<td class="budget-threshold-cell">
<span class="budget-threshold-badge reminder">{{ row.reminderLine }}</span>
</td>
<td class="budget-threshold-cell">
<span class="budget-threshold-badge alert">{{ row.alertLine }}</span>
</td>
<td class="budget-threshold-cell">
<span class="budget-threshold-badge risk">{{ row.riskLine }}</span>
</td>
</tr>
</tbody>
</table>
@@ -206,145 +229,9 @@
</article>
</section>
<Teleport to="body">
<Transition name="budget-dialog-fade">
<div v-if="budgetEditOpen" class="budget-dialog-backdrop" @click.self="closeBudgetEditDialog">
<section class="budget-edit-dialog" role="dialog" aria-modal="true" aria-label="编辑预算">
<header class="budget-edit-head">
<strong>编辑预算</strong>
<button class="budget-dialog-close" type="button" aria-label="关闭" @click="closeBudgetEditDialog">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="budget-edit-body">
<section class="budget-edit-section">
<h3>基本信息</h3>
<div class="budget-edit-form-grid">
<label class="required">
<span>预算年度</span>
<EnterpriseSelect v-model="budgetEditForm.budgetYear" :options="yearOptions" />
</label>
<label class="required">
<span>预算季度</span>
<EnterpriseSelect v-model="budgetEditForm.budgetQuarter" :options="quarters" />
</label>
<label v-if="canSwitchDepartments" class="required">
<span>所属部门</span>
<EnterpriseSelect v-model="budgetEditForm.departmentCode" :options="departmentOptions" />
</label>
<label v-else class="required">
<span>所属部门</span>
<input :value="activeDepartmentName" type="text" disabled />
</label>
</div>
<label class="budget-edit-textarea">
<span>预算说明</span>
<textarea v-model="budgetEditForm.budgetDescription" maxlength="300"></textarea>
<em>{{ budgetEditForm.budgetDescription.length }}/300</em>
</label>
</section>
<section class="budget-edit-section">
<h3>预算明细</h3>
<div class="budget-edit-table-wrap">
<table class="budget-edit-table">
<thead>
<tr>
<th>费用类型 <i>*</i></th>
<th>预算金额 <i>*</i></th>
<th>预警线% <i>*</i></th>
<th>控制动作 <i>*</i></th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in budgetEditRows" :key="row.id">
<td>
<EnterpriseSelect
v-model="row.budgetSubjectCode"
:options="expenseTypeOptions"
size="small"
@change="syncBudgetRowSubject(row)"
/>
</td>
<td><input v-model="row.budgetAmount" type="text" inputmode="decimal" /></td>
<td>
<EnterpriseSelect v-model="row.warningThreshold" :options="warningOptions" size="small" />
</td>
<td>
<EnterpriseSelect v-model="row.controlAction" :options="controlActionOptions" size="small" />
</td>
<td><input v-model="row.budgetRemark" type="text" /></td>
<td>
<button
class="budget-row-delete"
type="button"
aria-label="删除预算明细"
@click="removeBudgetDetailRow(row.id)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button class="budget-add-row-btn" type="button" @click="addBudgetDetailRow">
<i class="mdi mdi-plus"></i>
<span>添加行</span>
</button>
<div class="budget-edit-total">
<span>合计金额</span>
<strong>¥ {{ budgetEditTotal }}</strong>
</div>
</section>
</div>
<footer class="budget-edit-foot">
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</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="确认删除"
description="确定要删除当前预算明细行吗?删除后不可恢复。"
confirm-text="确认删除"
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"
@close="cancelDeleteRow"
@confirm="confirmDeleteRow"
/>
</Teleport>
</section>
</template>
<script src="./scripts/BudgetCenterView.js"></script>
<style scoped src="../assets/styles/views/budget-center-view.css"></style>
<style scoped src="../assets/styles/views/budget-center-dialog.css"></style>

View File

@@ -93,7 +93,7 @@
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<button class="filter-btn date-range-trigger" type="button" @click="toggleDatePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
@@ -646,6 +646,16 @@ function hasActiveFilters() {
function toggleFilter(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
if (openFilterKey.value) {
datePopover.value = false
}
}
function toggleDatePopover() {
datePopover.value = !datePopover.value
if (datePopover.value) {
openFilterKey.value = ''
}
}
function selectDocumentType(value) {

View File

@@ -379,10 +379,12 @@
</form>
</section>
<TravelReimbursementInsightPanel
v-if="hasInsightPanelContent"
:ui="insightPanelUi"
/>
<Transition name="el-fade-in-linear" appear>
<TravelReimbursementInsightPanel
v-if="hasInsightPanelContent"
:ui="insightPanelUi"
/>
</Transition>
</div>
</div>

View File

@@ -1,9 +1,8 @@
import { computed, onMounted, ref, watch } from 'vue'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import { createBudgetAllocation, fetchBudgetSummary } from '../../services/budgets.js'
import { fetchBudgetSummary } from '../../services/budgets.js'
import { fetchEmployeeMeta } from '../../services/employees.js'
import {
canEditBudgetCenter,
@@ -12,14 +11,9 @@ import {
isExecutiveUser
} from '../../utils/accessControl.js'
import {
BUDGET_CONTROL_ACTION_OPTIONS,
BUDGET_QUARTER_OPTIONS,
BUDGET_STATUS_OPTIONS,
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
BUDGET_WARNING_OPTIONS,
BUDGET_YEAR_OPTIONS,
buildBudgetOntologyContext,
formatBudgetPeriod,
resolveBudgetExpenseTypeLabel
} from '../../utils/budgetOntology.js'
@@ -66,13 +60,19 @@ const comparison = (value, direction) => ({
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
})
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 BUDGET_COMPILED_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
const normalizePeriodKey = (year, quarter) => {
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
@@ -87,19 +87,49 @@ const parsePercent = (value, fallback = 80) => {
return Number.isFinite(parsed) ? parsed : fallback
}
const resolveControlActionCode = (value) => {
if (value === BUDGET_CONTROL_ACTION_OPTIONS[0]) return 'allow'
if (value === BUDGET_CONTROL_ACTION_OPTIONS[1]) return 'warn'
if (value === BUDGET_CONTROL_ACTION_OPTIONS[2]) return 'block'
return String(value || '').trim() || 'block'
const clampPercent = (value) => Math.min(100, Math.max(0, Number(value) || 0))
function buildThresholds(warning) {
const alert = clampPercent(warning)
return {
reminder: clampPercent(alert - 10),
alert,
risk: clampPercent(alert + 10)
}
}
const resolveControlActionLabel = (value) => {
const normalized = String(value || '').trim().toLowerCase()
if (normalized === 'allow') return BUDGET_CONTROL_ACTION_OPTIONS[0]
if (normalized === 'warn') return BUDGET_CONTROL_ACTION_OPTIONS[1]
if (normalized === 'block') return BUDGET_CONTROL_ACTION_OPTIONS[2]
return value || BUDGET_CONTROL_ACTION_OPTIONS[2]
function formatBudgetCompiledAt(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return BUDGET_COMPILED_TIME_FORMATTER.format(date).replace(/\//g, '-')
}
function resolveBudgetCompiler(item) {
return String(
item?.compiler
|| item?.compiled_by
|| item?.compiledBy
|| item?.created_by
|| item?.createdBy
|| item?.owner_name
|| item?.ownerName
|| '预算编制助手'
).trim()
}
function resolveBudgetReviewer(item) {
return String(
item?.reviewer
|| item?.reviewed_by
|| item?.reviewedBy
|| item?.approved_by
|| item?.approvedBy
|| item?.auditor
|| item?.updated_by
|| item?.updatedBy
|| '高级财务人员'
).trim()
}
function normalizeBudgetAllocationRow(item) {
@@ -110,6 +140,7 @@ function normalizeBudgetAllocationRow(item) {
const leftAmount = Number(balance.available_amount ?? 0)
const rate = Number(balance.usage_rate ?? 0)
const warning = parsePercent(item?.warning_threshold, 80)
const thresholds = buildThresholds(warning)
const budgetSubjectCode = String(item?.subject_code || '').trim()
const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode)
@@ -117,17 +148,22 @@ function normalizeBudgetAllocationRow(item) {
allocationId: item?.id || '',
budgetNo: item?.budget_no || '',
budgetSubjectCode,
compiledAt: formatBudgetCompiledAt(item?.created_at || item?.createdAt || item?.updated_at || item?.updatedAt),
compiler: resolveBudgetCompiler(item),
reviewer: resolveBudgetReviewer(item),
expenseType,
totalAmount,
usedAmount,
occupiedAmount,
leftAmount,
rate,
rateTone: rate >= warning ? 'danger' : rate >= warning - 12 ? 'warn' : 'ok',
warning,
warningTone: warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
warningLine: `${warning}%`,
action: resolveControlActionLabel(item?.control_action),
rateTone: rate >= thresholds.risk ? 'danger' : rate >= thresholds.alert ? 'warn' : 'ok',
reminderThreshold: thresholds.reminder,
alertThreshold: thresholds.alert,
riskThreshold: thresholds.risk,
reminderLine: `${thresholds.reminder}%`,
alertLine: `${thresholds.alert}%`,
riskLine: `${thresholds.risk}%`,
total: currency(totalAmount),
used: currency(usedAmount),
occupied: currency(occupiedAmount),
@@ -176,12 +212,12 @@ export default {
default: () => ({})
}
},
emits: ['openAssistant'],
components: {
BudgetTrendChart,
ConfirmDialog,
EnterpriseSelect
},
setup(props) {
setup(props, { emit }) {
const departments = ref(FALLBACK_DEPARTMENTS)
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
@@ -193,25 +229,11 @@ export default {
})
const budgetPage = ref(1)
const budgetPageSize = ref(5)
const budgetTableKeyword = ref('')
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',
budgetPeriod: '2026年Q1',
departmentCode: FALLBACK_DEPARTMENTS[0].code,
costCenter: FALLBACK_DEPARTMENTS[0].costCenter,
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: ''
})
const budgetEditRows = ref([])
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
const isDepartmentBudgetMonitor = computed(
@@ -238,8 +260,26 @@ export default {
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
)
const departmentRows = computed(() => budgetRows.value)
const filteredBudgetRows = computed(() =>
departmentRows.value
const filteredBudgetRows = computed(() => {
const keyword = budgetTableKeyword.value.trim().toLowerCase()
return departmentRows.value
.filter((row) => {
if (!keyword) return true
return [
row.compiledAt,
row.compiler,
row.reviewer,
row.expenseType,
row.total,
row.used,
row.occupied,
row.left,
`${row.rate}%`,
row.reminderLine,
row.alertLine,
row.riskLine
].some((value) => String(value || '').toLowerCase().includes(keyword))
})
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
if (filters.value.status === '全部') return true
@@ -247,7 +287,7 @@ export default {
if (filters.value.status === '管控') return row.rateTone === 'danger'
return row.rateTone === 'ok'
})
)
})
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
const totalBudgetPages = computed(() =>
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
@@ -330,120 +370,18 @@ export default {
const budgetUsageData = computed(() =>
normalizeBudgetUsageData(departmentRows.value)
)
const budgetEditTotal = computed(() =>
currency(
budgetEditRows.value.reduce(
(sum, row) => sum + parseBudgetAmount(row.budgetAmount),
0
)
)
)
const budgetOntologyContext = computed(() =>
buildBudgetOntologyContext({
form: budgetEditForm.value,
rows: budgetEditRows.value,
departments: departments.value
})
)
function buildEditableRows() {
const rows = departmentRows.value.length ? departmentRows.value : EXPENSE_BLUEPRINTS.map((row) => ({
...row,
totalAmount: row.total || 0,
warning: row.warning || 80,
action: row.action || BUDGET_CONTROL_ACTION_OPTIONS[2]
}))
return rows.map((row) => ({
id: makeBudgetRowId(),
budgetSubject: row.expenseType,
budgetSubjectCode: row.budgetSubjectCode || '',
budgetAmount: currency(row.totalAmount),
warningThreshold: `${row.warning}%`,
controlAction: row.action,
budgetRemark: `${row.expenseType}相关费用`
}))
}
function resolveNextExpenseTypeOption() {
const usedCodes = new Set(budgetEditRows.value.map((row) => row.budgetSubjectCode))
return (
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS[0]
)
}
function syncBudgetRowSubject(row) {
row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject)
}
function openBudgetEditDialog() {
function openBudgetAssistant() {
if (!canEditBudget.value) return
const department = activeDepartment.value
const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter)
budgetEditForm.value = {
budgetYear: filters.value.year,
budgetQuarter: filters.value.quarter,
budgetPeriod,
departmentCode: department?.code || activeDepartmentCode.value,
costCenter: department?.costCenter || '',
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: `${department?.name || '当前部门'}2026年度预算编制用于指导费用支出及控制成本确保资源合理使用。`
}
budgetEditRows.value = buildEditableRows()
budgetEditOpen.value = true
}
function closeBudgetEditDialog() {
confirmSaveOpen.value = false
budgetEditOpen.value = false
}
function addBudgetDetailRow() {
const option = resolveNextExpenseTypeOption()
budgetEditRows.value.push({
id: makeBudgetRowId(),
budgetSubject: option.label,
budgetSubjectCode: option.value,
budgetAmount: '0.00',
warningThreshold: '70%',
controlAction: '正常',
budgetRemark: ''
emit('openAssistant', {
source: 'budget',
sessionType: 'budget',
prompt: '',
files: [],
conversation: null
})
}
const confirmDeleteOpen = ref(false)
const rowToDelete = ref(null)
function removeBudgetDetailRow(rowId) {
if (budgetEditRows.value.length <= 1) return
rowToDelete.value = rowId
confirmDeleteOpen.value = true
}
function confirmDeleteRow() {
if (rowToDelete.value !== null) {
budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowToDelete.value)
rowToDelete.value = null
}
confirmDeleteOpen.value = false
}
function cancelDeleteRow() {
rowToDelete.value = null
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)
@@ -453,44 +391,6 @@ export default {
goToBudgetPage(currentBudgetPage.value + direction)
}
function buildBudgetPayloads(status) {
const department = activeDepartment.value || {}
return budgetEditRows.value.map((row) => ({
fiscal_year: Number(String(budgetEditForm.value.budgetYear || filters.value.year || '2026').replace(/[^\d]/g, '')),
period_type: 'quarter',
period_key: normalizePeriodKey(
budgetEditForm.value.budgetYear || filters.value.year,
budgetEditForm.value.budgetQuarter || filters.value.quarter
),
department_id: department.id || null,
department_name: department.name || '',
cost_center: budgetEditForm.value.costCenter || department.costCenter || '',
project_code: '',
subject_code: row.budgetSubjectCode || '',
subject_name: row.budgetSubject || resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject),
original_amount: parseBudgetAmount(row.budgetAmount),
warning_threshold: parsePercent(row.warningThreshold, 80),
control_action: resolveControlActionCode(row.controlAction),
description: budgetEditForm.value.budgetDescription || status
}))
}
async function saveBudgetRows(status) {
if (!canEditBudget.value) return
budgetSaving.value = true
try {
const payloads = buildBudgetPayloads(status)
for (const payload of payloads) {
await createBudgetAllocation(payload)
}
await loadBudgetData()
closeBudgetEditDialog()
} finally {
budgetSaving.value = false
}
}
function resolveScopedDepartments(options) {
if (!isDepartmentBudgetMonitor.value) {
return options
@@ -568,13 +468,6 @@ export default {
}
}
async function confirmSaveBudget() {
if (!canEditBudget.value || budgetSaving.value) return
budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[0]
await saveBudgetRows('saved')
confirmSaveOpen.value = false
}
onMounted(() => {
void loadDepartments()
})
@@ -586,7 +479,8 @@ export default {
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.expenseType,
() => filters.value.status
() => filters.value.status,
budgetTableKeyword
],
() => {
budgetPage.value = 1
@@ -609,52 +503,31 @@ export default {
return {
activeDepartmentCode,
activeDepartmentName,
addBudgetDetailRow,
budgetEditForm,
budgetEditOpen,
budgetEditRows,
budgetEditTotal,
budgetError,
budgetLoading,
budgetMetrics,
budgetOntologyContext,
budgetSaving,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions,
budgetTableKeyword,
canEditBudget,
canSwitchDepartments,
closeBudgetEditDialog,
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
changeBudgetPage,
confirmSaveBudget,
confirmSaveOpen,
departmentKeyword,
departments,
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
filters,
openBudgetEditDialog,
openBudgetAssistant,
quarters: BUDGET_QUARTER_OPTIONS,
addBudgetDetailRow,
removeBudgetDetailRow,
confirmDeleteOpen,
confirmDeleteRow,
cancelDeleteRow,
cancelSaveBudget,
departmentOptions,
requestSaveBudget,
statusOptions: BUDGET_STATUS_OPTIONS,
statuses: ['全部', '正常', '预警', '管控'],
syncBudgetRowSubject,
goToBudgetPage,
totalBudgetPages,
totalBudgetRows,
budgetUsageData,
visibleBudgetRows,
visibleDepartments,
warningOptions: BUDGET_WARNING_OPTIONS,
warnings,
yearOptions,
years: BUDGET_YEAR_OPTIONS

View File

@@ -618,6 +618,7 @@ export default {
const {
flowRunId,
flowSteps,
activeFlowSteps,
visibleFlowSteps,
flowRefreshBusy,
completedFlowStepCount,
@@ -677,7 +678,7 @@ export default {
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(() => {
return isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
return isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || activeFlowSteps.value.length > 0
})
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
@@ -696,6 +697,9 @@ export default {
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
}
if (activeSessionType.value === SESSION_TYPE_BUDGET) {
return '例如:查询市场部 Q1 预算编制情况,重点看差旅、通信、招待费和办公用品。'
}
return '例如查一下近10日报销金额、解释酒店超标风险或根据附件整理报销核对信息。'
})
const currentIntentLabel = computed(() => {
@@ -807,7 +811,7 @@ export default {
activeReviewPayload,
activeReviewPanelScope,
reviewFilePreviews,
flowSteps,
flowSteps: activeFlowSteps,
submitting,
reviewActionBusy,
triggerFileUpload: (...args) => triggerFileUpload(...args),
@@ -1087,15 +1091,19 @@ export default {
})
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() =>
filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value).map((mode) => ({
const shortcuts = computed(() => {
const accessibleModes = filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value)
const visibleModes = props.entrySource === 'budget'
? accessibleModes.filter((mode) => mode.key === SESSION_TYPE_BUDGET)
: accessibleModes
return visibleModes.map((mode) => ({
label: mode.label,
icon: mode.icon,
action: 'switch_view',
targetSessionType: mode.key,
active: mode.key === activeSessionType.value
}))
)
})
watch(
() => [activeReviewPayload.value, activeReviewPanelScope.value],
([payload]) => {
@@ -1103,7 +1111,7 @@ export default {
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
// ? REVIEW_DRAWER_MODE_RISK
// : REVIEW_DRAWER_MODE_REVIEW
const shouldKeepFlowDrawer = reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && flowSteps.value.length > 0
const shouldKeepFlowDrawer = reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && activeFlowSteps.value.length > 0
resetReviewDrawerFromPayload(payload)
if (shouldKeepFlowDrawer) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
@@ -1148,6 +1156,17 @@ export default {
}
)
watch(
() => [activeSessionType.value, activeFlowSteps.value.length],
([, activeCount], [, previousActiveCount] = []) => {
if (activeCount <= 0 || previousActiveCount > 0) {
return
}
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
insightPanelCollapsed.value = false
}
)
watch(
() => composerDraft.value,
() => {
@@ -1664,6 +1683,9 @@ export default {
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.budgetReport) {
return 'message-bubble-budget-report'
}
if (message?.role === 'assistant' && message?.applicationPreview) {
return 'message-bubble-application-preview'
}
@@ -2217,7 +2239,7 @@ export default {
flowRunId: flowRunId.value,
flowRefreshBusy: flowRefreshBusy.value,
refreshFlowRunDetail,
flowSteps: flowSteps.value,
flowSteps: activeFlowSteps.value,
visibleFlowSteps: visibleFlowSteps.value,
resolveFlowStepStatusLabel,
formatFlowStepDuration,

View File

@@ -0,0 +1,277 @@
const QUARTER_NAME_MAP = {
1: 'Q1',
2: 'Q2',
3: 'Q3',
4: 'Q4'
}
const CHINESE_QUARTER_MAP = {
: 1,
: 2,
: 3,
: 4
}
const BUDGET_REPORT_COLORS = {
travel: 'var(--theme-primary)',
meal: 'var(--chart-amber)',
office: 'var(--chart-blue)',
communication: 'var(--chart-purple)'
}
const PREVIOUS_QUARTER_SPEND = [
{
key: 'travel',
name: '差旅',
value: 468000,
previousValue: 395600,
recommendedBudget: 550000,
color: BUDGET_REPORT_COLORS.travel,
drivers: ['客户现场实施增加', '跨区域项目支持', '住宿单价上浮'],
risk: 'Q2 差旅占比最高Q3 如果继续集中出差,建议把提醒阈值放在 75%。',
suggestion: '建议 Q3 编制 52-56 万,优先锁定核心项目差旅,非项目型出行走事前说明。'
},
{
key: 'meal',
name: '招待费',
value: 286000,
previousValue: 255100,
recommendedBudget: 320000,
color: BUDGET_REPORT_COLORS.meal,
drivers: ['重点客户拜访', '渠道活动增多', '单次人均金额偏高'],
risk: '招待费增长快于整体费用,容易触发合规说明和客户关联材料补充。',
suggestion: '建议 Q3 编制 30-32 万,并按客户拓展活动拆分额度,避免月底集中消耗。'
},
{
key: 'office',
name: '办公用品',
value: 181600,
previousValue: 172000,
recommendedBudget: 200000,
color: BUDGET_REPORT_COLORS.office,
drivers: ['新员工入职物资', '会议设备补充', '季度集中采购'],
risk: '办公用品整体平稳,但集中采购会造成单月占用偏高。',
suggestion: '建议 Q3 编制 19-20 万,采用月度采购节奏,把一次性采购纳入占用预留。'
},
{
key: 'communication',
name: '通信',
value: 98000,
previousValue: 102800,
recommendedBudget: 110000,
color: BUDGET_REPORT_COLORS.communication,
drivers: ['固定通讯补贴', '项目远程协同', '少量专线费用'],
risk: '通信费用占比较低且略有下降,适合维持刚性额度。',
suggestion: '建议 Q3 编制 10-11 万,保留 8% 弹性池用于项目临时通讯支出。'
}
]
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
const compactCurrency = (value) => `¥${(Number(value || 0) / 10000).toFixed(1)}`
const percent = (value, total) => {
const denominator = Number(total || 0)
if (!denominator) return '0.0%'
return `${((Number(value || 0) / denominator) * 100).toFixed(1)}%`
}
function normalizeBudgetText(rawText) {
return String(rawText || '')
.replace(/\s+/g, '')
.toLowerCase()
}
function parseQuarter(rawText) {
const text = String(rawText || '')
const qMatch = text.match(/[qQ]\s*([1-4])/)
if (qMatch) return Number(qMatch[1])
const numberMatch = text.match(/第?\s*([1-4])\s*(?:季|季度)/)
if (numberMatch) return Number(numberMatch[1])
const chineseMatch = text.match(/第?\s*([一二三四])\s*(?:季|季度)/)
if (chineseMatch) return CHINESE_QUARTER_MAP[chineseMatch[1]] || 0
return 0
}
function parseYear(rawText) {
const match = String(rawText || '').match(/(20\d{2})/)
return match ? Number(match[1]) : 2026
}
function resolvePreviousPeriod(year, quarter) {
if (quarter > 1) {
return { year, quarter: quarter - 1 }
}
return { year: year - 1, quarter: 4 }
}
export function shouldUseBudgetCompileReport(rawText, options = {}) {
if (String(options.sessionType || '').trim() !== 'budget') {
return false
}
const text = normalizeBudgetText(rawText)
return Boolean(
text &&
/(预算|budget)/.test(text) &&
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
parseQuarter(rawText)
)
}
export function buildBudgetCompileReport(rawText, user = {}) {
const targetYear = parseYear(rawText)
const targetQuarter = parseQuarter(rawText) || 3
const previous = resolvePreviousPeriod(targetYear, targetQuarter)
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value, 0)
const totalBudget = 1320000
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget, 0)
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
const trendValue = item.previousValue
? ((item.value - item.previousValue) / item.previousValue) * 100
: 0
return {
...item,
amountDisplay: compactCurrency(item.value),
display: percent(item.value, totalSpend),
share: percent(item.value, totalSpend),
trend: `${trendValue >= 0 ? '+' : ''}${trendValue.toFixed(1)}%`,
trendTone: trendValue >= 10 ? 'risk' : trendValue >= 0 ? 'warn' : 'stable',
recommendedDisplay: compactCurrency(item.recommendedBudget)
}
})
const topItem = [...items].sort((a, b) => b.value - a.value)[0]
const growthItem = [...items].sort((a, b) => {
const aGrowth = a.previousValue ? (a.value - a.previousValue) / a.previousValue : 0
const bGrowth = b.previousValue ? (b.value - b.previousValue) / b.previousValue : 0
return bGrowth - aGrowth
})[0]
return {
type: 'budget_compile_analysis',
title: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`,
departmentName,
targetPeriod: `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`,
basePeriod: `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
centerValue: compactCurrency(totalSpend),
centerLabel: '上季度开销',
summary: {
totalBudget: compactCurrency(totalBudget),
totalSpend: compactCurrency(totalSpend),
usageRate: percent(totalSpend, totalBudget),
recommendedTotal: compactCurrency(recommendedTotal)
},
macroInsights: [
`${previous.year}${previous.quarter}季度实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${targetYear}${targetQuarter}季度预算编制的第一优先级。`,
`${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
],
items,
recommendations: [
`建议${targetYear}${targetQuarter}季度总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
],
generatedAt: '模拟数据 · 用于 Demo 预览'
}
}
export async function handleBudgetCompileReportSubmit(runtime) {
const {
adjustComposerTextareaHeight,
clearAttachedFiles,
completeFlowStep,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
createMessage,
currentUser,
fileInputRef,
fileNames,
messages,
nextTick,
options,
persistSessionState,
rawText,
replaceMessage,
resetFlowRun,
scrollToBottom,
startFlowStep,
submitting,
userText
} = runtime
const analysisStartedAt = Date.now()
resetFlowRun()
startFlowStep('budget-prior-quarter-analysis', {
title: '上季度预算开销分析',
tool: 'budget.analysis.previous_quarter',
detail: '正在汇总上季度费用占比、增长点和下一季度编制建议...'
})
startFlowStep('budget-compile-guidance', {
title: '预算编制建议生成',
tool: 'budget.compile.recommendation',
detail: '正在生成预算编制前置分析报告...'
})
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingMessage = createMessage(
'assistant',
'我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。',
[],
{ meta: ['预算分析中'] }
)
messages.value.push(pendingMessage)
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
submitting.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
try {
await new Promise((resolve) => setTimeout(resolve, 360))
const budgetReport = buildBudgetCompileReport(rawText, currentUser.value || {})
completeFlowStep(
'budget-prior-quarter-analysis',
'已完成上季度费用占比、增长点和风险点分析',
Date.now() - analysisStartedAt
)
completeFlowStep(
'budget-compile-guidance',
'已生成下一季度预算编制建议',
Date.now() - analysisStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
'下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。',
[],
{
meta: ['预算分析报告', '模拟数据'],
budgetReport
}
))
persistSessionState()
} finally {
submitting.value = false
nextTick(scrollToBottom)
}
return null
}

View File

@@ -2,7 +2,7 @@ import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
import { isBudgetMonitorUser, isExecutiveUser } from '../../utils/accessControl.js'
import { isBudgetMonitorUser, isExecutiveUser, isPlatformAdminUser } from '../../utils/accessControl.js'
import {
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_APPLICATION,
@@ -58,7 +58,7 @@ export const ASSISTANT_SESSION_MODE_OPTIONS = [
]
export function canUseBudgetAssistantSession(user = null) {
return Boolean(isBudgetMonitorUser(user) || isExecutiveUser(user))
return Boolean(isPlatformAdminUser(user) || isBudgetMonitorUser(user) || isExecutiveUser(user))
}
function canUseAssistantSessionType(sessionType, user = null) {
@@ -102,6 +102,7 @@ export const SOURCE_LABELS = {
workbench: '来自个人工作台',
topbar: '来自发起报销',
application: '来自发起申请',
budget: '来自预算中心',
detail: '来自智能录入',
upload: '来自附件上传',
requests: '来自报销列表'
@@ -111,6 +112,7 @@ export const SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
budget: '预算',
knowledge: '知识',
unknown: '通用'
}
@@ -230,6 +232,24 @@ export const APPROVAL_WELCOME_QUICK_ACTIONS = [
}
]
export const BUDGET_WELCOME_QUICK_ACTIONS = [
{
label: '预算编制查询',
prompt: '帮我查询当前部门本季度预算编制情况,重点看差旅、通信、招待费和办公用品。',
icon: 'mdi mdi-calculator-variant-outline'
},
{
label: '阈值风险检查',
prompt: '帮我检查当前预算的提醒阈值、告警阈值和风险阈值设置是否合理,并指出需要关注的费用类型。',
icon: 'mdi mdi-alert-decagram-outline'
},
{
label: '预算调整建议',
prompt: '请根据已发生、已占用和剩余预算,帮我整理下一轮预算调整建议。',
icon: 'mdi mdi-chart-box-plus-outline'
}
]
export const HOT_KNOWLEDGE_QUESTIONS = [
'差旅住宿标准按什么规则执行?',
'酒店超标后如何申请例外报销?',
@@ -287,6 +307,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
riskFlags: [],
pendingAttachmentAssociation: null,
applicationPreview: null,
budgetReport: null,
...extras
}
}
@@ -557,6 +578,10 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
return APPROVAL_WELCOME_QUICK_ACTIONS
}
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
return BUDGET_WELCOME_QUICK_ACTIONS
}
return EXPENSE_WELCOME_QUICK_ACTIONS
}
@@ -601,6 +626,18 @@ export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SE
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 预算编制助手。** 我可以帮您查询预算编制情况、整理费用类型预算、检查提醒/告警/风险阈值,并保持预算对话独立记录。',
'',
'业务范围:预算编制查询、部门预算检查、费用类型额度梳理、预算占用说明和阈值风险分析。报销发起、审核动作和制度问答请切换到对应助手。',
'',
'您可以直接输入预算问题,或点击下方快捷操作快速开始。'
].join('\n')
}
if (entrySource === 'detail' && linkedRequest?.id) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
@@ -659,6 +696,17 @@ export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SE
}
}
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
return {
intent: 'welcome',
metricLabel: '当前助手',
metricValue: '预算编制助手',
title: '预算编制助手',
summary: `${ctx.honorific},这里会单独保存预算相关对话,适合查询预算编制、预算占用和阈值风险。`,
agent: null
}
}
return {
intent: 'welcome',
metricLabel: '当前助手',
@@ -837,6 +885,7 @@ export function serializeSessionMessages(messages) {
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
budgetReport: message.budgetReport || null,
assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
@@ -857,6 +906,7 @@ export function hasMeaningfulSessionMessages(messages) {
|| message.reviewPayload
|| message.queryPayload
|| message.draftPayload
|| message.budgetReport
)
})
}

View File

@@ -86,6 +86,7 @@ export function useTravelReimbursementFlow({
FLOW_STEP_STATUS_FAILED
}) {
const flowRunId = ref('')
const flowSessionType = ref('')
const flowStartedAt = ref(0)
const flowFinishedAt = ref(0)
const flowSteps = ref([])
@@ -94,15 +95,23 @@ export function useTravelReimbursementFlow({
let flowTickTimer = 0
const flowSimulationTimers = []
const activeFlowSteps = computed(() => (
flowSessionType.value === resolveCurrentFlowSessionType()
? flowSteps.value
: []
))
const completedFlowStepCount = computed(
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
() => activeFlowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
)
const rawRunningFlowStep = computed(
() => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
)
const runningFlowStep = computed(
() => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
() => activeFlowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
)
const visibleFlowSteps = computed(() => {
const visibleSteps = []
for (const step of flowSteps.value) {
for (const step of activeFlowSteps.value) {
visibleSteps.push(step)
if (step.status !== FLOW_STEP_STATUS_COMPLETED) {
break
@@ -111,19 +120,23 @@ export function useTravelReimbursementFlow({
return visibleSteps
})
const flowOverallStatusTone = computed(() => {
if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
if (activeFlowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
return 'failed'
}
if (runningFlowStep.value) {
return 'running'
}
if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) {
if (
activeFlowSteps.value.length
&& completedFlowStepCount.value === activeFlowSteps.value.length
&& flowStartedAt.value
) {
return 'completed'
}
return 'pending'
})
const flowOverallStatusText = computed(() => {
const total = flowSteps.value.length
const total = activeFlowSteps.value.length
const completed = completedFlowStepCount.value
if (flowOverallStatusTone.value === 'failed') {
return `异常 ${completed}/${total}`
@@ -146,13 +159,17 @@ export function useTravelReimbursementFlow({
return formatFlowDuration(finishedAt - flowStartedAt.value)
}
const measuredDuration = flowSteps.value.reduce((total, step) => {
const measuredDuration = activeFlowSteps.value.reduce((total, step) => {
const duration = Number(step.durationMs)
return total + (Number.isFinite(duration) && duration > 0 ? duration : 0)
}, 0)
return measuredDuration ? formatFlowDuration(measuredDuration) : '--'
})
function resolveCurrentFlowSessionType() {
return String(activeSessionType?.value || '').trim()
}
function startFlowTick() {
if (flowTickTimer) {
return
@@ -183,6 +200,7 @@ export function useTravelReimbursementFlow({
const shouldOpenDrawer = options.openDrawer !== false
const startedAt = Number(options.startedAt)
flowRunId.value = ''
flowSessionType.value = String(options.sessionType || resolveCurrentFlowSessionType()).trim()
flowStartedAt.value = Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : Date.now()
flowFinishedAt.value = 0
if (shouldOpenDrawer) {
@@ -242,6 +260,9 @@ export function useTravelReimbursementFlow({
}
function startFlowStep(key, patch = {}) {
if (!flowSessionType.value) {
flowSessionType.value = resolveCurrentFlowSessionType()
}
const normalizedPatch = normalizeFlowStepPatch(key, patch)
const explicitStartedAt = Number(normalizedPatch.startedAt)
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
@@ -327,7 +348,7 @@ export function useTravelReimbursementFlow({
function failCurrentFlowStep(error) {
clearFlowSimulationTimers()
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
const currentStep = rawRunningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
failFlowStep(
currentStep?.key || 'orchestrator-error',
error?.message || '智能体调用失败',
@@ -695,9 +716,11 @@ export function useTravelReimbursementFlow({
return {
flowRunId,
flowSessionType,
flowStartedAt,
flowFinishedAt,
flowSteps,
activeFlowSteps,
visibleFlowSteps,
flowRefreshBusy,
flowTick,

View File

@@ -14,6 +14,7 @@ import {
ASSISTANT_SESSION_TYPES,
filterAssistantSessionTypes,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_BUDGET,
SESSION_TYPE_EXPENSE,
buildInitialInsightFromConversation,
buildWelcomeInsight,
@@ -64,6 +65,9 @@ export function useTravelReimbursementSessionState({
}
function resolveDefaultSessionTypeFromEntry() {
if (props.entrySource === 'budget') {
return SESSION_TYPE_BUDGET
}
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
}
@@ -197,6 +201,7 @@ export function useTravelReimbursementSessionState({
: buildEmptySessionState(initialSessionType)
const canRestorePersistedInitialState =
shouldPersistLocalSnapshot
&& props.entrySource !== 'budget'
&& !String(props.initialPrompt || '').trim()
&& !props.initialFiles.length
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)

View File

@@ -16,6 +16,10 @@ import {
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
@@ -444,6 +448,32 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, { sessionType: activeSessionType.value }) && !reviewAction) {
return handleBudgetCompileReportSubmit({
adjustComposerTextareaHeight,
clearAttachedFiles,
completeFlowStep,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
createMessage,
currentUser,
fileInputRef,
fileNames,
messages,
nextTick,
options,
persistSessionState,
rawText,
replaceMessage,
resetFlowRun,
scrollToBottom,
startFlowStep,
submitting,
userText
})
}
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
attachmentCount: files.length,
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
@@ -535,9 +565,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
applicationPreview
}
))
if (insightPanelCollapsed) {
insightPanelCollapsed.value = true
}
persistSessionState()
nextTick(scrollToBottom)
} finally {