feat: 新增预算助手报告组件并优化报销交互细节
新增预算助手报告视图模型和组件,优化报销洞察面板和消息项 样式细节,完善预算中心页面布局和文档中心视图,增强报销创 建会话管理和提交编排器,调整 Vite 构建配置,补充单元测试。
This commit is contained in:
@@ -43,6 +43,21 @@
|
|||||||
pointer-events: none;
|
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 {
|
.insight-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -401,11 +416,6 @@
|
|||||||
color: #cbd5e1;
|
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 {
|
.flow-step-item.running .flow-step-rail span {
|
||||||
border-color: #2563eb;
|
border-color: #2563eb;
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
@@ -429,32 +439,6 @@
|
|||||||
background: #fef2f2;
|
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,
|
.flow-empty-state,
|
||||||
.review-side-empty,
|
.review-side-empty,
|
||||||
.review-document-preview-placeholder {
|
.review-document-preview-placeholder {
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
max-width: min(100%, 980px);
|
max-width: min(100%, 980px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-bubble-budget-report {
|
||||||
|
max-width: min(100%, 1080px);
|
||||||
|
}
|
||||||
|
|
||||||
.message-bubble-review-risk-low,
|
.message-bubble-review-risk-low,
|
||||||
.message-bubble-review-risk-medium,
|
.message-bubble-review-risk-medium,
|
||||||
.message-bubble-review-risk-high {
|
.message-bubble-review-risk-high {
|
||||||
@@ -487,6 +491,38 @@
|
|||||||
opacity: 0.42;
|
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 {
|
.application-preview-footer-missing {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -265,6 +265,46 @@
|
|||||||
font-weight: 800;
|
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 {
|
.department-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 12px 14px 8px;
|
margin: 12px 14px 8px;
|
||||||
@@ -320,7 +360,7 @@
|
|||||||
|
|
||||||
.budget-table-panel table {
|
.budget-table-panel table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1040px;
|
min-width: 1460px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,29 +423,36 @@
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-warning-red {
|
.budget-threshold-cell {
|
||||||
color: var(--danger) !important;
|
padding-left: 12px !important;
|
||||||
font-weight: 800;
|
padding-right: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-warning-yellow {
|
.budget-threshold-badge {
|
||||||
color: var(--warning-active) !important;
|
min-width: 58px;
|
||||||
font-weight: 800;
|
display: inline-flex;
|
||||||
}
|
|
||||||
|
|
||||||
.budget-row-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.budget-threshold-badge.reminder {
|
||||||
border: 0;
|
background: rgba(37, 99, 235, .1);
|
||||||
background: transparent;
|
color: #2563eb;
|
||||||
color: var(--theme-primary-active);
|
}
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
.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 {
|
.budget-table-foot {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.employee-detail {
|
.employee-detail {
|
||||||
|
--employee-detail-radius: 4px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) auto;
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -699,6 +700,7 @@ tbody tr:last-child td {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
|
border-radius: var(--employee-detail-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-profile {
|
.hero-profile {
|
||||||
@@ -712,7 +714,7 @@ tbody tr:last-child td {
|
|||||||
height: 64px;
|
height: 64px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 18px;
|
border-radius: var(--employee-detail-radius);
|
||||||
background: var(--theme-gradient-primary);
|
background: var(--theme-gradient-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -724,7 +726,7 @@ tbody tr:last-child td {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 999px;
|
border-radius: var(--employee-detail-radius);
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -753,7 +755,7 @@ tbody tr:last-child td {
|
|||||||
|
|
||||||
.hero-stat {
|
.hero-stat {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 12px;
|
border-radius: var(--employee-detail-radius);
|
||||||
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
||||||
border: 1px solid #edf2f7;
|
border: 1px solid #edf2f7;
|
||||||
}
|
}
|
||||||
@@ -791,6 +793,7 @@ tbody tr:last-child td {
|
|||||||
.detail-card,
|
.detail-card,
|
||||||
.side-card {
|
.side-card {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
border-radius: var(--employee-detail-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-head {
|
.card-head {
|
||||||
@@ -819,7 +822,7 @@ tbody tr:last-child td {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 999px;
|
border-radius: var(--employee-detail-radius);
|
||||||
background: #ecfeff;
|
background: #ecfeff;
|
||||||
color: #0891b2;
|
color: #0891b2;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -847,7 +850,7 @@ tbody tr:last-child td {
|
|||||||
.field input {
|
.field input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #d7e0ea;
|
border: 1px solid #d7e0ea;
|
||||||
border-radius: 10px;
|
border-radius: var(--employee-detail-radius);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -985,7 +988,7 @@ tbody tr:last-child td {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid #edf2f7;
|
border: 1px solid #edf2f7;
|
||||||
border-radius: 14px;
|
border-radius: var(--employee-detail-radius);
|
||||||
background: #fbfdff;
|
background: #fbfdff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,6 +1021,19 @@ tbody tr:last-child td {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.bullet-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1108,7 +1124,7 @@ td.cell-updated {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 10px;
|
border-radius: var(--employee-detail-radius);
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,7 +1158,7 @@ td.cell-updated {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border-radius: 8px;
|
border-radius: var(--employee-detail-radius);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 760;
|
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
|
<TransitionGroup
|
||||||
v-if="(ui.visibleFlowSteps || ui.flowSteps).length"
|
v-if="(ui.visibleFlowSteps || ui.flowSteps).length"
|
||||||
name="flow-step-reveal"
|
name="el-fade-in-linear"
|
||||||
tag="div"
|
tag="div"
|
||||||
class="review-flow-list"
|
class="review-flow-list"
|
||||||
>
|
>
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
|
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
v-if="(ui.visibleFlowSteps || ui.flowSteps).length"
|
v-if="(ui.visibleFlowSteps || ui.flowSteps).length"
|
||||||
name="flow-step-reveal"
|
name="el-fade-in-linear"
|
||||||
tag="div"
|
tag="div"
|
||||||
class="review-flow-list"
|
class="review-flow-list"
|
||||||
>
|
>
|
||||||
@@ -630,7 +630,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
resolveFlowStepStyle(index) {
|
resolveFlowStepStyle(index) {
|
||||||
return {
|
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)"
|
@click="ui.handleAssistantMarkdownClick($event, message)"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
<BudgetAssistantReport
|
||||||
|
v-if="message.role === 'assistant' && message.budgetReport"
|
||||||
|
:report="message.budgetReport"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="message.role === 'assistant' && message.applicationPreview"
|
v-if="message.role === 'assistant' && message.applicationPreview"
|
||||||
class="application-preview-table"
|
class="application-preview-table"
|
||||||
@@ -427,11 +432,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import BudgetAssistantReport from './BudgetAssistantReport.vue'
|
||||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TravelReimbursementMessageItem',
|
name: 'TravelReimbursementMessageItem',
|
||||||
components: {
|
components: {
|
||||||
|
BudgetAssistantReport,
|
||||||
EnterpriseSelect
|
EnterpriseSelect
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -264,7 +264,14 @@ export function useAppShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSmartEntry(payload = {}) {
|
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
|
smartEntryRevealToken.value += 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,16 +513,9 @@ export function buildLocalApplicationPreviewMessage(preview) {
|
|||||||
export function buildApplicationPreviewFooterMessage(preview) {
|
export function buildApplicationPreviewFooterMessage(preview) {
|
||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||||
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
|
|
||||||
if (missingFields.length) {
|
if (missingFields.length) {
|
||||||
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
|
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"
|
@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" />
|
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||||
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
|
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
|
||||||
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="budget-action-set">
|
<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>
|
<i class="mdi mdi-pencil-outline"></i>
|
||||||
<span>编辑预算</span>
|
<span>编辑预算</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -88,23 +88,39 @@
|
|||||||
<article class="budget-table-panel">
|
<article class="budget-table-panel">
|
||||||
<header>
|
<header>
|
||||||
<strong>当前部门:{{ activeDepartmentName }}</strong>
|
<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>
|
</header>
|
||||||
<div class="budget-table-wrap">
|
<div class="budget-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
<th>使用率</th>
|
||||||
<th>预警线</th>
|
<th>提醒阈值</th>
|
||||||
<th>控制动作</th>
|
<th>告警阈值</th>
|
||||||
|
<th>风险阈值</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in visibleBudgetRows" :key="row.expenseType">
|
<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.expenseType }}</td>
|
||||||
<td>{{ row.total }}</td>
|
<td>{{ row.total }}</td>
|
||||||
<td>{{ row.used }}</td>
|
<td>{{ row.used }}</td>
|
||||||
@@ -116,8 +132,15 @@
|
|||||||
<div><em :class="row.rateTone" :style="{ width: `${Math.min(row.rate, 100)}%` }"></em></div>
|
<div><em :class="row.rateTone" :style="{ width: `${Math.min(row.rate, 100)}%` }"></em></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td :class="row.warningTone">{{ row.warningLine }}</td>
|
<td class="budget-threshold-cell">
|
||||||
<td>{{ row.action }}</td>
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -206,145 +229,9 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./scripts/BudgetCenterView.js"></script>
|
<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-view.css"></style>
|
||||||
<style scoped src="../assets/styles/views/budget-center-dialog.css"></style>
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="date-range-filter" :class="{ open: datePopover }">
|
<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>
|
<span class="date-range-label">{{ dateRangeLabel }}</span>
|
||||||
<i class="mdi mdi-calendar"></i>
|
<i class="mdi mdi-calendar"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -646,6 +646,16 @@ function hasActiveFilters() {
|
|||||||
|
|
||||||
function toggleFilter(key) {
|
function toggleFilter(key) {
|
||||||
openFilterKey.value = openFilterKey.value === key ? '' : 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) {
|
function selectDocumentType(value) {
|
||||||
|
|||||||
@@ -379,10 +379,12 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Transition name="el-fade-in-linear" appear>
|
||||||
<TravelReimbursementInsightPanel
|
<TravelReimbursementInsightPanel
|
||||||
v-if="hasInsightPanelContent"
|
v-if="hasInsightPanelContent"
|
||||||
:ui="insightPanelUi"
|
:ui="insightPanelUi"
|
||||||
/>
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
|
||||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.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 { fetchEmployeeMeta } from '../../services/employees.js'
|
||||||
import {
|
import {
|
||||||
canEditBudgetCenter,
|
canEditBudgetCenter,
|
||||||
@@ -12,14 +11,9 @@ import {
|
|||||||
isExecutiveUser
|
isExecutiveUser
|
||||||
} from '../../utils/accessControl.js'
|
} from '../../utils/accessControl.js'
|
||||||
import {
|
import {
|
||||||
BUDGET_CONTROL_ACTION_OPTIONS,
|
|
||||||
BUDGET_QUARTER_OPTIONS,
|
BUDGET_QUARTER_OPTIONS,
|
||||||
BUDGET_STATUS_OPTIONS,
|
|
||||||
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||||
BUDGET_WARNING_OPTIONS,
|
|
||||||
BUDGET_YEAR_OPTIONS,
|
BUDGET_YEAR_OPTIONS,
|
||||||
buildBudgetOntologyContext,
|
|
||||||
formatBudgetPeriod,
|
|
||||||
resolveBudgetExpenseTypeLabel
|
resolveBudgetExpenseTypeLabel
|
||||||
} from '../../utils/budgetOntology.js'
|
} from '../../utils/budgetOntology.js'
|
||||||
|
|
||||||
@@ -66,13 +60,19 @@ const comparison = (value, direction) => ({
|
|||||||
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
|
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 BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
|
||||||
const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '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 normalizePeriodKey = (year, quarter) => {
|
||||||
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
|
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
|
||||||
@@ -87,19 +87,49 @@ const parsePercent = (value, fallback = 80) => {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveControlActionCode = (value) => {
|
const clampPercent = (value) => Math.min(100, Math.max(0, Number(value) || 0))
|
||||||
if (value === BUDGET_CONTROL_ACTION_OPTIONS[0]) return 'allow'
|
|
||||||
if (value === BUDGET_CONTROL_ACTION_OPTIONS[1]) return 'warn'
|
function buildThresholds(warning) {
|
||||||
if (value === BUDGET_CONTROL_ACTION_OPTIONS[2]) return 'block'
|
const alert = clampPercent(warning)
|
||||||
return String(value || '').trim() || 'block'
|
return {
|
||||||
|
reminder: clampPercent(alert - 10),
|
||||||
|
alert,
|
||||||
|
risk: clampPercent(alert + 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveControlActionLabel = (value) => {
|
function formatBudgetCompiledAt(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase()
|
if (!value) return '—'
|
||||||
if (normalized === 'allow') return BUDGET_CONTROL_ACTION_OPTIONS[0]
|
const date = new Date(value)
|
||||||
if (normalized === 'warn') return BUDGET_CONTROL_ACTION_OPTIONS[1]
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
if (normalized === 'block') return BUDGET_CONTROL_ACTION_OPTIONS[2]
|
return BUDGET_COMPILED_TIME_FORMATTER.format(date).replace(/\//g, '-')
|
||||||
return value || BUDGET_CONTROL_ACTION_OPTIONS[2]
|
}
|
||||||
|
|
||||||
|
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) {
|
function normalizeBudgetAllocationRow(item) {
|
||||||
@@ -110,6 +140,7 @@ function normalizeBudgetAllocationRow(item) {
|
|||||||
const leftAmount = Number(balance.available_amount ?? 0)
|
const leftAmount = Number(balance.available_amount ?? 0)
|
||||||
const rate = Number(balance.usage_rate ?? 0)
|
const rate = Number(balance.usage_rate ?? 0)
|
||||||
const warning = parsePercent(item?.warning_threshold, 80)
|
const warning = parsePercent(item?.warning_threshold, 80)
|
||||||
|
const thresholds = buildThresholds(warning)
|
||||||
const budgetSubjectCode = String(item?.subject_code || '').trim()
|
const budgetSubjectCode = String(item?.subject_code || '').trim()
|
||||||
const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode)
|
const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode)
|
||||||
|
|
||||||
@@ -117,17 +148,22 @@ function normalizeBudgetAllocationRow(item) {
|
|||||||
allocationId: item?.id || '',
|
allocationId: item?.id || '',
|
||||||
budgetNo: item?.budget_no || '',
|
budgetNo: item?.budget_no || '',
|
||||||
budgetSubjectCode,
|
budgetSubjectCode,
|
||||||
|
compiledAt: formatBudgetCompiledAt(item?.created_at || item?.createdAt || item?.updated_at || item?.updatedAt),
|
||||||
|
compiler: resolveBudgetCompiler(item),
|
||||||
|
reviewer: resolveBudgetReviewer(item),
|
||||||
expenseType,
|
expenseType,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
usedAmount,
|
usedAmount,
|
||||||
occupiedAmount,
|
occupiedAmount,
|
||||||
leftAmount,
|
leftAmount,
|
||||||
rate,
|
rate,
|
||||||
rateTone: rate >= warning ? 'danger' : rate >= warning - 12 ? 'warn' : 'ok',
|
rateTone: rate >= thresholds.risk ? 'danger' : rate >= thresholds.alert ? 'warn' : 'ok',
|
||||||
warning,
|
reminderThreshold: thresholds.reminder,
|
||||||
warningTone: warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
|
alertThreshold: thresholds.alert,
|
||||||
warningLine: `${warning}%`,
|
riskThreshold: thresholds.risk,
|
||||||
action: resolveControlActionLabel(item?.control_action),
|
reminderLine: `${thresholds.reminder}%`,
|
||||||
|
alertLine: `${thresholds.alert}%`,
|
||||||
|
riskLine: `${thresholds.risk}%`,
|
||||||
total: currency(totalAmount),
|
total: currency(totalAmount),
|
||||||
used: currency(usedAmount),
|
used: currency(usedAmount),
|
||||||
occupied: currency(occupiedAmount),
|
occupied: currency(occupiedAmount),
|
||||||
@@ -176,12 +212,12 @@ export default {
|
|||||||
default: () => ({})
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
emits: ['openAssistant'],
|
||||||
components: {
|
components: {
|
||||||
BudgetTrendChart,
|
BudgetTrendChart,
|
||||||
ConfirmDialog,
|
|
||||||
EnterpriseSelect
|
EnterpriseSelect
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props, { emit }) {
|
||||||
const departments = ref(FALLBACK_DEPARTMENTS)
|
const departments = ref(FALLBACK_DEPARTMENTS)
|
||||||
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
||||||
const departmentKeyword = ref('')
|
const departmentKeyword = ref('')
|
||||||
@@ -193,25 +229,11 @@ export default {
|
|||||||
})
|
})
|
||||||
const budgetPage = ref(1)
|
const budgetPage = ref(1)
|
||||||
const budgetPageSize = ref(5)
|
const budgetPageSize = ref(5)
|
||||||
|
const budgetTableKeyword = ref('')
|
||||||
const budgetRows = ref([])
|
const budgetRows = ref([])
|
||||||
const budgetSummary = ref(null)
|
const budgetSummary = ref(null)
|
||||||
const budgetLoading = ref(false)
|
const budgetLoading = ref(false)
|
||||||
const budgetError = ref('')
|
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 canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
|
||||||
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
||||||
const isDepartmentBudgetMonitor = computed(
|
const isDepartmentBudgetMonitor = computed(
|
||||||
@@ -238,8 +260,26 @@ export default {
|
|||||||
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
|
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
|
||||||
)
|
)
|
||||||
const departmentRows = computed(() => budgetRows.value)
|
const departmentRows = computed(() => budgetRows.value)
|
||||||
const filteredBudgetRows = computed(() =>
|
const filteredBudgetRows = computed(() => {
|
||||||
departmentRows.value
|
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) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
|
||||||
.filter((row) => {
|
.filter((row) => {
|
||||||
if (filters.value.status === '全部') return true
|
if (filters.value.status === '全部') return true
|
||||||
@@ -247,7 +287,7 @@ export default {
|
|||||||
if (filters.value.status === '管控') return row.rateTone === 'danger'
|
if (filters.value.status === '管控') return row.rateTone === 'danger'
|
||||||
return row.rateTone === 'ok'
|
return row.rateTone === 'ok'
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
|
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
|
||||||
const totalBudgetPages = computed(() =>
|
const totalBudgetPages = computed(() =>
|
||||||
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
|
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
|
||||||
@@ -330,120 +370,18 @@ export default {
|
|||||||
const budgetUsageData = computed(() =>
|
const budgetUsageData = computed(() =>
|
||||||
normalizeBudgetUsageData(departmentRows.value)
|
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() {
|
function openBudgetAssistant() {
|
||||||
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() {
|
|
||||||
if (!canEditBudget.value) return
|
if (!canEditBudget.value) return
|
||||||
const department = activeDepartment.value
|
emit('openAssistant', {
|
||||||
const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter)
|
source: 'budget',
|
||||||
budgetEditForm.value = {
|
sessionType: 'budget',
|
||||||
budgetYear: filters.value.year,
|
prompt: '',
|
||||||
budgetQuarter: filters.value.quarter,
|
files: [],
|
||||||
budgetPeriod,
|
conversation: null
|
||||||
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: ''
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function goToBudgetPage(page) {
|
||||||
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
||||||
@@ -453,44 +391,6 @@ export default {
|
|||||||
goToBudgetPage(currentBudgetPage.value + direction)
|
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) {
|
function resolveScopedDepartments(options) {
|
||||||
if (!isDepartmentBudgetMonitor.value) {
|
if (!isDepartmentBudgetMonitor.value) {
|
||||||
return options
|
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(() => {
|
onMounted(() => {
|
||||||
void loadDepartments()
|
void loadDepartments()
|
||||||
})
|
})
|
||||||
@@ -586,7 +479,8 @@ export default {
|
|||||||
() => filters.value.year,
|
() => filters.value.year,
|
||||||
() => filters.value.quarter,
|
() => filters.value.quarter,
|
||||||
() => filters.value.expenseType,
|
() => filters.value.expenseType,
|
||||||
() => filters.value.status
|
() => filters.value.status,
|
||||||
|
budgetTableKeyword
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
budgetPage.value = 1
|
budgetPage.value = 1
|
||||||
@@ -609,52 +503,31 @@ export default {
|
|||||||
return {
|
return {
|
||||||
activeDepartmentCode,
|
activeDepartmentCode,
|
||||||
activeDepartmentName,
|
activeDepartmentName,
|
||||||
addBudgetDetailRow,
|
|
||||||
budgetEditForm,
|
|
||||||
budgetEditOpen,
|
|
||||||
budgetEditRows,
|
|
||||||
budgetEditTotal,
|
|
||||||
budgetError,
|
budgetError,
|
||||||
budgetLoading,
|
budgetLoading,
|
||||||
budgetMetrics,
|
budgetMetrics,
|
||||||
budgetOntologyContext,
|
|
||||||
budgetSaving,
|
|
||||||
budgetPage: currentBudgetPage,
|
budgetPage: currentBudgetPage,
|
||||||
budgetPageNumbers,
|
budgetPageNumbers,
|
||||||
budgetPageSize,
|
budgetPageSize,
|
||||||
budgetPageSizeOptions,
|
budgetPageSizeOptions,
|
||||||
|
budgetTableKeyword,
|
||||||
canEditBudget,
|
canEditBudget,
|
||||||
canSwitchDepartments,
|
canSwitchDepartments,
|
||||||
closeBudgetEditDialog,
|
|
||||||
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
|
|
||||||
changeBudgetPage,
|
changeBudgetPage,
|
||||||
confirmSaveBudget,
|
|
||||||
confirmSaveOpen,
|
|
||||||
departmentKeyword,
|
departmentKeyword,
|
||||||
departments,
|
departments,
|
||||||
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
|
||||||
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
||||||
filters,
|
filters,
|
||||||
openBudgetEditDialog,
|
openBudgetAssistant,
|
||||||
quarters: BUDGET_QUARTER_OPTIONS,
|
quarters: BUDGET_QUARTER_OPTIONS,
|
||||||
addBudgetDetailRow,
|
|
||||||
removeBudgetDetailRow,
|
|
||||||
confirmDeleteOpen,
|
|
||||||
confirmDeleteRow,
|
|
||||||
cancelDeleteRow,
|
|
||||||
cancelSaveBudget,
|
|
||||||
departmentOptions,
|
departmentOptions,
|
||||||
requestSaveBudget,
|
|
||||||
statusOptions: BUDGET_STATUS_OPTIONS,
|
|
||||||
statuses: ['全部', '正常', '预警', '管控'],
|
statuses: ['全部', '正常', '预警', '管控'],
|
||||||
syncBudgetRowSubject,
|
|
||||||
goToBudgetPage,
|
goToBudgetPage,
|
||||||
totalBudgetPages,
|
totalBudgetPages,
|
||||||
totalBudgetRows,
|
totalBudgetRows,
|
||||||
budgetUsageData,
|
budgetUsageData,
|
||||||
visibleBudgetRows,
|
visibleBudgetRows,
|
||||||
visibleDepartments,
|
visibleDepartments,
|
||||||
warningOptions: BUDGET_WARNING_OPTIONS,
|
|
||||||
warnings,
|
warnings,
|
||||||
yearOptions,
|
yearOptions,
|
||||||
years: BUDGET_YEAR_OPTIONS
|
years: BUDGET_YEAR_OPTIONS
|
||||||
|
|||||||
@@ -618,6 +618,7 @@ export default {
|
|||||||
const {
|
const {
|
||||||
flowRunId,
|
flowRunId,
|
||||||
flowSteps,
|
flowSteps,
|
||||||
|
activeFlowSteps,
|
||||||
visibleFlowSteps,
|
visibleFlowSteps,
|
||||||
flowRefreshBusy,
|
flowRefreshBusy,
|
||||||
completedFlowStepCount,
|
completedFlowStepCount,
|
||||||
@@ -677,7 +678,7 @@ export default {
|
|||||||
})
|
})
|
||||||
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
|
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
|
||||||
const hasInsightPanelContent = computed(() => {
|
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 showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
|
||||||
const insightPanelToggleLabel = computed(() =>
|
const insightPanelToggleLabel = computed(() =>
|
||||||
@@ -696,6 +697,9 @@ export default {
|
|||||||
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
|
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
|
||||||
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
|
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
|
||||||
}
|
}
|
||||||
|
if (activeSessionType.value === SESSION_TYPE_BUDGET) {
|
||||||
|
return '例如:查询市场部 Q1 预算编制情况,重点看差旅、通信、招待费和办公用品。'
|
||||||
|
}
|
||||||
return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件整理报销核对信息。'
|
return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件整理报销核对信息。'
|
||||||
})
|
})
|
||||||
const currentIntentLabel = computed(() => {
|
const currentIntentLabel = computed(() => {
|
||||||
@@ -807,7 +811,7 @@ export default {
|
|||||||
activeReviewPayload,
|
activeReviewPayload,
|
||||||
activeReviewPanelScope,
|
activeReviewPanelScope,
|
||||||
reviewFilePreviews,
|
reviewFilePreviews,
|
||||||
flowSteps,
|
flowSteps: activeFlowSteps,
|
||||||
submitting,
|
submitting,
|
||||||
reviewActionBusy,
|
reviewActionBusy,
|
||||||
triggerFileUpload: (...args) => triggerFileUpload(...args),
|
triggerFileUpload: (...args) => triggerFileUpload(...args),
|
||||||
@@ -1087,15 +1091,19 @@ export default {
|
|||||||
})
|
})
|
||||||
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
||||||
|
|
||||||
const shortcuts = computed(() =>
|
const shortcuts = computed(() => {
|
||||||
filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value).map((mode) => ({
|
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,
|
label: mode.label,
|
||||||
icon: mode.icon,
|
icon: mode.icon,
|
||||||
action: 'switch_view',
|
action: 'switch_view',
|
||||||
targetSessionType: mode.key,
|
targetSessionType: mode.key,
|
||||||
active: mode.key === activeSessionType.value
|
active: mode.key === activeSessionType.value
|
||||||
}))
|
}))
|
||||||
)
|
})
|
||||||
watch(
|
watch(
|
||||||
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
||||||
([payload]) => {
|
([payload]) => {
|
||||||
@@ -1103,7 +1111,7 @@ export default {
|
|||||||
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
|
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
|
||||||
// ? REVIEW_DRAWER_MODE_RISK
|
// ? REVIEW_DRAWER_MODE_RISK
|
||||||
// : REVIEW_DRAWER_MODE_REVIEW
|
// : 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)
|
resetReviewDrawerFromPayload(payload)
|
||||||
if (shouldKeepFlowDrawer) {
|
if (shouldKeepFlowDrawer) {
|
||||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
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(
|
watch(
|
||||||
() => composerDraft.value,
|
() => composerDraft.value,
|
||||||
() => {
|
() => {
|
||||||
@@ -1664,6 +1683,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMessageBubbleClass(message) {
|
function buildMessageBubbleClass(message) {
|
||||||
|
if (message?.role === 'assistant' && message?.budgetReport) {
|
||||||
|
return 'message-bubble-budget-report'
|
||||||
|
}
|
||||||
if (message?.role === 'assistant' && message?.applicationPreview) {
|
if (message?.role === 'assistant' && message?.applicationPreview) {
|
||||||
return 'message-bubble-application-preview'
|
return 'message-bubble-application-preview'
|
||||||
}
|
}
|
||||||
@@ -2217,7 +2239,7 @@ export default {
|
|||||||
flowRunId: flowRunId.value,
|
flowRunId: flowRunId.value,
|
||||||
flowRefreshBusy: flowRefreshBusy.value,
|
flowRefreshBusy: flowRefreshBusy.value,
|
||||||
refreshFlowRunDetail,
|
refreshFlowRunDetail,
|
||||||
flowSteps: flowSteps.value,
|
flowSteps: activeFlowSteps.value,
|
||||||
visibleFlowSteps: visibleFlowSteps.value,
|
visibleFlowSteps: visibleFlowSteps.value,
|
||||||
resolveFlowStepStatusLabel,
|
resolveFlowStepStatusLabel,
|
||||||
formatFlowStepDuration,
|
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 { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||||
import { isBudgetMonitorUser, isExecutiveUser } from '../../utils/accessControl.js'
|
import { isBudgetMonitorUser, isExecutiveUser, isPlatformAdminUser } from '../../utils/accessControl.js'
|
||||||
import {
|
import {
|
||||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||||
GUIDED_ACTION_START_APPLICATION,
|
GUIDED_ACTION_START_APPLICATION,
|
||||||
@@ -58,7 +58,7 @@ export const ASSISTANT_SESSION_MODE_OPTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function canUseBudgetAssistantSession(user = null) {
|
export function canUseBudgetAssistantSession(user = null) {
|
||||||
return Boolean(isBudgetMonitorUser(user) || isExecutiveUser(user))
|
return Boolean(isPlatformAdminUser(user) || isBudgetMonitorUser(user) || isExecutiveUser(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseAssistantSessionType(sessionType, user = null) {
|
function canUseAssistantSessionType(sessionType, user = null) {
|
||||||
@@ -102,6 +102,7 @@ export const SOURCE_LABELS = {
|
|||||||
workbench: '来自个人工作台',
|
workbench: '来自个人工作台',
|
||||||
topbar: '来自发起报销',
|
topbar: '来自发起报销',
|
||||||
application: '来自发起申请',
|
application: '来自发起申请',
|
||||||
|
budget: '来自预算中心',
|
||||||
detail: '来自智能录入',
|
detail: '来自智能录入',
|
||||||
upload: '来自附件上传',
|
upload: '来自附件上传',
|
||||||
requests: '来自报销列表'
|
requests: '来自报销列表'
|
||||||
@@ -111,6 +112,7 @@ export const SCENARIO_LABELS = {
|
|||||||
expense: '报销',
|
expense: '报销',
|
||||||
accounts_receivable: '应收',
|
accounts_receivable: '应收',
|
||||||
accounts_payable: '应付',
|
accounts_payable: '应付',
|
||||||
|
budget: '预算',
|
||||||
knowledge: '知识',
|
knowledge: '知识',
|
||||||
unknown: '通用'
|
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 = [
|
export const HOT_KNOWLEDGE_QUESTIONS = [
|
||||||
'差旅住宿标准按什么规则执行?',
|
'差旅住宿标准按什么规则执行?',
|
||||||
'酒店超标后如何申请例外报销?',
|
'酒店超标后如何申请例外报销?',
|
||||||
@@ -287,6 +307,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
|||||||
riskFlags: [],
|
riskFlags: [],
|
||||||
pendingAttachmentAssociation: null,
|
pendingAttachmentAssociation: null,
|
||||||
applicationPreview: null,
|
applicationPreview: null,
|
||||||
|
budgetReport: null,
|
||||||
...extras
|
...extras
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,6 +578,10 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
|
|||||||
return APPROVAL_WELCOME_QUICK_ACTIONS
|
return APPROVAL_WELCOME_QUICK_ACTIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
|
||||||
|
return BUDGET_WELCOME_QUICK_ACTIONS
|
||||||
|
}
|
||||||
|
|
||||||
return EXPENSE_WELCOME_QUICK_ACTIONS
|
return EXPENSE_WELCOME_QUICK_ACTIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,6 +626,18 @@ export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SE
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
|
||||||
|
return [
|
||||||
|
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||||
|
'',
|
||||||
|
'**欢迎来到个人财务中心 · 预算编制助手。** 我可以帮您查询预算编制情况、整理费用类型预算、检查提醒/告警/风险阈值,并保持预算对话独立记录。',
|
||||||
|
'',
|
||||||
|
'业务范围:预算编制查询、部门预算检查、费用类型额度梳理、预算占用说明和阈值风险分析。报销发起、审核动作和制度问答请切换到对应助手。',
|
||||||
|
'',
|
||||||
|
'您可以直接输入预算问题,或点击下方快捷操作快速开始。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||||
return [
|
return [
|
||||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
`${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 {
|
return {
|
||||||
intent: 'welcome',
|
intent: 'welcome',
|
||||||
metricLabel: '当前助手',
|
metricLabel: '当前助手',
|
||||||
@@ -837,6 +885,7 @@ export function serializeSessionMessages(messages) {
|
|||||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||||
applicationPreview: message.applicationPreview || null,
|
applicationPreview: message.applicationPreview || null,
|
||||||
|
budgetReport: message.budgetReport || null,
|
||||||
assistantName: message.assistantName || '',
|
assistantName: message.assistantName || '',
|
||||||
isWelcome: Boolean(message.isWelcome),
|
isWelcome: Boolean(message.isWelcome),
|
||||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||||
@@ -857,6 +906,7 @@ export function hasMeaningfulSessionMessages(messages) {
|
|||||||
|| message.reviewPayload
|
|| message.reviewPayload
|
||||||
|| message.queryPayload
|
|| message.queryPayload
|
||||||
|| message.draftPayload
|
|| message.draftPayload
|
||||||
|
|| message.budgetReport
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function useTravelReimbursementFlow({
|
|||||||
FLOW_STEP_STATUS_FAILED
|
FLOW_STEP_STATUS_FAILED
|
||||||
}) {
|
}) {
|
||||||
const flowRunId = ref('')
|
const flowRunId = ref('')
|
||||||
|
const flowSessionType = ref('')
|
||||||
const flowStartedAt = ref(0)
|
const flowStartedAt = ref(0)
|
||||||
const flowFinishedAt = ref(0)
|
const flowFinishedAt = ref(0)
|
||||||
const flowSteps = ref([])
|
const flowSteps = ref([])
|
||||||
@@ -94,15 +95,23 @@ export function useTravelReimbursementFlow({
|
|||||||
let flowTickTimer = 0
|
let flowTickTimer = 0
|
||||||
const flowSimulationTimers = []
|
const flowSimulationTimers = []
|
||||||
|
|
||||||
|
const activeFlowSteps = computed(() => (
|
||||||
|
flowSessionType.value === resolveCurrentFlowSessionType()
|
||||||
|
? flowSteps.value
|
||||||
|
: []
|
||||||
|
))
|
||||||
const completedFlowStepCount = computed(
|
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(
|
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 visibleFlowSteps = computed(() => {
|
||||||
const visibleSteps = []
|
const visibleSteps = []
|
||||||
for (const step of flowSteps.value) {
|
for (const step of activeFlowSteps.value) {
|
||||||
visibleSteps.push(step)
|
visibleSteps.push(step)
|
||||||
if (step.status !== FLOW_STEP_STATUS_COMPLETED) {
|
if (step.status !== FLOW_STEP_STATUS_COMPLETED) {
|
||||||
break
|
break
|
||||||
@@ -111,19 +120,23 @@ export function useTravelReimbursementFlow({
|
|||||||
return visibleSteps
|
return visibleSteps
|
||||||
})
|
})
|
||||||
const flowOverallStatusTone = computed(() => {
|
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'
|
return 'failed'
|
||||||
}
|
}
|
||||||
if (runningFlowStep.value) {
|
if (runningFlowStep.value) {
|
||||||
return 'running'
|
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 'completed'
|
||||||
}
|
}
|
||||||
return 'pending'
|
return 'pending'
|
||||||
})
|
})
|
||||||
const flowOverallStatusText = computed(() => {
|
const flowOverallStatusText = computed(() => {
|
||||||
const total = flowSteps.value.length
|
const total = activeFlowSteps.value.length
|
||||||
const completed = completedFlowStepCount.value
|
const completed = completedFlowStepCount.value
|
||||||
if (flowOverallStatusTone.value === 'failed') {
|
if (flowOverallStatusTone.value === 'failed') {
|
||||||
return `异常 ${completed}/${total}`
|
return `异常 ${completed}/${total}`
|
||||||
@@ -146,13 +159,17 @@ export function useTravelReimbursementFlow({
|
|||||||
return formatFlowDuration(finishedAt - flowStartedAt.value)
|
return formatFlowDuration(finishedAt - flowStartedAt.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const measuredDuration = flowSteps.value.reduce((total, step) => {
|
const measuredDuration = activeFlowSteps.value.reduce((total, step) => {
|
||||||
const duration = Number(step.durationMs)
|
const duration = Number(step.durationMs)
|
||||||
return total + (Number.isFinite(duration) && duration > 0 ? duration : 0)
|
return total + (Number.isFinite(duration) && duration > 0 ? duration : 0)
|
||||||
}, 0)
|
}, 0)
|
||||||
return measuredDuration ? formatFlowDuration(measuredDuration) : '--'
|
return measuredDuration ? formatFlowDuration(measuredDuration) : '--'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function resolveCurrentFlowSessionType() {
|
||||||
|
return String(activeSessionType?.value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
function startFlowTick() {
|
function startFlowTick() {
|
||||||
if (flowTickTimer) {
|
if (flowTickTimer) {
|
||||||
return
|
return
|
||||||
@@ -183,6 +200,7 @@ export function useTravelReimbursementFlow({
|
|||||||
const shouldOpenDrawer = options.openDrawer !== false
|
const shouldOpenDrawer = options.openDrawer !== false
|
||||||
const startedAt = Number(options.startedAt)
|
const startedAt = Number(options.startedAt)
|
||||||
flowRunId.value = ''
|
flowRunId.value = ''
|
||||||
|
flowSessionType.value = String(options.sessionType || resolveCurrentFlowSessionType()).trim()
|
||||||
flowStartedAt.value = Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : Date.now()
|
flowStartedAt.value = Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : Date.now()
|
||||||
flowFinishedAt.value = 0
|
flowFinishedAt.value = 0
|
||||||
if (shouldOpenDrawer) {
|
if (shouldOpenDrawer) {
|
||||||
@@ -242,6 +260,9 @@ export function useTravelReimbursementFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startFlowStep(key, patch = {}) {
|
function startFlowStep(key, patch = {}) {
|
||||||
|
if (!flowSessionType.value) {
|
||||||
|
flowSessionType.value = resolveCurrentFlowSessionType()
|
||||||
|
}
|
||||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||||
const explicitStartedAt = Number(normalizedPatch.startedAt)
|
const explicitStartedAt = Number(normalizedPatch.startedAt)
|
||||||
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
||||||
@@ -327,7 +348,7 @@ export function useTravelReimbursementFlow({
|
|||||||
|
|
||||||
function failCurrentFlowStep(error) {
|
function failCurrentFlowStep(error) {
|
||||||
clearFlowSimulationTimers()
|
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(
|
failFlowStep(
|
||||||
currentStep?.key || 'orchestrator-error',
|
currentStep?.key || 'orchestrator-error',
|
||||||
error?.message || '智能体调用失败',
|
error?.message || '智能体调用失败',
|
||||||
@@ -695,9 +716,11 @@ export function useTravelReimbursementFlow({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
flowRunId,
|
flowRunId,
|
||||||
|
flowSessionType,
|
||||||
flowStartedAt,
|
flowStartedAt,
|
||||||
flowFinishedAt,
|
flowFinishedAt,
|
||||||
flowSteps,
|
flowSteps,
|
||||||
|
activeFlowSteps,
|
||||||
visibleFlowSteps,
|
visibleFlowSteps,
|
||||||
flowRefreshBusy,
|
flowRefreshBusy,
|
||||||
flowTick,
|
flowTick,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ASSISTANT_SESSION_TYPES,
|
ASSISTANT_SESSION_TYPES,
|
||||||
filterAssistantSessionTypes,
|
filterAssistantSessionTypes,
|
||||||
SESSION_TYPE_APPLICATION,
|
SESSION_TYPE_APPLICATION,
|
||||||
|
SESSION_TYPE_BUDGET,
|
||||||
SESSION_TYPE_EXPENSE,
|
SESSION_TYPE_EXPENSE,
|
||||||
buildInitialInsightFromConversation,
|
buildInitialInsightFromConversation,
|
||||||
buildWelcomeInsight,
|
buildWelcomeInsight,
|
||||||
@@ -64,6 +65,9 @@ export function useTravelReimbursementSessionState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultSessionTypeFromEntry() {
|
function resolveDefaultSessionTypeFromEntry() {
|
||||||
|
if (props.entrySource === 'budget') {
|
||||||
|
return SESSION_TYPE_BUDGET
|
||||||
|
}
|
||||||
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
|
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +201,7 @@ export function useTravelReimbursementSessionState({
|
|||||||
: buildEmptySessionState(initialSessionType)
|
: buildEmptySessionState(initialSessionType)
|
||||||
const canRestorePersistedInitialState =
|
const canRestorePersistedInitialState =
|
||||||
shouldPersistLocalSnapshot
|
shouldPersistLocalSnapshot
|
||||||
|
&& props.entrySource !== 'budget'
|
||||||
&& !String(props.initialPrompt || '').trim()
|
&& !String(props.initialPrompt || '').trim()
|
||||||
&& !props.initialFiles.length
|
&& !props.initialFiles.length
|
||||||
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||||
|
import {
|
||||||
|
handleBudgetCompileReportSubmit,
|
||||||
|
shouldUseBudgetCompileReport
|
||||||
|
} from './budgetAssistantReportModel.js'
|
||||||
|
|
||||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||||
const {
|
const {
|
||||||
@@ -444,6 +448,32 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||||
: `我上传了 ${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, {
|
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
|
||||||
attachmentCount: files.length,
|
attachmentCount: files.length,
|
||||||
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
|
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
|
||||||
@@ -535,9 +565,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
applicationPreview
|
applicationPreview
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
if (insightPanelCollapsed) {
|
|
||||||
insightPanelCollapsed.value = true
|
|
||||||
}
|
|
||||||
persistSessionState()
|
persistSessionState()
|
||||||
nextTick(scrollToBottom)
|
nextTick(scrollToBottom)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ test('application preview keeps rule fallback distinct from model reviewed resul
|
|||||||
|
|
||||||
assert.equal(fallbackPreview.modelReviewStatus, 'fallback')
|
assert.equal(fallbackPreview.modelReviewStatus, 'fallback')
|
||||||
assert.match(message, /规则兜底/)
|
assert.match(message, /规则兜底/)
|
||||||
assert.match(footer, /规则兜底/)
|
assert.match(footer, /请确认上述的信息是否填写正确/)
|
||||||
assert.doesNotMatch(footer, /#application-submit/)
|
assert.match(footer, /#application-submit/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('application preview with missing budget stays in chat and asks for补充信息', () => {
|
test('application preview with missing budget stays in chat and asks for补充信息', () => {
|
||||||
@@ -280,6 +280,8 @@ test('application session shows intent flow, persists preview, and supports inli
|
|||||||
assert.match(messageItemStyles, /\.application-preview-row \{[\s\S]*grid-template-columns: 108px minmax\(0, 1fr\);/)
|
assert.match(messageItemStyles, /\.application-preview-row \{[\s\S]*grid-template-columns: 108px minmax\(0, 1fr\);/)
|
||||||
assert.match(messageItemStyles, /\.application-preview-text \{[\s\S]*overflow-wrap: anywhere;/)
|
assert.match(messageItemStyles, /\.application-preview-text \{[\s\S]*overflow-wrap: anywhere;/)
|
||||||
assert.match(messageItemStyles, /\.application-preview-select \{[\s\S]*width: 100%;/)
|
assert.match(messageItemStyles, /\.application-preview-select \{[\s\S]*width: 100%;/)
|
||||||
|
assert.match(messageItemStyles, /\.application-preview-footer \{[\s\S]*margin-top: 48px;/)
|
||||||
|
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-action-link\) \{[\s\S]*text-decoration: underline;/)
|
||||||
assert.match(messageItemStyles, /\.application-preview-footer-missing \{[\s\S]*margin-top: 48px;[\s\S]*background: transparent;/)
|
assert.match(messageItemStyles, /\.application-preview-footer-missing \{[\s\S]*margin-top: 48px;[\s\S]*background: transparent;/)
|
||||||
assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/)
|
assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ const createViewScript = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const messageItemStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
test('expense application submit uses rich text link and confirm dialog', () => {
|
test('expense application submit uses rich text link and confirm dialog', () => {
|
||||||
const copy = '请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。'
|
const copy = '请确认上述的信息是否填写正确?如果准确无误,点击 [确认](#application-submit) 进入审批环节。'
|
||||||
const rendered = renderMarkdown(copy)
|
const rendered = renderMarkdown(copy)
|
||||||
|
|
||||||
|
assert.match(copy, /请确认上述的信息是否填写正确/)
|
||||||
|
assert.match(copy, /进入审批环节/)
|
||||||
assert.match(
|
assert.match(
|
||||||
rendered,
|
rendered,
|
||||||
/<a href="#application-submit" class="markdown-action-link markdown-action-link-confirm">确认<\/a>/
|
/<a href="#application-submit" class="markdown-action-link markdown-action-link-confirm">确认<\/a>/
|
||||||
)
|
)
|
||||||
|
assert.match(messageItemStyles, /\.application-preview-footer \{[\s\S]*margin-top: 48px;/)
|
||||||
|
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-action-link\) \{[\s\S]*text-decoration: underline;/)
|
||||||
assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/)
|
assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/)
|
||||||
assert.match(createViewTemplate, /title="确认提交当前费用申请?"/)
|
assert.match(createViewTemplate, /title="确认提交当前费用申请?"/)
|
||||||
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"/)
|
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"/)
|
||||||
|
|||||||
@@ -1024,6 +1024,7 @@ function localSetupPlugin() {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
envDir: '..',
|
envDir: '..',
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ['www.caoxiaozhu.com', 'caoxiaozhu.com'],
|
||||||
watch: {
|
watch: {
|
||||||
...(preferPollingWatcher
|
...(preferPollingWatcher
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user