feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -18,13 +18,11 @@
<main
class="main"
:class="{
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'archive-main': activeView === 'archive',
'policies-main': activeView === 'policies',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'budget-main': activeView === 'budget',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
'logs-main': activeView === 'logs',
@@ -57,7 +55,7 @@
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -68,11 +66,9 @@
<section
class="workarea"
:class="{
'requests-workarea': activeView === 'requests',
'documents-workarea': activeView === 'documents',
'approval-workarea': activeView === 'approval',
'archive-workarea': activeView === 'archive',
'policies-workarea': activeView === 'policies',
'documents-workarea': activeView === 'documents',
'budget-workarea': activeView === 'budget',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'logs-workarea': activeView === 'logs',
'employees-workarea': activeView === 'employees',
@@ -92,11 +88,11 @@
@open-assistant="openSmartEntry"
/>
<TravelRequestDetailView
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
:request="selectedRequest"
:back-label="activeView === 'documents' ? '返回单据中心' : '返回报销列表'"
@back-to-requests="closeRequestDetail"
<TravelRequestDetailView
v-else-if="activeView === 'documents' && detailMode && selectedRequest"
:request="selectedRequest"
back-label="返回单据中心"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated"
@request-deleted="handleRequestDeleted"
@@ -115,22 +111,8 @@
@summary-change="documentSummary = $event"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@reload="reloadRequests"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<ArchiveCenterView v-else-if="activeView === 'archive'" />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<BudgetCenterView v-else-if="activeView === 'budget'" />
<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" />
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
@@ -145,11 +127,12 @@
:initial-prompt="smartEntryContext.prompt"
:initial-files="smartEntryContext.files"
:initial-conversation="smartEntryContext.conversation"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
:reopen-token="smartEntryRevealToken"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
/>
</div>
</template>
@@ -162,13 +145,11 @@ import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import ArchiveCenterView from './ArchiveCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import BudgetCenterView from './BudgetCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import LogsView from './LogsView.vue'
import LogDetailView from './LogDetailView.vue'
@@ -222,9 +203,10 @@ const {
search,
selectedRequest,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
toast,
topBarView
} = useAppShell()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
<template>
<section class="budget-center-page">
<header class="budget-local-head">
<h2>预算管理</h2>
</header>
<section class="budget-summary-grid" aria-label="预算概览">
<article v-for="metric in budgetMetrics" :key="metric.label" class="budget-summary-card">
<span class="summary-icon" :class="metric.tone">
<i :class="metric.icon"></i>
</span>
<div>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<em>{{ metric.note }}</em>
</div>
</article>
</section>
<section class="budget-filter-bar">
<label>
<span>预算周期</span>
<select v-model="filters.period">
<option v-for="period in periods" :key="period">{{ period }}</option>
</select>
</label>
<label>
<span>费用类型</span>
<select v-model="filters.expenseType">
<option v-for="type in expenseTypes" :key="type">{{ type }}</option>
</select>
</label>
<label>
<span>状态</span>
<select v-model="filters.status">
<option v-for="status in statuses" :key="status">{{ status }}</option>
</select>
</label>
<button class="budget-primary-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建预算</span>
</button>
</section>
<section class="budget-work-grid">
<aside class="budget-department-panel">
<header>
<strong>部门切换</strong>
</header>
<div class="department-search">
<i class="mdi mdi-magnify"></i>
<input v-model="departmentKeyword" type="search" placeholder="搜索部门" />
</div>
<nav class="department-list" aria-label="预算部门">
<button
v-for="department in visibleDepartments"
:key="department.code"
type="button"
:class="{ active: department.code === activeDepartmentCode }"
@click="activeDepartmentCode = department.code"
>
<i :class="department.icon"></i>
<span>{{ department.name }}</span>
</button>
</nav>
</aside>
<article class="budget-table-panel">
<header>
<strong>当前部门{{ activeDepartmentName }}</strong>
</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>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleBudgetRows" :key="row.expenseType">
<td>{{ row.expenseType }}</td>
<td>{{ row.total }}</td>
<td>{{ row.used }}</td>
<td>{{ row.occupied }}</td>
<td>{{ row.left }}</td>
<td>
<div class="budget-rate">
<span>{{ row.rate }}%</span>
<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>
<div class="budget-row-actions">
<button type="button">详情</button>
<button type="button">编辑</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="budget-table-foot">
<button type="button" disabled><i class="mdi mdi-chevron-left"></i></button>
<button type="button" class="active">1</button>
<button type="button" disabled><i class="mdi mdi-chevron-right"></i></button>
<select aria-label="每页条数">
<option>10 /</option>
</select>
<span> {{ visibleBudgetRows.length }} </span>
</footer>
</article>
</section>
<section class="budget-bottom-grid">
<article class="budget-chart-panel">
<header class="budget-card-head">
<strong>预算使用趋势</strong>
<div class="budget-chart-legend">
<span><i class="legend-line budget"></i>预算</span>
<span><i class="legend-line used"></i>已发生</span>
</div>
</header>
<BudgetTrendChart
:labels="trendData.labels"
:budget="trendData.budget"
:used="trendData.used"
/>
</article>
<article class="budget-alert-panel">
<header class="budget-card-head">
<strong>预算预警</strong>
<button type="button">查看全部</button>
</header>
<div class="budget-alert-list">
<div v-for="alert in warnings" :key="alert.title" class="budget-alert-row">
<i :class="alert.tone"></i>
<strong>{{ alert.title }}</strong>
<span>{{ alert.desc }}</span>
<time>{{ alert.date }}</time>
</div>
</div>
</article>
</section>
</section>
</template>
<script src="./scripts/BudgetCenterView.js"></script>
<style scoped src="../assets/styles/views/budget-center-view.css"></style>

View File

