From 7d32eae74ef5da574dacdde008dc042263082678 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 27 May 2026 12:27:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E6=8A=A5=E5=91=8A=E7=BB=84=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8A=A5=E9=94=80=E4=BA=A4=E4=BA=92=E7=BB=86?= =?UTF-8?q?=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增预算助手报告视图模型和组件,优化报销洞察面板和消息项 样式细节,完善预算中心页面布局和文档中心视图,增强报销创 建会话管理和提交编排器,调整 Vite 构建配置,补充单元测试。 --- .../travel-reimbursement-insight-panel.css | 46 +- .../travel-reimbursement-message-item.css | 36 ++ .../styles/views/budget-center-view.css | 83 +++- .../styles/views/employee-management-view.css | 32 +- .../travel/BudgetAssistantReport.vue | 452 ++++++++++++++++++ .../TravelReimbursementInsightPanel.vue | 6 +- .../travel/TravelReimbursementMessageItem.vue | 7 + web/src/composables/useAppShell.js | 9 +- web/src/utils/expenseApplicationPreview.js | 9 +- web/src/views/AppShellRouteView.vue | 6 +- web/src/views/BudgetCenterView.vue | 169 ++----- web/src/views/DocumentsCenterView.vue | 12 +- .../views/TravelReimbursementCreateView.vue | 10 +- web/src/views/scripts/BudgetCenterView.js | 319 ++++-------- .../scripts/TravelReimbursementCreateView.js | 36 +- .../scripts/budgetAssistantReportModel.js | 277 +++++++++++ .../travelReimbursementConversationModel.js | 54 ++- .../scripts/useTravelReimbursementFlow.js | 39 +- .../useTravelReimbursementSessionState.js | 5 + .../useTravelReimbursementSubmitComposer.js | 33 +- .../expense-application-fast-preview.test.mjs | 6 +- ...e-application-submit-rich-confirm.test.mjs | 10 +- web/vite.config.js | 5 +- 23 files changed, 1197 insertions(+), 464 deletions(-) create mode 100644 web/src/components/travel/BudgetAssistantReport.vue create mode 100644 web/src/views/scripts/budgetAssistantReportModel.js diff --git a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css index 2ea88e3..5680ec9 100644 --- a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css +++ b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css @@ -43,6 +43,21 @@ pointer-events: none; } +.el-fade-in-linear-enter-active, +.el-fade-in-linear-leave-active { + transition: opacity 200ms linear; +} + +.el-fade-in-linear-enter-from, +.el-fade-in-linear-leave-to { + opacity: 0; +} + +.el-fade-in-linear-enter-to, +.el-fade-in-linear-leave-from { + opacity: 1; +} + .insight-head { display: grid; gap: 12px; @@ -401,11 +416,6 @@ color: #cbd5e1; } -.flow-step-item.completed .flow-step-status.completed { - background: rgba(255, 255, 255, 0.14); - color: #ffffff; -} - .flow-step-item.running .flow-step-rail span { border-color: #2563eb; background: #2563eb; @@ -429,32 +439,6 @@ background: #fef2f2; } -.flow-step-reveal-enter-active, -.flow-step-reveal-leave-active { - transition: - opacity 0.24s ease, - transform 0.28s ease, - filter 0.28s ease; -} - -.flow-step-reveal-enter-from, -.flow-step-reveal-leave-to { - opacity: 0; - filter: blur(2px); - transform: translateY(-8px); -} - -.flow-step-reveal-enter-to, -.flow-step-reveal-leave-from { - opacity: 1; - filter: blur(0); - transform: translateY(0); -} - -.flow-step-reveal-move { - transition: transform 0.24s ease; -} - .flow-empty-state, .review-side-empty, .review-document-preview-placeholder { diff --git a/web/src/assets/styles/components/travel-reimbursement-message-item.css b/web/src/assets/styles/components/travel-reimbursement-message-item.css index 8190e16..29684a7 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-item.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-item.css @@ -54,6 +54,10 @@ max-width: min(100%, 980px); } +.message-bubble-budget-report { + max-width: min(100%, 1080px); +} + .message-bubble-review-risk-low, .message-bubble-review-risk-medium, .message-bubble-review-risk-high { @@ -487,6 +491,38 @@ opacity: 0.42; } +.application-preview-footer { + margin-top: 48px; + color: #334155; + font-size: var(--wb-fs-bubble, 13px); + font-weight: 760; + line-height: 1.7; +} + +.application-preview-footer.message-answer-markdown :deep(p) { + margin: 0; +} + +.message-answer-markdown :deep(a) { + color: var(--theme-primary-active, #255b7d); + font-weight: 850; + text-decoration: underline; + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; +} + +.message-answer-markdown :deep(a:hover) { + color: var(--theme-primary, #3a7ca5); +} + +.message-answer-markdown :deep(.markdown-action-link) { + color: var(--theme-primary-active, #255b7d); + font-weight: 880; + text-decoration: underline; + text-decoration-thickness: 1.6px; + text-underline-offset: 3px; +} + .application-preview-footer-missing { display: flex; flex-wrap: wrap; diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index e87b7e0..c61d3b5 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -265,6 +265,46 @@ font-weight: 800; } +.budget-table-search { + position: relative; + width: min(260px, 42%); + min-width: 190px; + display: flex; + align-items: center; +} + +.budget-table-search i { + position: absolute; + left: 11px; + color: #94a3b8; + font-size: 15px; + pointer-events: none; +} + +.budget-table-search input { + width: 100%; + height: 32px; + border: 1px solid #dbe4ee; + border-radius: 6px; + padding: 0 11px 0 32px; + background: #fff; + color: #1f2937; + font-size: 13px; + font-weight: 650; + outline: none; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.budget-table-search input::placeholder { + color: #94a3b8; + font-weight: 500; +} + +.budget-table-search input:focus { + border-color: rgba(var(--theme-primary-rgb), .48); + box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb), .1); +} + .department-search { position: relative; margin: 12px 14px 8px; @@ -320,7 +360,7 @@ .budget-table-panel table { width: 100%; - min-width: 1040px; + min-width: 1460px; border-collapse: collapse; } @@ -383,29 +423,36 @@ background: var(--danger); } -.budget-warning-red { - color: var(--danger) !important; - font-weight: 800; +.budget-threshold-cell { + padding-left: 12px !important; + padding-right: 12px !important; } -.budget-warning-yellow { - color: var(--warning-active) !important; - font-weight: 800; -} - -.budget-row-actions { - display: flex; +.budget-threshold-badge { + min-width: 58px; + display: inline-flex; align-items: center; justify-content: center; - gap: 14px; + padding: 4px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + line-height: 1.2; } -.budget-row-actions button { - border: 0; - background: transparent; - color: var(--theme-primary-active); - font-size: 14px; - font-weight: 800; +.budget-threshold-badge.reminder { + background: rgba(37, 99, 235, .1); + color: #2563eb; +} + +.budget-threshold-badge.alert { + background: rgba(245, 158, 11, .14); + color: #b45309; +} + +.budget-threshold-badge.risk { + background: rgba(127, 29, 29, .1); + color: #7f1d1d; } .budget-table-foot { diff --git a/web/src/assets/styles/views/employee-management-view.css b/web/src/assets/styles/views/employee-management-view.css index 9210880..13e668d 100644 --- a/web/src/assets/styles/views/employee-management-view.css +++ b/web/src/assets/styles/views/employee-management-view.css @@ -29,6 +29,7 @@ } .employee-detail { + --employee-detail-radius: 4px; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 12px; @@ -699,6 +700,7 @@ tbody tr:last-child td { display: grid; gap: 18px; padding: 18px 20px; + border-radius: var(--employee-detail-radius); } .hero-profile { @@ -712,7 +714,7 @@ tbody tr:last-child td { height: 64px; display: grid; place-items: center; - border-radius: 18px; + border-radius: var(--employee-detail-radius); background: var(--theme-gradient-primary); color: #fff; font-size: 24px; @@ -724,7 +726,7 @@ tbody tr:last-child td { align-items: center; min-height: 26px; padding: 0 10px; - border-radius: 999px; + border-radius: var(--employee-detail-radius); background: #eff6ff; color: #2563eb; font-size: 12px; @@ -753,7 +755,7 @@ tbody tr:last-child td { .hero-stat { padding: 14px 16px; - border-radius: 12px; + border-radius: var(--employee-detail-radius); background: linear-gradient(180deg, #ffffff, #f8fafc); border: 1px solid #edf2f7; } @@ -791,6 +793,7 @@ tbody tr:last-child td { .detail-card, .side-card { padding: 18px; + border-radius: var(--employee-detail-radius); } .card-head { @@ -819,7 +822,7 @@ tbody tr:last-child td { align-items: center; min-height: 28px; padding: 0 10px; - border-radius: 999px; + border-radius: var(--employee-detail-radius); background: #ecfeff; color: #0891b2; font-size: 12px; @@ -847,7 +850,7 @@ tbody tr:last-child td { .field input { width: 100%; border: 1px solid #d7e0ea; - border-radius: 10px; + border-radius: var(--employee-detail-radius); background: #fff; color: #0f172a; font-size: 13px; @@ -985,7 +988,7 @@ tbody tr:last-child td { align-items: start; padding: 14px; border: 1px solid #edf2f7; - border-radius: 14px; + border-radius: var(--employee-detail-radius); background: #fbfdff; } @@ -1018,6 +1021,19 @@ tbody tr:last-child td { flex-wrap: wrap; } +.tag-list span { + display: inline-flex; + min-height: 26px; + align-items: center; + padding: 0 9px; + border: 1px solid rgba(var(--theme-primary-rgb), 0.18); + border-radius: var(--employee-detail-radius); + background: var(--theme-primary-soft); + color: var(--theme-primary-active); + font-size: 12px; + font-weight: 760; +} + .bullet-list { display: grid; gap: 8px; @@ -1108,7 +1124,7 @@ td.cell-updated { gap: 12px; min-height: 42px; padding: 0 12px; - border-radius: 10px; + border-radius: var(--employee-detail-radius); background: #f8fafc; } @@ -1142,7 +1158,7 @@ td.cell-updated { justify-content: center; gap: 6px; padding: 0 14px; - border-radius: 8px; + border-radius: var(--employee-detail-radius); font-size: 13px; font-weight: 760; } diff --git a/web/src/components/travel/BudgetAssistantReport.vue b/web/src/components/travel/BudgetAssistantReport.vue new file mode 100644 index 0000000..e2c67ea --- /dev/null +++ b/web/src/components/travel/BudgetAssistantReport.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/web/src/components/travel/TravelReimbursementInsightPanel.vue b/web/src/components/travel/TravelReimbursementInsightPanel.vue index 7909f98..4935326 100644 --- a/web/src/components/travel/TravelReimbursementInsightPanel.vue +++ b/web/src/components/travel/TravelReimbursementInsightPanel.vue @@ -147,7 +147,7 @@ @@ -356,7 +356,7 @@ @@ -630,7 +630,7 @@ export default { methods: { resolveFlowStepStyle(index) { return { - transitionDelay: `${Math.min(Number(index) || 0, 5) * 70}ms` + transitionDelay: `${500 + Math.min(Number(index) || 0, 5) * 80}ms` } } } diff --git a/web/src/components/travel/TravelReimbursementMessageItem.vue b/web/src/components/travel/TravelReimbursementMessageItem.vue index 334e182..baa3530 100644 --- a/web/src/components/travel/TravelReimbursementMessageItem.vue +++ b/web/src/components/travel/TravelReimbursementMessageItem.vue @@ -35,6 +35,11 @@ @click="ui.handleAssistantMarkdownClick($event, message)" > + +
- diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue index ecb62fd..4b72e68 100644 --- a/web/src/views/DocumentsCenterView.vue +++ b/web/src/views/DocumentsCenterView.vue @@ -93,7 +93,7 @@
- @@ -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) { diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 113580e..360b7d8 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -379,10 +379,12 @@ - + + +
diff --git a/web/src/views/scripts/BudgetCenterView.js b/web/src/views/scripts/BudgetCenterView.js index 7c20c66..697a3d7 100644 --- a/web/src/views/scripts/BudgetCenterView.js +++ b/web/src/views/scripts/BudgetCenterView.js @@ -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 diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 4ae6d84..6df7500 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -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, diff --git a/web/src/views/scripts/budgetAssistantReportModel.js b/web/src/views/scripts/budgetAssistantReportModel.js new file mode 100644 index 0000000..de24bd3 --- /dev/null +++ b/web/src/views/scripts/budgetAssistantReportModel.js @@ -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 +} diff --git a/web/src/views/scripts/travelReimbursementConversationModel.js b/web/src/views/scripts/travelReimbursementConversationModel.js index bf24058..2fea687 100644 --- a/web/src/views/scripts/travelReimbursementConversationModel.js +++ b/web/src/views/scripts/travelReimbursementConversationModel.js @@ -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 ) }) } diff --git a/web/src/views/scripts/useTravelReimbursementFlow.js b/web/src/views/scripts/useTravelReimbursementFlow.js index 51be778..a15a307 100644 --- a/web/src/views/scripts/useTravelReimbursementFlow.js +++ b/web/src/views/scripts/useTravelReimbursementFlow.js @@ -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, diff --git a/web/src/views/scripts/useTravelReimbursementSessionState.js b/web/src/views/scripts/useTravelReimbursementSessionState.js index f873b9f..9906e38 100644 --- a/web/src/views/scripts/useTravelReimbursementSessionState.js +++ b/web/src/views/scripts/useTravelReimbursementSessionState.js @@ -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) diff --git a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js index 950ba4f..6d29cde 100644 --- a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js +++ b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js @@ -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 { diff --git a/web/tests/expense-application-fast-preview.test.mjs b/web/tests/expense-application-fast-preview.test.mjs index 10f5d98..4952a9a 100644 --- a/web/tests/expense-application-fast-preview.test.mjs +++ b/web/tests/expense-application-fast-preview.test.mjs @@ -180,8 +180,8 @@ test('application preview keeps rule fallback distinct from model reviewed resul assert.equal(fallbackPreview.modelReviewStatus, 'fallback') assert.match(message, /规则兜底/) - assert.match(footer, /规则兜底/) - assert.doesNotMatch(footer, /#application-submit/) + assert.match(footer, /请确认上述的信息是否填写正确/) + assert.match(footer, /#application-submit/) }) 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-text \{[\s\S]*overflow-wrap: anywhere;/) 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-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/) }) diff --git a/web/tests/expense-application-submit-rich-confirm.test.mjs b/web/tests/expense-application-submit-rich-confirm.test.mjs index 85c77fc..ac60386 100644 --- a/web/tests/expense-application-submit-rich-confirm.test.mjs +++ b/web/tests/expense-application-submit-rich-confirm.test.mjs @@ -13,15 +13,23 @@ const createViewScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)), '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', () => { - const copy = '请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。' + const copy = '请确认上述的信息是否填写正确?如果准确无误,点击 [确认](#application-submit) 进入审批环节。' const rendered = renderMarkdown(copy) + assert.match(copy, /请确认上述的信息是否填写正确/) + assert.match(copy, /进入审批环节/) assert.match( rendered, /确认<\/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, /title="确认提交当前费用申请?"/) assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"/) diff --git a/web/vite.config.js b/web/vite.config.js index c9f4bb9..ce57acd 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1021,9 +1021,10 @@ function localSetupPlugin() { } } -export default defineConfig({ - envDir: '..', +export default defineConfig({ + envDir: '..', server: { + allowedHosts: ['www.caoxiaozhu.com', 'caoxiaozhu.com'], watch: { ...(preferPollingWatcher ? {