feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -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
160
web/src/views/BudgetCenterView.vue
Normal file
160
web/src/views/BudgetCenterView.vue
Normal 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>
|
||||
@@ -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 = []
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
216
web/src/views/scripts/BudgetCenterView.js
Normal file
216
web/src/views/scripts/BudgetCenterView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const FALLBACK_ROLE_OPTIONS = [
|
||||
id: 'approver',
|
||||
code: 'approver',
|
||||
label: '审批负责人',
|
||||
desc: '可以处理审批中心中的待审单据。'
|
||||
desc: '可以处理单据中心中的待审单据。'
|
||||
},
|
||||
{
|
||||
id: 'executive',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '餐饮招待' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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无交集且无合理说明,或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 : []
|
||||
|
||||
@@ -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('。')
|
||||
|
||||
@@ -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: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
105
web/src/views/scripts/useApplicationPreviewEditor.js
Normal file
105
web/src/views/scripts/useApplicationPreviewEditor.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user