feat: 新增预算助手报告组件并优化报销交互细节
新增预算助手报告视图模型和组件,优化报销洞察面板和消息项 样式细节,完善预算中心页面布局和文档中心视图,增强报销创 建会话管理和提交编排器,调整 Vite 构建配置,补充单元测试。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
452
web/src/components/travel/BudgetAssistantReport.vue
Normal file
452
web/src/components/travel/BudgetAssistantReport.vue
Normal 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>
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) 进入审批环节。'
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
277
web/src/views/scripts/budgetAssistantReportModel.js
Normal file
277
web/src/views/scripts/budgetAssistantReportModel.js
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user