@@ -264,6 +264,7 @@ import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, isArchivedDocumentRow } from '../utils/documentCenterRows.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const DOCUMENT_TYPE_ALL = 'all'
@@ -388,9 +389,11 @@ const dateRangeLabel = computed(() => {
})
const ownedRows = computed(() =>
props.filteredRequests
.map((item) => buildDocumentRow(item, { source: 'owned' }))
.filter(Boolean)
excludeArchivedDocumentRows(
props.filteredRequests
.map((item) => buildDocumentRow(item, { source: 'owned' }))
.filter(Boolean)
)
)
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
@@ -518,7 +521,7 @@ const emptyState = computed(() => {
actionIcon: '',
tone: 'emerald',
artLabel: 'APPLY',
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
}
}
@@ -533,10 +536,17 @@ const emptyState = computed(() => {
actionIcon: '',
tone: 'emerald',
artLabel: filtered ? 'FILTER' : 'DOCS',
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档数据']
}
})
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
return '申请归档'
}
return normalized.node || normalized.workflowNode || '财务归档'
}
function buildDocumentRow(request, options = {}) {
const normalized = normalizeRequestForUi(request)
if (!normalized) {
@@ -563,7 +573,7 @@ function buildDocumentRow(request, options = {}) {
documentTypeLabel,
claimId,
documentNo,
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
statusGroup,
statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
@@ -598,6 +608,10 @@ function resolveStatusTone(row, statusGroup) {
}
function matchesStatusTab(row, tab) {
if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
return false
}
if (tab === '全部') return true
if (tab === '草稿') return row.statusGroup === 'draft'
if (tab === '待提交') return row.statusGroup === 'pending_submit'
@@ -730,12 +744,14 @@ async function loadSupportingRows() {
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = Array.isArray(approvalResult.value)
? approvalResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
approvalRows.value = excludeArchivedDocumentRows(
Array.isArray(approvalResult.value)
? approvalResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
)
} else {
approvalRows.value = []
}

View File

@@ -50,7 +50,7 @@
class="assistant-layout"
:class="{
'can-show-insight': hasInsightPanelContent,
'has-insight': showInsightPanel
'has-insight': hasInsightPanelContent && showInsightPanel
}"
>
<section class="dialog-panel">
@@ -109,6 +109,88 @@
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="isApplicationPreviewEditing(message, row.key) && resolveApplicationPreviewEditorControl(row.key) !== 'select'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
@blur="commitApplicationPreviewEditor(message)"
/>
<select
v-else-if="isApplicationPreviewEditing(message, row.key)"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
autofocus
@click.stop
@change="commitApplicationPreviewEditor(message)"
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
@blur="commitApplicationPreviewEditor(message)"
>
<option value="">请选择</option>
<option
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview && buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="renderMarkdown(buildApplicationPreviewFooterText(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
class="welcome-quick-actions"
@@ -577,7 +659,7 @@
</div>
</div>
</div>
<div class="travel-calculator-anchor">
<div v-if="canShowTravelCalculator" class="travel-calculator-anchor">
<button
type="button"
class="tool-btn composer-side-btn travel-calculator-trigger"

View File

@@ -88,7 +88,7 @@
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<article v-if="!isApplicationDocument" class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>附加说明</h3>
@@ -133,11 +133,14 @@
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>{{ isApplicationDocument ? '申请预算' : '费用明细' }}</h3>
<h3 class="detail-card-title-with-icon">
<i v-if="isApplicationDocument" class="mdi mdi-file-document-outline"></i>
<span>{{ isApplicationDocument ? '申请详情' : '费用明细' }}</span>
</h3>
<p>
{{
isApplicationDocument
? '展示本次费用申请的预计金额,提交后纳入预算管理口径。'
? '展示本次申请的事实信息、职级规则测算和用户预估费用。'
: isTravelRequest
? '按出行时间逐笔核对票据与差旅规则。'
: '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。'
@@ -162,13 +165,42 @@
</div>
</div>
<div v-if="isApplicationDocument" class="detail-note readonly">
<p>
预计总费用{{ request.amountDisplay }}该金额用于领导审批和预算管理无需补充任何报销票据
</p>
<div v-if="isApplicationDocument" class="application-detail-facts">
<div
v-for="item in applicationDetailFactItems"
:key="item.key"
class="application-detail-fact"
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-else class="detail-expense-table">
<div v-if="showApplicationLeaderOpinion" class="application-leader-opinion">
<div class="application-leader-opinion-head">
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
</div>
<div v-if="showApplicationLeaderOpinionInput" class="leader-approval-card inline-leader-opinion">
<textarea
v-model="leaderOpinion"
maxlength="500"
:required="requiresApprovalOpinion"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</div>
<div v-else class="detail-note readonly application-leader-opinion-display">
<p>{{ leaderApprovalReadonlyText }}</p>
</div>
</div>
<div v-if="!isApplicationDocument" class="detail-expense-table">
<table>
<thead>
<tr>
@@ -452,6 +484,7 @@
<textarea
v-model="leaderOpinion"
maxlength="500"
:required="requiresApprovalOpinion"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
@@ -479,7 +512,7 @@
{{ submitBusy ? '提交中' : '提交审批' }}
</button>
</div>
<div v-else-if="canReturnRequest || canApproveRequest || canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
<div v-else-if="canReturnRequest || canApproveRequest || canDeleteRequest" class="approval-action-group" aria-label="单据管理操作">
<button
v-if="canReturnRequest"
class="return-action"
@@ -498,10 +531,10 @@
@click="handleApproveRequest"
>
<i class="mdi mdi-check-circle-outline"></i>
{{ approveBusy ? '通过中' : '审批通过' }}
{{ approveBusy ? approveBusyLabel : approveActionLabel }}
</button>
<button
v-if="canManageCurrentClaim"
v-if="canDeleteRequest"
class="reject-action"
type="button"
:disabled="actionBusy"
@@ -750,11 +783,11 @@
:open="approveConfirmDialogOpen"
:badge="approvalConfirmBadge"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
:title="approveConfirmTitle"
:description="approvalConfirmDescription"
cancel-text="返回核对"
confirm-text="确认通过"
busy-text="通过中..."
:confirm-text="approveConfirmText"
:busy-text="approveBusyText"
confirm-tone="primary"
confirm-icon="mdi mdi-check-circle-outline"
:busy="approveBusy"
@@ -771,7 +804,7 @@
<strong>{{ request.node }}</strong>
</div>
<div class="submit-confirm-row">
<span>下一节点</span>
<span>{{ approvalConfirmSummaryLabel }}</span>
<strong>{{ approvalNextStage }}</strong>
</div>
<div class="submit-confirm-row">
@@ -784,7 +817,7 @@
<ReturnReasonDialog
:open="returnDialogOpen"
:title="`确认退回 ${request.id} 吗?`"
description="退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。"
:description="returnDialogDescription"
:busy="returnBusy"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"

View File

@@ -21,10 +21,13 @@ import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const ARCHIVE_TAB_ALL = '全部归档'
const ARCHIVE_TAB_APPLICATION = '申请归档'
const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'
const ARCHIVE_TYPE_APPLICATION = '申请'
const ARCHIVE_TYPE_APPLICATION_CODE = 'application'
const ARCHIVE_TYPE_REIMBURSEMENT = '报销'
const ARCHIVE_TYPE_REIMBURSEMENT_CODE = 'reimbursement'
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT]
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_APPLICATION, ARCHIVE_TAB_REIMBURSEMENT]
const RISK_FILTER_OPTIONS = [
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
{ value: 'has', label: '有风险' },
@@ -49,6 +52,7 @@ function buildArchiveRow(request) {
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
const hasRisk = riskCount > 0
const isApplicationDocument = normalized.documentTypeCode === 'application'
const archiveMonth = extractArchiveMonth(
normalized.updatedAt,
normalized.submittedAt,
@@ -68,16 +72,16 @@ function buildArchiveRow(request) {
archivedAt: normalized.updatedAt || normalized.applyTime,
archiveMonth,
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
archiveType: ARCHIVE_TYPE_REIMBURSEMENT,
archiveTypeCode: ARCHIVE_TYPE_REIMBURSEMENT_CODE,
node: normalized.workflowNode || '归档入账',
archiveType: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT,
archiveTypeCode: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION_CODE : ARCHIVE_TYPE_REIMBURSEMENT_CODE,
node: isApplicationDocument ? '申请归档' : (normalized.workflowNode || '归档入账'),
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: ARCHIVE_TAB_REIMBURSEMENT
archiveTab: isApplicationDocument ? ARCHIVE_TAB_APPLICATION : ARCHIVE_TAB_REIMBURSEMENT
}
}

View File

@@ -69,9 +69,7 @@ import {
} from './auditViewModel.js'
import {
createDefaultRiskRuleForm,
RISK_RULE_CREATE_DOMAIN_OPTIONS,
RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
RISK_RULE_LEVEL_OPTIONS
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
} from './auditViewRiskRuleModel.js'
export default {
@@ -141,6 +139,7 @@ export default {
let spreadsheetOnlyOfficeHadLocalEdits = false
let spreadsheetOnlyOfficeSyncSeq = 0
let spreadsheetOnlyOfficeChangePollTimer = null
const riskRuleGenerationPollTimers = new Map()
const assetBuckets = ref({
financialRules: [],
riskRules: [],
@@ -162,7 +161,7 @@ export default {
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const showOnlineColumn = computed(() => activeType.value === 'riskRules')
const showOnlineColumn = computed(() => false)
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
@@ -188,11 +187,19 @@ export default {
const riskRuleInReview = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
)
const riskRuleGenerationBusy = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'generating'
)
const riskRuleGenerationFailed = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'failed'
)
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
Boolean(selectedSkill.value?.id) &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value &&
!detailBusy.value
)
const canDeleteRiskRule = computed(
@@ -203,11 +210,17 @@ export default {
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
!detailBusy.value
)
const canSubmitRiskRuleReview = computed(
const canOpenRiskRuleReviewSubmit = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canSubmitReview.value &&
!riskRuleInReview.value &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value
)
const canSubmitRiskRuleReview = computed(
() =>
canOpenRiskRuleReviewSubmit.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
@@ -355,8 +368,8 @@ export default {
const showRiskScenarioFilter = computed(() =>
['financialRules', 'riskRules'].includes(activeType.value)
)
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
const showOnlineFilter = computed(() => activeType.value === 'riskRules')
const showStatusFilter = computed(() => true)
const showOnlineFilter = computed(() => false)
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
const selectedRiskScenarioLabel = computed(
() =>
@@ -618,6 +631,11 @@ export default {
return
}
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
const ruleTitle = String(riskRuleCreateForm.value.rule_title || '').trim()
if (ruleTitle.length < 2) {
toast('请输入至少 2 个字的规则标题。')
return
}
if (naturalLanguage.length < 8) {
toast('请至少输入 8 个字的风险规则描述。')
return
@@ -627,11 +645,9 @@ export default {
try {
const detail = await generateRiskRuleAsset(
{
business_domain: riskRuleCreateForm.value.business_domain,
expense_category: riskRuleCreateForm.value.business_domain === 'expense'
? riskRuleCreateForm.value.expense_category
: null,
risk_level: riskRuleCreateForm.value.risk_level,
business_domain: 'expense',
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
natural_language: naturalLanguage
},
@@ -639,9 +655,8 @@ export default {
)
riskRuleCreateOpen.value = false
await refreshCurrentAssets()
selectedSkill.value = buildDetailViewModel(detail, runs.value)
await loadRiskRuleJson(detail.id)
toast('风险规则草稿已生成,请在详情中核对业务说明和判断流程。')
scheduleRiskRuleGenerationPoll(detail.id)
toast('风险规则已进入后台生成,列表会先显示生成中。')
} catch (error) {
toast(error?.message || '风险规则生成失败,请稍后重试。')
} finally {
@@ -649,6 +664,40 @@ export default {
}
}
function stopRiskRuleGenerationPoll(assetId) {
const timer = riskRuleGenerationPollTimers.get(assetId)
if (timer) {
window.clearTimeout(timer)
riskRuleGenerationPollTimers.delete(assetId)
}
}
function scheduleRiskRuleGenerationPoll(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId) {
return
}
stopRiskRuleGenerationPoll(normalizedAssetId)
const timer = window.setTimeout(async () => {
try {
await refreshCurrentAssets()
const latest = (assetBuckets.value.riskRules || []).find((item) => item.id === normalizedAssetId)
if (!latest || latest.statusValue !== 'generating' || attempt >= 59) {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
return
}
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} catch {
if (attempt < 59) {
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} else {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
}
}
}, attempt === 0 ? 1200 : 3000)
riskRuleGenerationPollTimers.set(normalizedAssetId, timer)
}
async function persistRuleRuntimeConfig(asset, runtimeRule) {
await updateAgentAsset(
asset.id,
@@ -1033,6 +1082,9 @@ export default {
loadSpreadsheetChangeRecords(assetId).catch(() => {})
}
if (selectedSkill.value.usesJsonRiskRule) {
if (selectedSkill.value.riskRuleGenerationFailed || selectedSkill.value.riskRuleGenerationBusy) {
return
}
try {
await loadRiskRuleJson(assetId)
} catch (jsonError) {
@@ -1143,6 +1195,10 @@ export default {
}
function openAssetDetail(asset) {
if (asset?.usesJsonRiskRule && asset.statusValue === 'generating') {
toast('规则仍在后台生成中,生成完成后才能进入详情。')
return
}
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
@@ -1397,11 +1453,13 @@ export default {
}
async function openSubmitReviewDialog() {
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
if (
selectedSkillUsesJsonRisk.value &&
!canOpenRiskRuleReviewSubmit.value
) {
return
}
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
toast('请先在“测试规则”中保存测试通过报告,再提交审核。')
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || ''
@@ -1698,6 +1756,8 @@ export default {
onBeforeUnmount(() => {
destroySpreadsheetOnlyOfficeEditor()
riskRuleGenerationPollTimers.forEach((timer) => window.clearTimeout(timer))
riskRuleGenerationPollTimers.clear()
document.removeEventListener('click', handleDocumentClick)
})
@@ -1753,6 +1813,7 @@ export default {
canCreateRiskRule,
canOpenRiskRuleTest,
canDeleteRiskRule,
canOpenRiskRuleReviewSubmit,
canSubmitRiskRuleReview,
canReturnRiskRule,
canPublishRiskRule,
@@ -1790,9 +1851,7 @@ export default {
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
showReviewNote,
spreadsheetUploadInput,
spreadsheetOnlyOfficeLoading,

View File

@@ -0,0 +1,216 @@
import { computed, onMounted, ref } from 'vue'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
{ code: 'FINANCE-DEPT', name: '财务部', costCenter: 'CC-2100' },
{ code: 'TECH-DEPT', name: '技术部', costCenter: 'CC-6100' },
{ code: 'HR-DEPT', name: '人力资源部', costCenter: 'CC-3200' },
{ code: 'PRODUCTION-DEPT', name: '生产部', costCenter: 'CC-7200' },
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
const EXPENSE_BLUEPRINTS = [
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
]
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
function buildDepartmentRows(departmentCode) {
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
const factor = 0.88 + (seed % 18) / 100
return EXPENSE_BLUEPRINTS.map((item, index) => {
const totalAmount = Math.round(item.total * factor)
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
return {
...item,
totalAmount,
usedAmount,
occupiedAmount,
leftAmount,
rate,
rateTone: rate >= item.warning ? 'danger' : rate >= item.warning - 12 ? 'warn' : 'ok',
warningTone: item.warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
warningLine: `${item.warning}%`,
total: currency(totalAmount),
used: currency(usedAmount),
occupied: currency(occupiedAmount),
left: currency(leftAmount)
}
})
}
function buildTrendData(rows) {
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
const used = rows.reduce((sum, item) => sum + item.usedAmount + item.occupiedAmount, 0)
return {
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
budget: [0.05, 0.18, 0.25, 0.34, 0.45, 0.52, 0.68, 0.76, 0.84, 0.91, 0.96, 1].map((ratio) =>
Math.round(total * ratio)
),
used: [0.03, 0.1, 0.13, 0.22, 0.3, 0.37, 0.51, 0.59, 0.69, 0.73, 0.86, 0.96].map((ratio) =>
Math.round(used * ratio)
)
}
}
export default {
name: 'BudgetCenterView',
components: {
BudgetTrendChart
},
setup() {
const departments = ref(FALLBACK_DEPARTMENTS)
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
const filters = ref({
period: '2026年度',
expenseType: '全部',
status: '全部'
})
const activeDepartment = computed(() =>
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
)
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
const departmentRows = computed(() => buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value))
const visibleBudgetRows = computed(() =>
departmentRows.value
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
if (filters.value.status === '全部') return true
if (filters.value.status === '预警') return row.rateTone === 'warn'
if (filters.value.status === '管控') return row.rateTone === 'danger'
return row.rateTone === 'ok'
})
)
const totals = computed(() => {
const rows = departmentRows.value
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
const used = rows.reduce((sum, item) => sum + item.usedAmount, 0)
const occupied = rows.reduce((sum, item) => sum + item.occupiedAmount, 0)
return {
total,
used,
occupied,
left: Math.max(total - used - occupied, 0)
}
})
const budgetMetrics = computed(() => [
{
label: '预算总额',
value: `¥${currency(totals.value.total)}`,
note: '本年累计',
tone: 'green',
icon: 'mdi mdi-wallet-outline'
},
{
label: '已发生',
value: `¥${currency(totals.value.used)}`,
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
tone: 'blue',
icon: 'mdi mdi-chart-line'
},
{
label: '已占用',
value: `¥${currency(totals.value.occupied)}`,
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
tone: 'orange',
icon: 'mdi mdi-briefcase-check-outline'
},
{
label: '剩余可用',
value: `¥${currency(totals.value.left)}`,
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
tone: 'green',
icon: 'mdi mdi-currency-cny'
}
])
const visibleDepartments = computed(() => {
const keyword = departmentKeyword.value.trim()
return departments.value
.filter((item) => !keyword || item.name.includes(keyword) || item.code.includes(keyword))
.map((item) => ({
...item,
icon: item.code === activeDepartmentCode.value ? 'mdi mdi-account-group-outline' : 'mdi mdi-domain'
}))
})
const warnings = computed(() =>
departmentRows.value
.slice()
.sort((a, b) => b.rate - a.rate)
.slice(0, 4)
.map((row, index) => ({
title: row.expenseType,
desc: `使用率已达 ${row.rate}%${row.rate >= row.warning ? '已超过预警线' : '接近预警线'}${row.warningLine}`,
date: index < 2 ? '2026-05-12' : '2026-05-10',
tone: row.rate >= row.warning ? 'danger' : row.rate >= row.warning - 12 ? 'warn' : 'ok'
}))
)
const trendData = computed(() => buildTrendData(departmentRows.value))
async function loadDepartments() {
try {
const payload = await fetchEmployeeMeta()
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
const nextDepartments = options
.filter((item) => item?.code && item?.name)
.map((item) => ({
code: String(item.code),
name: String(item.name),
costCenter: String(item.costCenter || '')
}))
if (nextDepartments.length) {
departments.value = nextDepartments
if (!nextDepartments.some((item) => item.code === activeDepartmentCode.value)) {
activeDepartmentCode.value = nextDepartments[0].code
}
}
} catch (error) {
console.warn('Failed to load budget departments from employee meta:', error)
}
}
onMounted(() => {
void loadDepartments()
})
return {
activeDepartmentCode,
activeDepartmentName,
budgetMetrics,
departmentKeyword,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
filters,
periods: ['2026年度', '2026年Q2', '2026年5月'],
statuses: ['全部', '正常', '预警', '管控'],
trendData,
visibleBudgetRows,
visibleDepartments,
warnings
}
}
}

View File

@@ -34,7 +34,7 @@ const FALLBACK_ROLE_OPTIONS = [
id: 'approver',
code: 'approver',
label: '审批负责人',
desc: '可以处理审批中心中的待审单据。'
desc: '可以处理单据中心中的待审单据。'
},
{
id: 'executive',

View File

@@ -12,6 +12,7 @@ import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementRevi
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
@@ -34,6 +35,12 @@ import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
calculateTravelReimbursement,
fetchExpenseClaims,
@@ -520,6 +527,10 @@ export default {
invalidatedDraftClaimId: {
type: String,
default: ''
},
reopenToken: {
type: Number,
default: 0
}
},
emits: ['close', 'draft-saved'],
@@ -578,7 +589,22 @@ export default {
const reviewActionBusy = ref(false)
const deleteSessionBusy = ref(false)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const {
applicationPreviewEditor,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
cancelApplicationPreviewEditor,
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast
})
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
@@ -642,9 +668,9 @@ export default {
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(
() => isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
)
const hasInsightPanelContent = computed(() => {
return isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
})
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
@@ -820,7 +846,7 @@ export default {
applyComposerDateSelection,
resolveTravelCalculatorInitialDays,
resolveTravelCalculatorInitialLocation,
openTravelCalculator,
openTravelCalculator: openTravelCalculatorInternal,
toggleTravelCalculator: toggleTravelCalculatorInternal,
closeTravelCalculator,
formatTravelCalculatorMoney,
@@ -845,6 +871,7 @@ export default {
buildLocallySyncedReviewPayload,
formatDateInputValue
})
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
const {
fileInputMode,
attachedFiles,
@@ -940,6 +967,7 @@ export default {
fetchExpenseClaims,
fileInputRef,
flowRunId,
insightPanelCollapsed,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
@@ -1011,13 +1039,30 @@ export default {
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer: submitComposerInternal,
currentUser,
toast
})
function openTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return openTravelCalculatorInternal()
}
function toggleTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return toggleTravelCalculatorInternal()
}
function submitTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
// 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。
// calculateTravelReimbursement({ grade: String(user.grade || '').trim() })
// 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计
@@ -1027,6 +1072,11 @@ export default {
// messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload)
return submitTravelCalculatorInternal()
}
watch(canShowTravelCalculator, (visible) => {
if (!visible && travelCalculatorOpen.value) {
closeTravelCalculator()
}
})
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() =>
@@ -1142,6 +1192,21 @@ export default {
}
)
watch(
() => props.reopenToken,
(token, previousToken) => {
if (token === previousToken) {
return
}
closeAfterBusy.value = false
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
@@ -1510,7 +1575,7 @@ export default {
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-request-detail',
name: 'app-document-detail',
params: { requestId: claimId }
})
: null
@@ -1558,7 +1623,7 @@ export default {
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-request-detail',
name: 'app-document-detail',
params: { requestId: claimId }
})
return {
@@ -1579,6 +1644,9 @@ export default {
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.applicationPreview) {
return 'message-bubble-application-preview'
}
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
return ''
}
@@ -1635,10 +1703,27 @@ export default {
}
}
function buildApplicationPreviewFooterText(message) {
if (!message?.applicationPreview) {
return ''
}
return buildApplicationPreviewFooterMessage(message.applicationPreview)
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
toast(`请先补充:${normalizedPreview.missingFields.join('、')}`)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
@@ -1660,6 +1745,12 @@ export default {
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
applicationSubmitConfirmDialog.value = {
open: false,
message: null
@@ -1667,10 +1758,15 @@ export default {
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: '确认提交',
rawText: applicationSubmitText,
userText: '确认提交',
pendingText: '正在提交费用申请...',
systemGenerated: true
systemGenerated: true,
skipScopeGuard: true,
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
@@ -1708,6 +1804,9 @@ export default {
}
function emitCloseAfterLeave() {
if (workbenchVisible.value) {
return
}
if (closeAfterBusy.value && isWorkbenchBusy()) {
return
}
@@ -1722,7 +1821,7 @@ export default {
}
router.push({
name: 'app-request-detail',
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
@@ -2018,12 +2117,12 @@ export default {
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, applicationSubmitConfirmDialog, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, canShowTravelCalculator, deleteSessionDialogOpen, applicationSubmitConfirmDialog, applicationPreviewEditor, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -20,11 +20,14 @@ import {
} from '../../services/reimbursements.js'
import {
canApproveLeaderExpenseClaims,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isFinanceUser
} from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import { buildLeaderApprovalInfo, resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js'
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
@@ -460,7 +463,13 @@ export default {
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
return isEditableRequest.value || canManageCurrentClaim.value
})
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
@@ -475,7 +484,7 @@ export default {
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
Boolean(props.approvalMode)
(Boolean(props.approvalMode) || isApplicationDocument.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
@@ -490,7 +499,37 @@ export default {
)
)
)
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
const showApplicationLeaderOpinionInput = computed(() => (
isApplicationDocument.value
&& canApproveRequest.value
&& isDirectManagerApprovalStage.value
))
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalReadonlyText = computed(() => {
if (leaderApprovalInfo.value.opinion) {
return leaderApprovalInfo.value.opinion
}
return isApplicationDocument.value ? '待直属领导填写审批意见。' : ''
})
const leaderApprovalReadonlyMeta = computed(() => {
const pieces = [
leaderApprovalInfo.value.operator ? `${leaderApprovalInfo.value.operator}确认` : '',
leaderApprovalInfo.value.time
].filter(Boolean)
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
}
return pieces.join(' · ')
})
const showApplicationLeaderOpinion = computed(() => (
isApplicationDocument.value
&& (
showApplicationLeaderOpinionInput.value
|| leaderApprovalReadonlyText.value
)
))
const showLeaderApprovalPanel = computed(() => canApproveRequest.value && !showApplicationLeaderOpinionInput.value)
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) {
@@ -505,7 +544,7 @@ export default {
if (isFinanceApprovalStage.value) {
return '审核通过后将进入归档入账。'
}
return isApplicationDocument.value ? '审批通过后申请流程完成。' : '审批通过后将流转至财务审批。'
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() => {
@@ -513,7 +552,7 @@ export default {
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
}
if (isApplicationDocument.value) {
return '确认后该申请单会完成直属领导审批,请确认申请信息与领导意见无误。'
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。'
}
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
})
@@ -521,14 +560,29 @@ export default {
if (isFinanceApprovalStage.value) {
return '归档入账'
}
return isApplicationDocument.value ? '审批完成' : '财务审批'
return isApplicationDocument.value ? '报销草稿' : '财务审批'
})
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
const approveConfirmTitle = computed(() => (
isApplicationDocument.value ? `确认审核 ${request.value.id} 吗?` : `确认通过 ${request.value.id} 吗?`
))
const approveConfirmText = computed(() => (isApplicationDocument.value ? '确认审核' : '确认通过'))
const approveBusyText = computed(() => (isApplicationDocument.value ? '确认中...' : '通过中...'))
const returnDialogDescription = computed(() => (
isApplicationDocument.value
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
))
const approvalConfirmSummaryLabel = computed(() => (
isApplicationDocument.value ? '生成结果' : '下一节点'
))
const approvalSuccessToast = computed(() => {
if (isFinanceApprovalStage.value) {
return `${request.value.id} 已完成财务终审,进入归档入账。`
}
return isApplicationDocument.value
? `${request.value.id} 申请已审批通过`
? `${request.value.id} 已确认审核,正在生成报销草稿`
: `${request.value.id} 已审批通过,流转至财务审批。`
})
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
@@ -613,13 +667,6 @@ export default {
value: request.value.typeLabel,
icon: '',
valueClass: ''
},
{
key: 'status',
label: '当前状态',
value: request.value.node,
icon: '',
valueClass: 'status'
}
])
@@ -652,6 +699,7 @@ export default {
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
return formatCurrency(total)
})
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseTableColumnCount = computed(
@@ -1582,7 +1630,11 @@ export default {
}
if (!canDeleteRequest.value) {
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
toast(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: '当前单据已进入流程,只有高级管理人员可以删除。'
)
return
}
@@ -1668,6 +1720,11 @@ export default {
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
return
}
approveConfirmDialogOpen.value = true
}
@@ -1692,14 +1749,25 @@ export default {
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
approveConfirmDialogOpen.value = false
return
}
approveBusy.value = true
try {
await approveExpenseClaim(request.value.claimId, {
const responsePayload = await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim()
})
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(approvalSuccessToast.value)
toast(
isApplicationDocument.value && generatedDraftClaimNo
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
: approvalSuccessToast.value
)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
@@ -1736,8 +1804,11 @@ export default {
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
approvalOpinionTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview,
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
applicationDetailFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog,
closeRiskOverrideDialog,
@@ -1756,12 +1827,15 @@ export default {
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
leaderApprovalReadonlyMeta, leaderApprovalReadonlyText,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
showAiAdvicePanel, showApplicationLeaderOpinion, showApplicationLeaderOpinionInput,
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}

View File

@@ -10,8 +10,9 @@ export const RULE_TABLE_COLUMNS = {
export const RISK_RULE_TABLE_COLUMNS = {
...RULE_TABLE_COLUMNS,
owner: '审核人',
metric: '发布者',
updatedAt: '发布时间'
status: '状态',
metric: '创建者',
updatedAt: '创建时间'
}
export const TYPE_META = {
@@ -100,7 +101,7 @@ export const TAB_META = {
tableColumns: RISK_RULE_TABLE_COLUMNS,
showRuntimeColumn: false,
showVersionColumn: false,
showStatusColumn: false,
showStatusColumn: true,
badgeTone: 'rose'
},
skills: {
@@ -121,10 +122,12 @@ export const TAB_META = {
}
export const STATUS_META = {
generating: { label: '生成中', tone: 'info' },
draft: { label: '草稿中', tone: 'draft' },
review: { label: '待审核', tone: 'warning' },
active: { label: '已上线', tone: 'success' },
disabled: { label: '已停用', tone: 'disabled' }
disabled: { label: '已停用', tone: 'disabled' },
failed: { label: '生成失败', tone: 'danger' }
}
export const REVIEW_META = {
@@ -250,10 +253,12 @@ export const DETAIL_TITLES = {
export const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'generating', label: '生成中' },
{ value: 'draft', label: '草稿中' },
{ value: 'review', label: '待审核' },
{ value: 'active', label: '已上线' },
{ value: 'disabled', label: '已停用' }
{ value: 'disabled', label: '已停用' },
{ value: 'failed', label: '生成失败' }
]
export const ONLINE_STATE_OPTIONS = [
@@ -285,6 +290,15 @@ export const RULE_TAB_TAG_ALIASES = {
export const RISK_SCENARIO_OPTIONS = [
{ value: '', label: '全部场景' },
{ value: '差旅费', label: '差旅费' },
{ value: '住宿费', label: '住宿费' },
{ value: '交通费', label: '交通费' },
{ value: '业务招待费', label: '业务招待费' },
{ value: '会务费', label: '会务费' },
{ value: '办公用品费', label: '办公用品费' },
{ value: '培训费', label: '培训费' },
{ value: '通讯费', label: '通讯费' },
{ value: '福利费', label: '福利费' },
{ value: '差旅', label: '差旅' },
{ value: '发票', label: '发票' },
{ value: '餐饮招待', label: '餐饮招待' },

View File

@@ -27,6 +27,10 @@ import {
resolveRiskRuleFields,
resolveRiskRuleFlow,
resolveRiskRuleFlowDiagramSvg,
resolveRiskRuleScore,
resolveRiskRuleScoreDetail,
resolveRiskRuleScoreLabel,
resolveRiskRuleScoreLevel,
resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js'
@@ -327,6 +331,14 @@ export function readScenarioItems(source) {
export function resolveRiskRuleCategory(source) {
const configJson = readConfigJson(source)
const expenseCategoryLabel =
normalizeText(configJson.expense_category_label) ||
normalizeText(configJson.metadata?.expense_category_label) ||
normalizeText(source?.expense_category_label)
if (expenseCategoryLabel) {
return expenseCategoryLabel
}
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
if (explicit) {
return explicit
@@ -442,16 +454,24 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
normalizeText(apiPayload?.description) ||
normalizeText(target.riskRuleDescription)
const riskCategory =
normalizeText(metadata.expense_category_label) ||
normalizeText(apiConfig.expense_category_label) ||
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher = apiPayload?.created_by || target.publisher || (apiPayload?.recent_versions && apiPayload.recent_versions[0]?.created_by) || '系统管理员'
const publisher =
target.creator ||
normalizeText(apiPayload?.owner) ||
normalizeText(metadata.created_by) ||
normalizeText(apiPayload?.recent_versions?.[0]?.created_by) ||
'未知'
let publishedAt = target.publishedAt || '-'
if (apiPayload?.recent_versions) {
@@ -470,15 +490,23 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
riskCategory,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSeverity: resolveRiskRuleSeverity(rulePayload),
riskRuleSeverityLabel: resolveRiskRuleSeverityLabel(rulePayload),
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleSeverityLabel: riskRuleScoreLevel
? resolveRiskRuleScoreLabel(rulePayload, apiConfig)
: resolveRiskRuleSeverityLabel(rulePayload),
riskRuleScore: resolveRiskRuleScore(rulePayload, apiConfig),
riskRuleScoreLabel: resolveRiskRuleScoreLabel(rulePayload, apiConfig),
riskRuleScoreLevel: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleScoreDetail: resolveRiskRuleScoreDetail(rulePayload, apiConfig),
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
riskRuleFields,
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
riskRuleFlowDiagramSvg:
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
riskRuleFlowDiagramSvg: resolveRiskRuleFlowDiagramSvg({
...rulePayload,
flow_diagram_svg: normalizeText(apiPayload?.flow_diagram_svg) || rulePayload?.flow_diagram_svg
}),
riskRuleRequiresAttachment: Boolean(
rulePayload.requires_attachment ||
metadata.requires_attachment ||
@@ -860,12 +888,13 @@ export function buildListItem(asset) {
const isOnlineValue = asset.status === 'active'
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const publisher = isRiskRule
? isOnlineValue
? normalizeText(asset.published_by) || reviewer || modifiedBy || '系统管理员'
: '-'
: ''
const publishedAt = isRiskRule && isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-'
const creator =
normalizeText(asset.owner) ||
normalizeText(asset.config_json?.generation_request?.actor) ||
modifiedBy ||
'未知'
const publisher = isRiskRule ? creator : ''
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
return {
id: asset.id,
@@ -895,8 +924,9 @@ export function buildListItem(asset) {
statusValue: asset.status,
statusTone: statusMeta.tone,
hitRate: isRiskRule ? publisher : buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
creator,
publisher,
publishedAt,
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
isOnlineValue,
isOnlineLabel: isOnlineValue ? '是' : '否',
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
@@ -905,7 +935,7 @@ export function buildListItem(asset) {
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
modifiedBy,
changeCount,
updatedAt: isRiskRule ? publishedAt : formatDateTime(asset.updated_at),
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
domainValue: asset.domain
}
@@ -1283,6 +1313,13 @@ export function buildDetailViewModel(detail, runs) {
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(detail) : true
const generationStatus = normalizeText(configJson.generation_status || detail.status)
const riskRuleGenerationFailed = usesJsonRiskRule && (detail.status === 'failed' || generationStatus === 'failed')
const riskRuleGenerationBusy = usesJsonRiskRule && (detail.status === 'generating' || generationStatus === 'generating')
const riskRuleCreator =
normalizeText(detail.owner) ||
normalizeText(detail.recent_versions?.[0]?.created_by) ||
'未知'
return {
id: detail.id,
@@ -1321,6 +1358,10 @@ export function buildDetailViewModel(detail, runs) {
riskRuleSourceRef: '',
riskRuleSeverity: 'medium',
riskRuleSeverityLabel: '中风险',
riskRuleScore: null,
riskRuleScoreLabel: '待计算',
riskRuleScoreLevel: 'medium',
riskRuleScoreDetail: null,
riskRuleCreatedAt: formatDateTime(detail.created_at),
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
isOnlineLabel: detail.status === 'active' ? '是' : '否',
@@ -1334,7 +1375,8 @@ export function buildDetailViewModel(detail, runs) {
detail.reviewer ||
(detail.recent_versions && detail.recent_versions[0]?.created_by) ||
'系统管理员'
: '-',
: riskRuleCreator,
creator: riskRuleCreator,
publishedAt:
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
@@ -1344,6 +1386,10 @@ export function buildDetailViewModel(detail, runs) {
riskRuleFlow: resolveRiskRuleFlow({}, []),
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
riskRuleRequiresAttachment: Boolean(configJson.requires_attachment),
riskRuleGenerationStatus: generationStatus,
riskRuleGenerationFailed,
riskRuleGenerationBusy,
riskRuleGenerationError: normalizeText(configJson.generation_error),
latestTestSummary: detail.latest_test_summary || detail.latestTestSummary || null,
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
ruleDocument,

View File

@@ -17,22 +17,40 @@ export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
]
export const RISK_RULE_LEVEL_OPTIONS = [
{ value: 'low', label: '低风险' },
{ value: 'medium', label: '中风险' },
{ value: 'high', label: '高风险' },
{ value: 'low', label: '风险' }
{ value: 'critical', label: '极高风险' }
]
const RISK_LEVEL_LABELS = {
low: '低风险',
medium: '中风险',
high: '高风险'
high: '高风险',
critical: '极高风险'
}
const RISK_SCORE_LEVEL_LABELS = RISK_LEVEL_LABELS
const CITY_ROUTE_CONDITION_SUMMARY =
'判断公式A=交通票行程城市住宿发票城市B=申报目的地明细发生地点C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。'
const CITY_ROUTE_FLOW_DECISION =
'附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市'
const CITY_ROUTE_FLOW_EVIDENCE =
'读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由'
const CITY_ROUTE_SEMANTIC_TYPES = new Set([
'travel_city_consistency',
'travel_route_city_consistency'
])
export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
expense_category: 'travel',
risk_level: 'medium',
rule_title: '',
requires_attachment: false,
natural_language: ''
}
@@ -52,16 +70,66 @@ export function formatRiskRuleFieldDisplay(field) {
}
export function resolveRiskRuleSeverity(payload) {
const scoreLevel = resolveRiskRuleScoreLevel(payload)
if (scoreLevel) {
return scoreLevel
}
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
const severity = normalizeRiskRuleText(fail.severity || payload?.severity).toLowerCase()
return ['low', 'medium', 'high'].includes(severity) ? severity : 'medium'
return Object.prototype.hasOwnProperty.call(RISK_LEVEL_LABELS, severity) ? severity : 'medium'
}
export function resolveRiskRuleSeverityLabel(payload) {
return RISK_LEVEL_LABELS[resolveRiskRuleSeverity(payload)] || '中风险'
}
export function resolveRiskRuleScore(payload, fallbackConfig = {}) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
const candidates = [
metadata.risk_score,
payload?.risk_score,
fail.risk_score,
fallbackConfig?.risk_score,
fallbackConfig?.riskScore
]
for (const value of candidates) {
const score = Number(value)
if (Number.isFinite(score)) {
return Math.max(0, Math.min(100, Math.round(score)))
}
}
return null
}
export function resolveRiskRuleScoreLevel(payload, fallbackConfig = {}) {
const score = resolveRiskRuleScore(payload, fallbackConfig)
if (score === null) {
return ''
}
if (score <= 30) return 'low'
if (score <= 60) return 'medium'
if (score <= 80) return 'high'
return 'critical'
}
export function resolveRiskRuleScoreLabel(payload, fallbackConfig = {}) {
const level = resolveRiskRuleScoreLevel(payload, fallbackConfig)
return level ? RISK_SCORE_LEVEL_LABELS[level] : '待计算'
}
export function resolveRiskRuleScoreDetail(payload, fallbackConfig = {}) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const detail =
metadata.risk_score_detail ||
payload?.risk_score_detail ||
fallbackConfig?.risk_score_detail ||
fallbackConfig?.riskScoreDetail
return detail && typeof detail === 'object' ? detail : null
}
export function resolveRiskRuleFields(payload) {
const inputs = payload && typeof payload === 'object' ? payload.inputs || {} : {}
const fieldRows = Array.isArray(inputs.fields) ? inputs.fields : []
@@ -146,15 +214,24 @@ export function resolveRiskRuleBusinessDescription(payload, fallback) {
export function resolveRiskRuleFlowDiagramSvg(payload) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
return (
const svg =
normalizeRiskRuleText(payload?.flow_diagram_svg) ||
normalizeRiskRuleText(metadata.flow_diagram_svg)
)
if (svg && !svg.includes('data-risk-flow-detail="logic-v2"')) {
return ''
}
if (isCityRouteConsistencyPayload(payload) && svg.includes('风险关键词')) {
return ''
}
return svg
}
export function resolveRiskRuleConditionSummary(payload) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const params = payload && typeof payload === 'object' ? payload.params || {} : {}
if (isCityRouteConsistencyPayload(payload)) {
return CITY_ROUTE_CONDITION_SUMMARY
}
return (
normalizeRiskRuleText(metadata.condition_summary) ||
normalizeRiskRuleText(params.condition_summary) ||
@@ -168,13 +245,220 @@ export function resolveRiskRuleFlow(payload, fields) {
const fieldSummary = buildRiskRuleFieldSummary(fields)
const conditionSummary = resolveRiskRuleConditionSummary(payload)
const severityLabel = resolveRiskRuleSeverityLabel(payload)
const isCityRouteRule = isCityRouteConsistencyPayload(payload)
return {
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
evidence: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
decision: normalizeRiskRuleText(flow.decision) || conditionSummary,
evidence: isCityRouteRule
? CITY_ROUTE_FLOW_EVIDENCE
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
decision: isCityRouteRule
? CITY_ROUTE_FLOW_DECISION
: normalizeRiskRuleText(flow.decision) || conditionSummary,
basis: conditionSummary,
...resolveRiskRuleFlowDetails(payload, fields),
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
}
}
function resolveRiskRuleFlowDetails(payload, fields) {
const params = payload && typeof payload === 'object' && payload.params && typeof payload.params === 'object'
? payload.params
: {}
const ruleIr = params.rule_ir && typeof params.rule_ir === 'object' ? params.rule_ir : {}
const facts = Array.isArray(ruleIr.facts) ? buildFactLines(ruleIr.facts, fields) : buildFieldFactLines(fields)
const conditions = buildConditionLines(params, fields)
const hitLogic = formatHitLogic(params.hit_logic) || normalizeRiskRuleText(params.formula)
return {
facts,
conditions,
hitLogic
}
}
function buildFactLines(facts, fields) {
const labelByKey = buildLabelByKey(fields)
const rows = facts
.slice(0, 4)
.map((fact) => {
const id = normalizeRiskRuleText(fact?.id)
const label = normalizeRiskRuleText(fact?.label || id || '事实')
const fieldKeys = readStringList(fact?.fields)
const fieldText = fieldKeys.slice(0, 3).map((key) => labelByKey[key] || key).join('')
return `${id ? `${id}=` : ''}${label}: ${fieldText || '规则字段'}`
})
.filter(Boolean)
return rows.length ? rows : buildFieldFactLines(fields)
}
function buildFieldFactLines(fields) {
return (Array.isArray(fields) ? fields : [])
.slice(0, 4)
.map((field, index) => `${String.fromCharCode(65 + index)}=${formatRiskRuleFieldDisplay(field)}`)
.filter(Boolean)
}
function buildConditionLines(params, fields) {
const labelByKey = buildLabelByKey(fields)
const conditions = Array.isArray(params.conditions) ? params.conditions : []
const rows = conditions
.slice(0, 4)
.map((condition, index) => formatConditionLine(condition, labelByKey, index + 1))
.filter(Boolean)
if (rows.length) {
return rows
}
return normalizeRiskRuleText(params.condition_summary) ? [normalizeRiskRuleText(params.condition_summary)] : []
}
function formatConditionLine(condition, labelByKey, index) {
const operator = normalizeRiskRuleText(condition?.operator)
const id = normalizeRiskRuleText(condition?.id || `C${index}`)
const prefix = `${id}: `
if (['not_in_scope', 'not_in_set', 'not_overlap'].includes(operator)) {
return `${prefix}${formatFieldGroup(condition?.left_fields, labelByKey)}${formatFieldGroup(condition?.right_fields, labelByKey)} = ∅`
}
if (['in_scope', 'overlap'].includes(operator)) {
return `${prefix}${formatFieldGroup(condition?.left_fields, labelByKey)}${formatFieldGroup(condition?.right_fields, labelByKey)} ≠ ∅`
}
if (operator === 'date_outside_range') {
return `${prefix}${formatFieldGroup(condition?.date_fields, labelByKey)} 不在 [${formatFieldGroup(condition?.range_start_fields, labelByKey)}, ${formatFieldGroup(condition?.range_end_fields, labelByKey)}]`
}
if (['contains_any', 'not_contains_any'].includes(operator)) {
const verb = operator === 'not_contains_any' ? '不含' : '包含'
const keywords = readStringList(condition?.keywords).slice(0, 4).join('、') || '关键词'
return `${prefix}${formatFieldGroup(condition?.fields, labelByKey)} ${verb} ${keywords}`
}
if (['exists_any', 'exists_all', 'all_present'].includes(operator)) {
const verb = operator === 'exists_any' ? '任一有值' : '全部有值'
return `${prefix}${formatFieldGroup(condition?.fields, labelByKey)} ${verb}`
}
const left = normalizeRiskRuleText(condition?.left)
const right = normalizeRiskRuleText(condition?.right)
if (left || right) {
return `${prefix}${labelByKey[left] || left} ${operator || 'compare'} ${labelByKey[right] || right}`
}
return `${prefix}${operator || '规则条件'}`
}
function formatFieldGroup(value, labelByKey) {
const keys = readStringList(value)
if (!keys.length) {
return '字段集合'
}
return keys.slice(0, 3).map((key) => labelByKey[key] || key).join('')
}
function formatHitLogic(value) {
if (typeof value === 'string') {
return normalizeRiskRuleText(value)
}
if (Array.isArray(value)) {
return value.map(formatHitLogic).filter(Boolean).join(' AND ')
}
if (!value || typeof value !== 'object') {
return ''
}
if (Array.isArray(value.all)) {
return value.all.map(wrapLogicPart).filter(Boolean).join(' AND ')
}
if (Array.isArray(value.any)) {
return value.any.map(wrapLogicPart).filter(Boolean).join(' OR ')
}
if (Object.prototype.hasOwnProperty.call(value, 'not')) {
const text = wrapLogicPart(value.not)
return text ? `NOT ${text}` : ''
}
return ''
}
function wrapLogicPart(value) {
const text = formatHitLogic(value)
return value && typeof value === 'object' && !Array.isArray(value) && text ? `(${text})` : text
}
function buildLabelByKey(fields) {
const map = {}
;(Array.isArray(fields) ? fields : []).forEach((field) => {
const key = normalizeRiskRuleText(field?.key)
if (key) {
map[key] = normalizeRiskRuleText(field?.label || key)
}
})
return map
}
function readStringList(value) {
return Array.isArray(value) ? value.map(normalizeRiskRuleText).filter(Boolean) : []
}
function isCityRouteConsistencyPayload(payload) {
if (!payload || typeof payload !== 'object') {
return false
}
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
const params = payload.params && typeof payload.params === 'object' ? payload.params : {}
const semanticType = normalizeRiskRuleText(payload.semantic_type || params.semantic_type)
if (CITY_ROUTE_SEMANTIC_TYPES.has(semanticType)) {
return true
}
const text = [
metadata.natural_language,
params.natural_language,
payload.description,
metadata.condition_summary,
params.condition_summary
]
.map(normalizeRiskRuleText)
.join('\n')
if (looksLikeCityRouteRuleText(text)) {
return true
}
const fieldKeys = new Set(resolveRiskRuleFieldKeys(payload))
const hasAttachmentCity =
fieldKeys.has('attachment.route_cities') || fieldKeys.has('attachment.hotel_city')
const hasReferenceCity = fieldKeys.has('claim.location') || fieldKeys.has('item.item_location')
return hasAttachmentCity && hasReferenceCity && text.includes('风险关键词')
}
function resolveRiskRuleFieldKeys(payload) {
const keys = []
const inputs = payload.inputs && typeof payload.inputs === 'object' ? payload.inputs : {}
if (Array.isArray(inputs.fields)) {
inputs.fields.forEach((item) => {
const key = normalizeRiskRuleText(item?.key)
if (key) keys.push(key)
})
}
;[payload.field_keys, payload.params?.field_keys, payload.params?.search_fields].forEach((rows) => {
if (!Array.isArray(rows)) return
rows.forEach((item) => {
const key = normalizeRiskRuleText(item)
if (key) keys.push(key)
})
})
return [...new Set(keys)]
}
function looksLikeCityRouteRuleText(text) {
const normalized = normalizeRiskRuleText(text)
if (!normalized) {
return false
}
const hasCitySubject = ['交通票', '住宿票', '住宿发票', '票据', '附件', '行程城市', '住宿城市'].some(
(term) => normalized.includes(term)
)
const hasReference = ['申报目的地', '申报地点', '明细地点', '发生地点', '意图城市', '目的地'].some(
(term) => normalized.includes(term)
)
const hasRelation = ['一致', '不一致', '形成一致关系', '匹配', '无法与', '对应'].some((term) =>
normalized.includes(term)
)
const hasRouteAnomaly = ['绕行', '跨城', '中转', '周转', '改签'].some((term) =>
normalized.includes(term)
)
return hasCitySubject && hasReference && (hasRelation || hasRouteAnomaly)
}

View File

@@ -4,6 +4,7 @@ import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './t
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
import {
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_START_REIMBURSEMENT,
GUIDED_ACTION_START_STATUS_QUERY
} from './travelReimbursementGuidedFlowModel.js'
@@ -163,7 +164,7 @@ export const EXPENSE_WELCOME_QUICK_ACTIONS = [
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
{
label: '快速发起申请',
prompt: '我想快速发起一笔费用申请,请先帮我判断申请类型并引导补充信息。',
action: GUIDED_ACTION_START_APPLICATION,
icon: 'mdi mdi-file-plus-outline'
},
{
@@ -252,6 +253,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
reviewPanelScope: '',
riskFlags: [],
pendingAttachmentAssociation: null,
applicationPreview: null,
...extras
}
}
@@ -801,6 +803,7 @@ export function serializeSessionMessages(messages) {
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []

View File

@@ -4,7 +4,7 @@ import {
} from './travelReimbursementReviewModel.js'
export const EXPENSE_QUERY_PAGE_SIZE = 5
export const EXPENSE_CENTER_HREF = '/app/requests'
export const EXPENSE_CENTER_HREF = '/app/documents'
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
const EXPENSE_STATUS_LABELS = {
draft: '草稿',
@@ -282,7 +282,7 @@ export function buildExpenseQueryHint(queryPayload) {
if (queryPayload.selectionLocked && queryPayload.selectedClaimId) {
return '已选择关联草稿,附件将按该单据继续识别和归集。'
}
return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。'
return '如果这些都不是本次要关联的单据,可以补充单号或先到单据中心新建草稿。'
}
const parts = []
@@ -290,9 +290,9 @@ export function buildExpenseQueryHint(queryPayload) {
const totalCount = Math.max(0, Number(queryPayload.recordCount || 0))
if (totalCount > previewLimit) {
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到报销中心查看。`)
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到单据中心查看。`)
} else if (totalCount > 0) {
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入报销中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入单据中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
}
return parts.join('。')

View File

@@ -3,6 +3,7 @@ export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
export const GUIDED_ACTION_START_REIMBURSEMENT = 'start_guided_reimbursement'
export const GUIDED_ACTION_START_APPLICATION = 'start_guided_application'
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
@@ -445,7 +446,7 @@ export function resolveGuidedQueryModeFromText(text) {
if (!normalized) return ''
const exact = GUIDED_QUERY_MODES.find((item) => normalized === item.label || normalized === item.key)
if (exact) return exact.key
if (/单号|编号|EXP-/i.test(normalized)) return 'claim_no'
if (/单号|编号|EXP-|APP-|AP-|RE-|AD-/i.test(normalized)) return 'claim_no'
if (/状态|草稿|审批|退回|归档|完成/.test(normalized)) return 'status'
if (/上周|本周|去年|今年|月份|时间|日期|[0-9]{4}-[0-9]{2}/.test(normalized)) return 'time_range'
return 'keyword'
@@ -484,7 +485,7 @@ export function buildGuidedQueryPromptText(state) {
].join('\n')
}
const prompts = {
claim_no: '请输入报销单号,例如 EXP-202605-001。',
claim_no: '请输入单据编号,例如 RE-20260525103045-ABCDEFGH。',
time_range: '请输入查询时间范围,例如:上周、今年 5 月、2025 年全年。',
keyword: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
}

View File

@@ -69,6 +69,7 @@ export function isApplicationDocumentRequest(request) {
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')

View File

@@ -0,0 +1,105 @@
import { ref } from 'vue'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
buildApplicationPreviewRows,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
const applicationPreviewEditor = ref({
messageId: '',
fieldKey: '',
draftValue: ''
})
function resolveApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
function resolveApplicationPreviewEditorControl(fieldKey) {
return fieldKey === 'transportMode' ? 'select' : 'text'
}
function resolveApplicationPreviewEditorOptions(fieldKey) {
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
}
function isApplicationPreviewEditing(message, fieldKey) {
return (
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
applicationPreviewEditor.value.fieldKey === fieldKey
)
}
function openApplicationPreviewEditor(message, fieldKey, value) {
if (!message?.applicationPreview || !fieldKey) return
const targetRow = buildApplicationPreviewRows(message.applicationPreview)
.find((row) => row.key === fieldKey)
if (targetRow && targetRow.editable === false) return
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
applicationPreviewEditor.value = {
messageId: String(message.id || ''),
fieldKey,
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue
}
}
function cancelApplicationPreviewEditor() {
applicationPreviewEditor.value = {
messageId: '',
fieldKey: '',
draftValue: ''
}
}
function commitApplicationPreviewEditor(message) {
const editor = applicationPreviewEditor.value
if (!message?.applicationPreview || String(editor.messageId || '') !== String(message.id || '') || !editor.fieldKey) {
cancelApplicationPreviewEditor()
return false
}
const nextValue = String(editor.draftValue || '').trim()
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: {
...(message.applicationPreview.fields || {}),
[editor.fieldKey]: nextValue
}
})
message.applicationPreview = nextPreview
message.text = buildLocalApplicationPreviewMessage(nextPreview)
cancelApplicationPreviewEditor()
persistSessionState?.()
toast?.('已更新核对表内容。')
return true
}
function handleApplicationPreviewEditorKeydown(event, message) {
if (event.key === 'Enter') {
event.preventDefault()
commitApplicationPreviewEditor(message)
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelApplicationPreviewEditor()
}
}
return {
applicationPreviewEditor,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
cancelApplicationPreviewEditor,
handleApplicationPreviewEditorKeydown
}
}

View File

@@ -1,6 +1,11 @@
import { ref } from 'vue'
import {
buildApplicationTemplatePreview,
buildLocalApplicationPreviewMessage
} from '../../utils/expenseApplicationPreview.js'
import {
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
@@ -84,6 +89,7 @@ export function useTravelReimbursementGuidedFlow({
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer,
currentUser,
toast
}) {
const guidedPendingFiles = ref([])
@@ -134,6 +140,16 @@ export function useTravelReimbursementGuidedFlow({
persistAndScroll()
}
function startGuidedApplicationTemplate() {
resetGuidedFlowState()
const applicationPreview = buildApplicationTemplatePreview(currentUser?.value || {})
pushAssistant(buildLocalApplicationPreviewMessage(applicationPreview), {
meta: ['申请模板'],
applicationPreview
})
persistAndScroll()
}
function startGuidedStatusQuery() {
guidedFlowState.value = createGuidedStatusQueryState()
guidedPendingFiles.value = []
@@ -146,6 +162,10 @@ export function useTravelReimbursementGuidedFlow({
function handleGuidedShortcut(shortcut) {
const actionType = normalizeText(shortcut?.action)
if (actionType === GUIDED_ACTION_START_APPLICATION) {
startGuidedApplicationTemplate()
return true
}
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
startGuidedReimbursement()
return true

View File

@@ -4,6 +4,18 @@ import {
buildUnsavedDraftAttachmentConfirmationMessage
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import {
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
@@ -46,6 +58,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
fetchExpenseClaims,
fileInputRef,
flowRunId,
insightPanelCollapsed,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
@@ -281,6 +294,73 @@ export function useTravelReimbursementSubmitComposer(ctx) {
).trim()
}
function buildApplicationPreviewReviewMeta(ontology) {
return [
'申请核对预览',
String(ontology?.parse_strategy || '').trim() === 'llm_primary'
? '模型复核完成'
: '规则兜底复核'
]
}
async function buildApplicationPreviewWithModelReview(rawText) {
const user = currentUser.value || {}
const localPreview = buildLocalApplicationPreview(rawText, user)
const enrichWithPolicyEstimate = async (preview) => {
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
if (!estimateRequest.canCalculate) {
return preview
}
try {
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
console.warn('Application policy estimate failed:', error)
return applyApplicationPolicyEstimateError(preview, error, user)
}
}
try {
const ontology = await fetchOntologyParse(
{
query: rawText,
user_id: user.username || user.name || 'anonymous',
context_json: {
...buildExpenseApplicationOntologyContext(user),
session_type: activeSessionType.value,
entry_source: props.entrySource,
user_input_text: rawText
}
},
{
timeoutMs: 45000,
timeoutMessage: '模型抽取申请字段超时,已保留当前本地预览。'
}
)
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
ontology,
rawText,
user
)
return {
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
meta: buildApplicationPreviewReviewMeta(ontology)
}
} catch (error) {
console.warn('Application preview model refinement failed:', error)
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'failed'
}),
meta: ['申请核对预览', '模型复核失败']
}
}
}
async function submitComposer(options = {}) {
if (submitting.value || sessionSwitchBusy.value) return null
@@ -388,6 +468,84 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return null
}
if (shouldUseLocalApplicationPreview(rawText, {
sessionType: activeSessionType.value,
attachmentCount: files.length,
reviewAction,
systemGenerated
})) {
const intentStartedAt = Date.now()
const reviewStartedAt = intentStartedAt
resetFlowRun()
startFlowStep('intent', {
title: '业务意图识别',
tool: 'ontology.intent_detection',
detail: '正在识别是否为费用申请事项...'
})
startFlowStep('application-review-preview', {
title: '申请信息核对',
tool: 'ontology.application_review',
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 = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
submitting.value = true
try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText)
const reviewStatus = String(meta?.[1] || '').trim()
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
reviewStatus === '模型复核完成'
? '模型复核完成,已生成申请核对表'
: reviewStatus === '模型复核失败'
? '模型复核失败,已生成临时核对表'
: '模型未返回稳定结果,已完成规则兜底核对',
Date.now() - reviewStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildLocalApplicationPreviewMessage(applicationPreview),
[],
{
meta,
applicationPreview
}
))
if (insightPanelCollapsed) {
insightPanelCollapsed.value = true
}
persistSessionState()
nextTick(scrollToBottom)
} finally {
submitting.value = false
}
return null
}
const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value &&
files.length &&