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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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