feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
'workbench-main': activeView === 'workbench',
|
||||
'requests-main': activeView === 'requests',
|
||||
'approval-main': activeView === 'approval',
|
||||
'archive-main': activeView === 'archive',
|
||||
'policies-main': activeView === 'policies',
|
||||
'audit-main': activeView === 'audit',
|
||||
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
|
||||
@@ -49,7 +50,7 @@
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
:compact="activeView === 'overview'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
@@ -62,6 +63,7 @@
|
||||
:class="{
|
||||
'requests-workarea': activeView === 'requests',
|
||||
'approval-workarea': activeView === 'approval',
|
||||
'archive-workarea': activeView === 'archive',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit',
|
||||
'logs-workarea': activeView === 'logs',
|
||||
@@ -105,6 +107,7 @@
|
||||
/>
|
||||
|
||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||
<ArchiveCenterView v-else-if="activeView === 'archive'" />
|
||||
<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" />
|
||||
@@ -122,6 +125,7 @@
|
||||
:initial-conversation="smartEntryContext.conversation"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
/>
|
||||
@@ -140,6 +144,7 @@ import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
import RequestsView from './RequestsView.vue'
|
||||
import ApprovalCenterView from './ApprovalCenterView.vue'
|
||||
import ArchiveCenterView from './ArchiveCenterView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import AuditView from './AuditView.vue'
|
||||
import LogsView from './LogsView.vue'
|
||||
@@ -187,6 +192,7 @@ const {
|
||||
search,
|
||||
selectedRequest,
|
||||
smartEntryContext,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
toast,
|
||||
|
||||
141
web/src/views/ArchiveCenterView.vue
Normal file
141
web/src/views/ArchiveCenterView.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<section class="approval-page archive-page">
|
||||
<TravelRequestDetailView
|
||||
v-if="selectedRow"
|
||||
:request="selectedRow"
|
||||
back-label="返回归档列表"
|
||||
@back-to-requests="closeSelectedDetail"
|
||||
@request-updated="reload"
|
||||
@request-deleted="reload"
|
||||
/>
|
||||
|
||||
<article v-else class="approval-list panel">
|
||||
<nav class="status-tabs" aria-label="归档分类">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab"
|
||||
type="button"
|
||||
:class="{ active: activeTab === tab }"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="filter-set">
|
||||
<div class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="dropdown in filterDropdowns"
|
||||
:key="dropdown.key"
|
||||
class="archive-dropdown-filter"
|
||||
:class="{ open: openFilterKey === dropdown.key }"
|
||||
>
|
||||
<button class="filter-btn" type="button" @click="toggleFilterDropdown(dropdown.key)">
|
||||
<span>{{ dropdown.label }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="openFilterKey === dropdown.key"
|
||||
class="archive-dropdown-menu"
|
||||
role="menu"
|
||||
:aria-label="`${dropdown.label}筛选`"
|
||||
>
|
||||
<button
|
||||
v-for="option in dropdown.options"
|
||||
:key="`${dropdown.key}-${option.value}`"
|
||||
type="button"
|
||||
class="archive-dropdown-option"
|
||||
:class="{ active: dropdown.activeValue === option.value }"
|
||||
role="menuitem"
|
||||
@click="selectFilterValue(dropdown.key, option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已归档入账的报销数据,点击单据行查看详情</p>
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<TableLoadingState
|
||||
title="归档数据同步中"
|
||||
message="正在加载公司已归档的报销单据"
|
||||
icon="mdi mdi-archive-check-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="table-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<strong>归档列表加载失败</strong>
|
||||
<p>{{ error }}</p>
|
||||
<button class="state-action" type="button" @click="reload">重新加载</button>
|
||||
</div>
|
||||
|
||||
<TableEmptyState
|
||||
v-else-if="showEmpty"
|
||||
:eyebrow="archiveEmptyState.eyebrow"
|
||||
:title="archiveEmptyState.title"
|
||||
:description="archiveEmptyState.desc"
|
||||
:icon="archiveEmptyState.icon"
|
||||
:action-label="archiveEmptyState.actionLabel"
|
||||
:action-icon="archiveEmptyState.actionIcon"
|
||||
:tone="archiveEmptyState.tone"
|
||||
:art-label="archiveEmptyState.artLabel"
|
||||
:tips="archiveEmptyState.tips"
|
||||
@action="handleEmptyAction"
|
||||
/>
|
||||
|
||||
<table v-else>
|
||||
<colgroup>
|
||||
<col><col><col><col><col><col><col><col><col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>单号</th>
|
||||
<th>申请人</th>
|
||||
<th>申请部门</th>
|
||||
<th>报销类型</th>
|
||||
<th>金额</th>
|
||||
<th>提交时间 <i class="mdi mdi-sort"></i></th>
|
||||
<th>归档节点</th>
|
||||
<th>风险</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in visibleRows" :key="row.id" @click="selectedRow = row">
|
||||
<td><strong class="doc-id">{{ row.id }}</strong></td>
|
||||
<td>
|
||||
<span class="person">
|
||||
<span class="avatar">{{ row.avatar }}</span>
|
||||
{{ row.applicant }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ row.type }}</td>
|
||||
<td>{{ row.amount }}</td>
|
||||
<td>{{ row.time }}</td>
|
||||
<td>{{ row.node }}</td>
|
||||
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
|
||||
<td><span class="status-tag archived">{{ row.status }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/ArchiveCenterView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/approval-center-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/approval-center-view-part2.css"></style>
|
||||
<style scoped src="../assets/styles/views/archive-center-view.css"></style>
|
||||
@@ -24,7 +24,7 @@
|
||||
:class="{ active: activeFolder === folder.name }"
|
||||
@click="activeFolder = folder.name"
|
||||
>
|
||||
<i :class="folder.icon"></i>
|
||||
<i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
|
||||
<span>{{ folder.name }}</span>
|
||||
<b>{{ folder.count }}</b>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="assistant-modal" @after-leave="emitCloseAfterLeave">
|
||||
<Transition name="assistant-modal" @after-enter="handleAssistantModalAfterEnter" @after-leave="emitCloseAfterLeave">
|
||||
<div v-if="workbenchVisible" class="assistant-overlay">
|
||||
<section class="assistant-modal">
|
||||
<div class="assistant-header-actions">
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
|
||||
<div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
|
||||
<span
|
||||
v-for="item in message.meta"
|
||||
:key="item"
|
||||
@@ -139,7 +139,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length"
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
|
||||
class="message-suggested-actions"
|
||||
>
|
||||
<button
|
||||
@@ -173,7 +173,7 @@
|
||||
</div>
|
||||
|
||||
<details
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length"
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.citations?.length"
|
||||
class="message-detail-block message-citation-disclosure"
|
||||
>
|
||||
<summary>
|
||||
@@ -197,7 +197,7 @@
|
||||
class="message-detail-block expense-query-block"
|
||||
>
|
||||
<strong>
|
||||
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : (message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细')) }}
|
||||
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : '最近 5 条筛选结果') }}
|
||||
</strong>
|
||||
|
||||
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
|
||||
@@ -242,6 +242,20 @@
|
||||
<span>{{ record.dateDisplay }}</span>
|
||||
<span>{{ record.amountDisplay }}</span>
|
||||
</div>
|
||||
<div v-if="record.riskItems?.length" class="expense-query-risk-row">
|
||||
<button
|
||||
v-for="risk in record.riskItems"
|
||||
:key="`${message.id}-${record.claimId}-${risk.key}`"
|
||||
type="button"
|
||||
class="expense-query-risk-chip"
|
||||
:class="risk.level"
|
||||
@click.stop="appendExpenseQueryRiskToConversation(record, risk)"
|
||||
>
|
||||
<span>{{ record.claimNo }}</span>
|
||||
<strong>{{ risk.levelLabel }}</strong>
|
||||
<em>{{ risk.title }}</em>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
@@ -289,15 +303,19 @@
|
||||
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint">
|
||||
{{ buildExpenseQueryHint(message.queryPayload) }}
|
||||
<p
|
||||
v-if="buildExpenseQueryHint(message.queryPayload)"
|
||||
class="expense-query-hint message-answer-markdown"
|
||||
v-html="renderMarkdown(buildExpenseQueryHint(message.queryPayload))"
|
||||
@click="handleAssistantMarkdownClick($event, message)"
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
|
||||
<div class="review-plain-followup">
|
||||
<template
|
||||
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
|
||||
v-for="followup in [buildReviewPlainFollowupForMessage(message)]"
|
||||
:key="`${message.id}-review-followup`"
|
||||
>
|
||||
<h3
|
||||
@@ -684,7 +702,7 @@
|
||||
|
||||
<div v-if="activeReviewPayload || isReviewFlowDrawer" class="review-insight-tools">
|
||||
<button
|
||||
v-if="activeReviewPayload"
|
||||
v-if="activeReviewPayload && reviewOverviewDrawerAvailable"
|
||||
type="button"
|
||||
class="review-insight-switch-icon-btn"
|
||||
:class="{
|
||||
@@ -836,7 +854,7 @@
|
||||
|
||||
<template v-else-if="currentInsight.intent === 'agent' && currentInsight.agent">
|
||||
<template v-if="activeReviewPayload">
|
||||
<template v-if="!isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
|
||||
<template v-if="reviewOverviewDrawerAvailable && !isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
|
||||
<section class="review-side-card review-side-overview-card">
|
||||
<div class="review-side-intent-row">
|
||||
<i class="mdi mdi-account-outline"></i>
|
||||
@@ -1221,7 +1239,7 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<section v-if="currentInsight.agent.citations?.length && !activeReviewPayload" class="insight-card">
|
||||
<section v-if="currentInsight.agent.citations?.length && !currentInsight.agent.queryPayload && !activeReviewPayload" class="insight-card">
|
||||
<div class="card-head">
|
||||
<h4>制度依据</h4>
|
||||
</div>
|
||||
@@ -1284,30 +1302,6 @@
|
||||
@confirm="confirmDeleteCurrentSession"
|
||||
/>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-confirm-modal review-upload-decision-modal">
|
||||
<div class="review-upload-decision-copy">
|
||||
<span class="assistant-badge">上传票据</span>
|
||||
<h3>检测到你已有单据事件</h3>
|
||||
<p>这次新上传的附件需要先确认处理方式。你可以继续归集到上一笔单据,也可以重新开启一张新单据。</p>
|
||||
</div>
|
||||
|
||||
<div class="review-confirm-actions review-upload-decision-actions">
|
||||
<button type="button" class="primary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="continueExistingUpload">
|
||||
继续
|
||||
</button>
|
||||
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="createNewUploadDocument">
|
||||
新单据
|
||||
</button>
|
||||
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="closeUploadDecisionDialog">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
|
||||
<section class="review-preview-modal">
|
||||
|
||||
@@ -97,20 +97,11 @@
|
||||
</div>
|
||||
<div v-if="canEditDetailNote" class="detail-note-editor">
|
||||
<textarea
|
||||
v-model="detailNoteEditor"
|
||||
v-model="detailNoteEditorView"
|
||||
maxlength="500"
|
||||
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
|
||||
aria-label="附加说明"
|
||||
></textarea>
|
||||
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
|
||||
<span
|
||||
v-for="tag in detailNoteTags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-note-editor-meta">
|
||||
<span>仅草稿待提交状态可编辑,提交后将作为明确说明展示。</span>
|
||||
<div class="detail-note-actions">
|
||||
@@ -136,15 +127,6 @@
|
||||
</div>
|
||||
<div v-else class="detail-note readonly">
|
||||
<p>{{ detailNote }}</p>
|
||||
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
|
||||
<span
|
||||
v-for="tag in detailNoteTags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -178,8 +160,8 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">时间</th>
|
||||
<th class="col-filled-at">填写时间</th>
|
||||
<th class="col-time">发生时间</th>
|
||||
<th class="col-type">费用项目</th>
|
||||
<th class="col-desc">说明</th>
|
||||
<th class="col-amount">金额</th>
|
||||
@@ -190,6 +172,10 @@
|
||||
<tbody>
|
||||
<template v-for="item in expenseItems" :key="item.id">
|
||||
<tr :class="{ 'system-generated-row': item.isSystemGenerated }">
|
||||
<td class="expense-filled-at col-filled-at">
|
||||
<strong>{{ item.filledAt }}</strong>
|
||||
<span>条款填写时间</span>
|
||||
</td>
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
|
||||
<i
|
||||
v-if="isMajorExpenseRisk(item)"
|
||||
@@ -208,10 +194,6 @@
|
||||
<span>{{ item.dayLabel }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="expense-filled-at col-filled-at">
|
||||
<strong>{{ item.filledAt }}</strong>
|
||||
<span>条款填写时间</span>
|
||||
</td>
|
||||
<td class="expense-type col-type">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
@@ -405,11 +387,11 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="isEditableRequest" class="detail-card panel validation-card">
|
||||
<article v-if="showAiAdvicePanel" class="detail-card panel validation-card">
|
||||
<div class="validation-head">
|
||||
<div>
|
||||
<h3>AI建议</h3>
|
||||
<p>按建议顺序补齐信息或处理风险后,再发起审批。</p>
|
||||
<h3>{{ aiAdviceTitle }}</h3>
|
||||
<p>{{ aiAdviceHint }}</p>
|
||||
</div>
|
||||
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
|
||||
</div>
|
||||
@@ -434,15 +416,6 @@
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
</div>
|
||||
<div v-if="card.tags?.length" class="risk-card-tag-list" aria-label="风险标签">
|
||||
<span
|
||||
v-for="tag in card.tags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
@@ -733,15 +706,6 @@
|
||||
<strong>{{ currentSubmitRiskWarning.title }}</strong>
|
||||
</div>
|
||||
<p>{{ currentSubmitRiskWarning.risk }}</p>
|
||||
<div class="risk-card-tag-list" aria-label="风险标签">
|
||||
<span
|
||||
v-for="tag in currentSubmitRiskWarning.tags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
|
||||
maxlength="160"
|
||||
|
||||
@@ -6,6 +6,11 @@ import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
|
||||
import {
|
||||
filterActionableRiskFlags,
|
||||
isRiskSummaryWithRisk,
|
||||
normalizeRiskFlagTone
|
||||
} from '../../utils/riskFlags.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
@@ -37,10 +42,9 @@ function formatCurrency(value) {
|
||||
}
|
||||
|
||||
function resolveRiskTone(riskFlags, riskSummary) {
|
||||
if (Array.isArray(riskFlags)) {
|
||||
const severities = riskFlags
|
||||
.map((item) => String(item?.severity || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
const actionableFlags = filterActionableRiskFlags(riskFlags)
|
||||
if (actionableFlags.length) {
|
||||
const severities = actionableFlags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
|
||||
|
||||
if (severities.includes('high')) {
|
||||
return 'high'
|
||||
@@ -53,7 +57,7 @@ function resolveRiskTone(riskFlags, riskSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') {
|
||||
if (isRiskSummaryWithRisk(riskSummary)) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
|
||||
313
web/src/views/scripts/ArchiveCenterView.js
Normal file
313
web/src/views/scripts/ArchiveCenterView.js
Normal file
@@ -0,0 +1,313 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
ARCHIVE_FILTER_ALL,
|
||||
applyArchiveListFilters,
|
||||
buildArchiveMonthFilterOptions,
|
||||
buildDepartmentFilterOptions,
|
||||
buildTypeFilterOptions,
|
||||
countClaimRisks,
|
||||
extractArchiveMonth,
|
||||
formatArchiveMonthLabel,
|
||||
formatArchiveRiskCountLabel,
|
||||
hasActiveArchiveListFilters,
|
||||
resolveArchiveRiskTone
|
||||
} from '../../utils/archiveCenterListFilters.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
|
||||
const RISK_FILTER_OPTIONS = [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
|
||||
{ value: 'has', label: '有风险' },
|
||||
{ value: 'none', label: '无风险' },
|
||||
{ value: 'high', label: '高风险' },
|
||||
{ value: 'medium', label: '中风险' },
|
||||
{ value: 'low', label: '低风险' }
|
||||
]
|
||||
|
||||
function formatCurrency(value) {
|
||||
const amount = Number(value)
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(Number.isFinite(amount) ? amount : 0)
|
||||
}
|
||||
|
||||
function resolveArchiveTypeTab(request) {
|
||||
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
|
||||
if (expenseType === 'travel') {
|
||||
return '差旅报销'
|
||||
}
|
||||
if (expenseType === 'entertainment') {
|
||||
return '招待报销'
|
||||
}
|
||||
return '其他费用'
|
||||
}
|
||||
|
||||
function buildArchiveRow(request) {
|
||||
const normalized = normalizeRequestForUi(request)
|
||||
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
|
||||
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
|
||||
const hasRisk = riskCount > 0
|
||||
const archiveMonth = extractArchiveMonth(
|
||||
normalized.updatedAt,
|
||||
normalized.submittedAt,
|
||||
normalized.createdAt,
|
||||
normalized.occurredAt,
|
||||
normalized.applyTime
|
||||
)
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
applicant: normalized.person,
|
||||
avatar: String(normalized.person || '?').trim().slice(0, 1) || '?',
|
||||
department: normalized.dept,
|
||||
type: normalized.typeLabel,
|
||||
amount: formatCurrency(normalized.amountValue),
|
||||
time: normalized.applyTime,
|
||||
archivedAt: normalized.updatedAt || normalized.applyTime,
|
||||
archiveMonth,
|
||||
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
|
||||
node: normalized.workflowNode || '归档入账',
|
||||
hasRisk,
|
||||
riskCount,
|
||||
risk: formatArchiveRiskCountLabel(riskCount),
|
||||
riskTone,
|
||||
status: '已归档',
|
||||
statusTone: 'archived',
|
||||
archiveTab: resolveArchiveTypeTab(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFilterLabel(options, activeValue, fallbackLabel) {
|
||||
return options.find((item) => item.value === activeValue)?.label || fallbackLabel
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ArchiveCenterView',
|
||||
components: {
|
||||
TravelRequestDetailView,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const activeTab = ref('全部归档')
|
||||
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const activeArchiveMonthFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const openFilterKey = ref('')
|
||||
const selectedClaimId = ref('')
|
||||
const listKeyword = ref('')
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const typeFilterOptions = computed(() => buildTypeFilterOptions(rows.value))
|
||||
const departmentFilterOptions = computed(() => buildDepartmentFilterOptions(rows.value))
|
||||
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
|
||||
|
||||
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
|
||||
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '费用类型'))
|
||||
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
|
||||
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
|
||||
|
||||
const filterDropdowns = computed(() => [
|
||||
{
|
||||
key: 'risk',
|
||||
label: riskFilterLabel.value,
|
||||
options: RISK_FILTER_OPTIONS,
|
||||
activeValue: activeRiskFilter.value
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: typeFilterLabel.value,
|
||||
options: typeFilterOptions.value,
|
||||
activeValue: activeTypeFilter.value
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
label: departmentFilterLabel.value,
|
||||
options: departmentFilterOptions.value,
|
||||
activeValue: activeDepartmentFilter.value
|
||||
},
|
||||
{
|
||||
key: 'archiveMonth',
|
||||
label: archiveMonthFilterLabel.value,
|
||||
options: archiveMonthFilterOptions.value,
|
||||
activeValue: activeArchiveMonthFilter.value
|
||||
}
|
||||
])
|
||||
|
||||
const selectedRow = computed({
|
||||
get() {
|
||||
return rows.value.find((row) => row.claimId === selectedClaimId.value) || null
|
||||
},
|
||||
set(value) {
|
||||
selectedClaimId.value = value?.claimId || ''
|
||||
}
|
||||
})
|
||||
|
||||
const visibleRows = computed(() => applyArchiveListFilters(rows.value, {
|
||||
tab: activeTab.value,
|
||||
risk: activeRiskFilter.value,
|
||||
type: activeTypeFilter.value,
|
||||
department: activeDepartmentFilter.value,
|
||||
archiveMonth: activeArchiveMonthFilter.value,
|
||||
keyword: listKeyword.value
|
||||
}))
|
||||
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const archiveEmptyState = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return {
|
||||
eyebrow: '归档中心',
|
||||
title: '当前还没有已归档单据',
|
||||
desc: '财务终审通过并进入「归档入账」节点的报销单会自动汇总到这里,形成公司级财务归档库。',
|
||||
icon: 'mdi mdi-archive-check-outline',
|
||||
actionLabel: null,
|
||||
actionIcon: null,
|
||||
tone: 'slate',
|
||||
artLabel: 'ARCHIVE',
|
||||
tips: ['仅展示已归档入账的单据', '申请人仍可在报销中心查看自己的归档记录']
|
||||
}
|
||||
}
|
||||
|
||||
const filtersActive = hasActiveArchiveListFilters({
|
||||
tab: activeTab.value,
|
||||
risk: activeRiskFilter.value,
|
||||
type: activeTypeFilter.value,
|
||||
department: activeDepartmentFilter.value,
|
||||
archiveMonth: activeArchiveMonthFilter.value,
|
||||
keyword: listKeyword.value
|
||||
})
|
||||
|
||||
return {
|
||||
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
|
||||
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `“${activeTab.value}”里暂时没有归档单据`,
|
||||
desc: filtersActive
|
||||
? '可以调整风险、费用类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
|
||||
: '可以切换到其他分类查看,或调整筛选条件后重新检索。',
|
||||
icon: 'mdi mdi-archive-outline',
|
||||
actionLabel: null,
|
||||
actionIcon: null,
|
||||
tone: 'sky',
|
||||
artLabel: filtersActive ? 'FILTER' : 'ARCHIVE',
|
||||
tips: ['归档中心保存全公司归档数据', '非申请人无法在报销中心查看他人归档单']
|
||||
}
|
||||
})
|
||||
|
||||
function resetListFilters() {
|
||||
activeTab.value = '全部归档'
|
||||
activeRiskFilter.value = ARCHIVE_FILTER_ALL
|
||||
activeTypeFilter.value = ARCHIVE_FILTER_ALL
|
||||
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
|
||||
activeArchiveMonthFilter.value = ARCHIVE_FILTER_ALL
|
||||
listKeyword.value = ''
|
||||
openFilterKey.value = ''
|
||||
}
|
||||
|
||||
function handleEmptyAction() {
|
||||
if (!rows.value.length) {
|
||||
void reload()
|
||||
return
|
||||
}
|
||||
|
||||
resetListFilters()
|
||||
}
|
||||
|
||||
function toggleFilterDropdown(key) {
|
||||
openFilterKey.value = openFilterKey.value === key ? '' : key
|
||||
}
|
||||
|
||||
function selectFilterValue(key, value) {
|
||||
if (key === 'risk') {
|
||||
activeRiskFilter.value = value
|
||||
} else if (key === 'type') {
|
||||
activeTypeFilter.value = value
|
||||
} else if (key === 'department') {
|
||||
activeDepartmentFilter.value = value
|
||||
} else if (key === 'archiveMonth') {
|
||||
activeArchiveMonthFilter.value = value
|
||||
}
|
||||
|
||||
openFilterKey.value = ''
|
||||
}
|
||||
|
||||
function handleDocumentClick(event) {
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!target.closest('.archive-dropdown-filter')) {
|
||||
openFilterKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeSelectedDetail() {
|
||||
selectedClaimId.value = ''
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = await fetchArchivedExpenseClaims()
|
||||
const mappedRows = (Array.isArray(payload) ? payload : [])
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.filter(Boolean)
|
||||
.map((item) => buildArchiveRow(item))
|
||||
rows.value = mappedRows
|
||||
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
|
||||
selectedClaimId.value = ''
|
||||
}
|
||||
} catch (nextError) {
|
||||
rows.value = []
|
||||
selectedClaimId.value = ''
|
||||
error.value = nextError instanceof Error ? nextError.message : '归档中心加载失败。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
void reload()
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
archiveEmptyState,
|
||||
closeSelectedDetail,
|
||||
error,
|
||||
filterDropdowns,
|
||||
handleEmptyAction,
|
||||
listKeyword,
|
||||
loading,
|
||||
openFilterKey,
|
||||
reload,
|
||||
resetListFilters,
|
||||
rows,
|
||||
selectFilterValue,
|
||||
selectedRow,
|
||||
showEmpty,
|
||||
tabs,
|
||||
toggleFilterDropdown,
|
||||
visibleRows
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
shouldRenderOnlyOfficePreview
|
||||
} from './knowledgePreviewMode.js'
|
||||
import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js'
|
||||
import { resolveInitialKnowledgeFolder } from './knowledgeFolderSelection.js'
|
||||
import {
|
||||
resolveInitialKnowledgeFolder,
|
||||
resolveKnowledgeFolderIcon
|
||||
} from './knowledgeFolderSelection.js'
|
||||
import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js'
|
||||
|
||||
const KNOWLEDGE_POLL_INTERVAL_MS = 5000
|
||||
@@ -663,11 +666,12 @@ export default {
|
||||
previewLoading,
|
||||
shouldRenderOnlyOffice,
|
||||
shouldRenderOnlyOfficeHostNode,
|
||||
selectDocument,
|
||||
selectPreviewPage,
|
||||
selectedDocument,
|
||||
syncingFolder,
|
||||
totalCount,
|
||||
selectDocument,
|
||||
selectPreviewPage,
|
||||
selectedDocument,
|
||||
resolveKnowledgeFolderIcon,
|
||||
syncingFolder,
|
||||
totalCount,
|
||||
totalPages,
|
||||
triggerUpload,
|
||||
uploadHint,
|
||||
|
||||
@@ -181,12 +181,26 @@ const REVIEW_DRAWER_MODE_REVIEW = 'review'
|
||||
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
|
||||
const REVIEW_DRAWER_MODE_RISK = 'risk'
|
||||
const REVIEW_DRAWER_MODE_FLOW = 'flow'
|
||||
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
||||
const FLOW_STEP_STATUS_PENDING = 'pending'
|
||||
const FLOW_STEP_STATUS_RUNNING = 'running'
|
||||
const FLOW_STEP_STATUS_COMPLETED = 'completed'
|
||||
const FLOW_STEP_STATUS_FAILED = 'failed'
|
||||
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
|
||||
|
||||
function normalizeReviewPanelScope(scope) {
|
||||
const normalized = String(scope || '').trim()
|
||||
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
|
||||
? normalized
|
||||
: ''
|
||||
}
|
||||
|
||||
function canExposeReviewPanelScope(scope) {
|
||||
return Boolean(normalizeReviewPanelScope(scope))
|
||||
}
|
||||
|
||||
function buildBusinessTimeContextFromReviewValues(values = {}) {
|
||||
return buildBusinessTimeContextFromReviewValuesModel(values)
|
||||
}
|
||||
@@ -413,11 +427,13 @@ function buildReviewRiskItems(reviewPayload) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function buildReviewRiskConversationText(item) {
|
||||
function buildReviewRiskConversationText(item, detailTarget = {}) {
|
||||
const title = String(item?.title || '风险提示').trim()
|
||||
const summary = String(item?.summary || '').trim()
|
||||
const detail = String(item?.detail || '').trim()
|
||||
const suggestion = String(item?.suggestion || '').trim()
|
||||
const detailHref = String(detailTarget?.href || '').trim()
|
||||
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
||||
const lines = [`${title}`]
|
||||
|
||||
if (summary) {
|
||||
@@ -429,6 +445,9 @@ function buildReviewRiskConversationText(item) {
|
||||
if (suggestion) {
|
||||
lines.push('', `修改建议:${suggestion}`)
|
||||
}
|
||||
if (detailHref) {
|
||||
lines.push('', `[${detailLabel}](${detailHref})`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -470,6 +489,10 @@ export default {
|
||||
requestContext: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
invalidatedDraftClaimId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['close', 'draft-saved'],
|
||||
@@ -484,10 +507,10 @@ export default {
|
||||
const composerDraft = ref('')
|
||||
const submitting = ref(false)
|
||||
const workbenchVisible = ref(false)
|
||||
const closeAfterBusy = ref(false)
|
||||
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
||||
const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS
|
||||
let sessionRuntimeRefs = {}
|
||||
const uploadDecisionDialogOpen = ref(false)
|
||||
const {
|
||||
activeSessionType,
|
||||
messages,
|
||||
@@ -511,7 +534,6 @@ export default {
|
||||
linkedRequest,
|
||||
toast,
|
||||
composerDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
adjustComposerTextareaHeight,
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs: () => sessionRuntimeRefs
|
||||
@@ -568,8 +590,21 @@ export default {
|
||||
FLOW_STEP_STATUS_COMPLETED,
|
||||
FLOW_STEP_STATUS_FAILED
|
||||
})
|
||||
const hasScopedReviewPayload = computed(() => {
|
||||
const agent = currentInsight.value.agent || null
|
||||
if (agent?.reviewPayload && canExposeReviewPanelScope(agent.reviewPanelScope)) {
|
||||
return true
|
||||
}
|
||||
if (currentInsight.value.intent === 'agent' && agent) {
|
||||
return false
|
||||
}
|
||||
return messages.value.some((item) =>
|
||||
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
|
||||
)
|
||||
})
|
||||
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
|
||||
const hasInsightPanelContent = computed(
|
||||
() => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || flowSteps.value.length > 0
|
||||
() => isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
|
||||
)
|
||||
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
|
||||
const insightPanelToggleLabel = computed(() =>
|
||||
@@ -604,11 +639,31 @@ export default {
|
||||
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
|
||||
)
|
||||
const latestReviewMessage = computed(() =>
|
||||
[...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null
|
||||
)
|
||||
const activeReviewPayload = computed(
|
||||
() => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null
|
||||
[...messages.value].reverse().find((item) =>
|
||||
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
|
||||
) ?? null
|
||||
)
|
||||
const activeReviewPanelScope = computed(() => {
|
||||
const agent = currentInsight.value.agent || null
|
||||
const agentScope = normalizeReviewPanelScope(agent?.reviewPanelScope)
|
||||
if (agent?.reviewPayload && agentScope) {
|
||||
return agentScope
|
||||
}
|
||||
if (currentInsight.value.intent === 'agent' && agent) {
|
||||
return ''
|
||||
}
|
||||
return normalizeReviewPanelScope(latestReviewMessage.value?.reviewPanelScope)
|
||||
})
|
||||
const activeReviewPayload = computed(() => {
|
||||
const agent = currentInsight.value.agent || null
|
||||
if (agent?.reviewPayload && normalizeReviewPanelScope(agent.reviewPanelScope)) {
|
||||
return agent.reviewPayload
|
||||
}
|
||||
if (currentInsight.value.intent === 'agent' && agent) {
|
||||
return null
|
||||
}
|
||||
return latestReviewMessage.value?.reviewPayload || null
|
||||
})
|
||||
const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload)
|
||||
const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver)
|
||||
const {
|
||||
@@ -634,6 +689,7 @@ export default {
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewOverviewDrawerAvailable,
|
||||
reviewDocumentDrawerAvailable,
|
||||
reviewRiskDrawerAvailable,
|
||||
reviewFlowDrawerAvailable,
|
||||
@@ -671,6 +727,7 @@ export default {
|
||||
closeDocumentPreview
|
||||
} = useTravelReimbursementReviewDrawer({
|
||||
activeReviewPayload,
|
||||
activeReviewPanelScope,
|
||||
reviewFilePreviews,
|
||||
flowSteps,
|
||||
submitting,
|
||||
@@ -709,6 +766,7 @@ export default {
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
syncComposerBusinessTimeToReviewCard,
|
||||
resolveComposerSubmitText,
|
||||
resolveComposerDisplaySubmitText,
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
@@ -853,6 +911,7 @@ export default {
|
||||
refreshFlowRunDetail,
|
||||
rememberFilePreviews,
|
||||
replaceMessage,
|
||||
resolveComposerDisplaySubmitText,
|
||||
resetFlowRun,
|
||||
resolveComposerSubmitText,
|
||||
reviewInlineForm,
|
||||
@@ -868,7 +927,6 @@ export default {
|
||||
startSemanticFlowPreview,
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
toast
|
||||
})
|
||||
const canSubmit = computed(
|
||||
@@ -906,8 +964,8 @@ export default {
|
||||
}
|
||||
])
|
||||
watch(
|
||||
() => activeReviewPayload.value,
|
||||
(payload) => {
|
||||
() => [activeReviewPayload.value, activeReviewPanelScope.value],
|
||||
([payload]) => {
|
||||
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
|
||||
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
|
||||
// ? REVIEW_DRAWER_MODE_RISK
|
||||
@@ -989,11 +1047,51 @@ export default {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.invalidatedDraftClaimId,
|
||||
(claimId) => {
|
||||
clearExpenseSessionForDeletedClaim(claimId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => workbenchVisible.value,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
maybeFinalizeDeferredClose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
|
||||
() => {
|
||||
maybeFinalizeDeferredClose()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
if (!workbenchVisible.value) {
|
||||
return
|
||||
}
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleComposerDatePickerOutside)
|
||||
startFlowTick()
|
||||
nextTick(() => {
|
||||
workbenchVisible.value = true
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
void clearKnowledgeSessionOnEntry()
|
||||
currentInsight.value =
|
||||
@@ -1008,11 +1106,6 @@ export default {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
submitComposer()
|
||||
} else {
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1023,8 +1116,31 @@ export default {
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!messageListRef.value) return
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
const scrollOnce = () => {
|
||||
const list = messageListRef.value
|
||||
if (!list) {
|
||||
return false
|
||||
}
|
||||
list.scrollTop = list.scrollHeight
|
||||
return true
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (scrollOnce()) {
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
scrollOnce()
|
||||
requestAnimationFrame(scrollOnce)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function handleAssistantModalAfterEnter() {
|
||||
scrollToBottom()
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
function resetCurrentSessionState() {
|
||||
@@ -1034,6 +1150,31 @@ export default {
|
||||
resetFlowRun({ startedAt: 0, openDrawer: false })
|
||||
}
|
||||
|
||||
function clearExpenseSessionForDeletedClaim(claimId) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId) {
|
||||
return
|
||||
}
|
||||
|
||||
const expenseSnapshot = sessionSnapshots.value[SESSION_TYPE_EXPENSE]
|
||||
const snapshotMatchesDeletedClaim = String(expenseSnapshot?.draftClaimId || '').trim() === normalizedClaimId
|
||||
const currentMatchesDeletedClaim =
|
||||
activeSessionType.value === SESSION_TYPE_EXPENSE
|
||||
&& String(resolveActiveClaimId() || '').trim() === normalizedClaimId
|
||||
if (!snapshotMatchesDeletedClaim && !currentMatchesDeletedClaim) {
|
||||
return
|
||||
}
|
||||
|
||||
clearAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
|
||||
if (currentMatchesDeletedClaim) {
|
||||
resetCurrentSessionState()
|
||||
toast('该草稿单据已删除,相关财务助手会话已清空。')
|
||||
return
|
||||
}
|
||||
|
||||
sessionSnapshots.value[SESSION_TYPE_EXPENSE] = buildEmptySessionState(SESSION_TYPE_EXPENSE)
|
||||
}
|
||||
|
||||
function adjustComposerTextareaHeight() {
|
||||
if (!composerTextareaRef.value) return
|
||||
|
||||
@@ -1071,31 +1212,6 @@ export default {
|
||||
messages.value.splice(index, 1, nextMessage)
|
||||
}
|
||||
|
||||
function closeUploadDecisionDialog() {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
uploadDecisionDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function continueExistingUpload() {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
uploadDecisionDialogOpen.value = false
|
||||
composerUploadIntent.value = 'continue_existing'
|
||||
await submitComposer({
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipUploadDecisionPrompt: true
|
||||
})
|
||||
}
|
||||
|
||||
async function createNewUploadDocument() {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
uploadDecisionDialogOpen.value = false
|
||||
composerUploadIntent.value = ''
|
||||
await submitComposer({
|
||||
uploadDisposition: 'new_document',
|
||||
skipUploadDecisionPrompt: true
|
||||
})
|
||||
}
|
||||
|
||||
async function runShortcut(shortcut) {
|
||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
@@ -1218,6 +1334,9 @@ export default {
|
||||
}
|
||||
|
||||
function switchToReviewOverviewDrawer() {
|
||||
if (!reviewOverviewDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
|
||||
}
|
||||
|
||||
@@ -1255,20 +1374,98 @@ export default {
|
||||
|
||||
function appendReviewRiskBriefToConversation(item) {
|
||||
if (!item) return
|
||||
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
|
||||
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
|
||||
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
|
||||
metaTone: item.level || 'low'
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function appendExpenseQueryRiskToConversation(record, risk) {
|
||||
if (!record || !risk) return
|
||||
const claimId = String(record.claimId || '').trim()
|
||||
const claimNo = String(record.claimNo || '该单据').trim()
|
||||
const route = claimId
|
||||
? router.resolve({
|
||||
name: 'app-request-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
: null
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
buildReviewRiskConversationText(
|
||||
{
|
||||
title: `${claimNo} ${risk.levelLabel || '风险提示'}:${risk.title || '风险提示'}`,
|
||||
summary: risk.summary,
|
||||
detail: risk.detail,
|
||||
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
|
||||
sourceLabel: risk.levelLabel,
|
||||
level: risk.level
|
||||
},
|
||||
route?.href
|
||||
? {
|
||||
href: route.href,
|
||||
label: `进入 ${claimNo} 详情重新填写`
|
||||
}
|
||||
: {}
|
||||
),
|
||||
[],
|
||||
{
|
||||
meta: [`${claimNo} 风险详情`],
|
||||
metaTone: risk.level || 'medium'
|
||||
}
|
||||
))
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function resolveReviewRiskDetailTarget() {
|
||||
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
|
||||
const candidates = [
|
||||
currentInsight.value.agent?.draftPayload,
|
||||
latestReviewMessage.value?.draftPayload,
|
||||
latestDraftMessage?.draftPayload,
|
||||
linkedRequest.value
|
||||
].filter(Boolean)
|
||||
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
|
||||
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
|
||||
if (!claimId) {
|
||||
return {}
|
||||
}
|
||||
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',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
return {
|
||||
href: route.href,
|
||||
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkbenchBusy() {
|
||||
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
|
||||
}
|
||||
|
||||
function maybeFinalizeDeferredClose() {
|
||||
if (!closeAfterBusy.value || workbenchVisible.value || isWorkbenchBusy()) {
|
||||
return
|
||||
}
|
||||
closeAfterBusy.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function requestCloseWorkbench() {
|
||||
persistSessionState()
|
||||
closeAfterBusy.value = isWorkbenchBusy()
|
||||
workbenchVisible.value = false
|
||||
}
|
||||
|
||||
function emitCloseAfterLeave() {
|
||||
if (closeAfterBusy.value && isWorkbenchBusy()) {
|
||||
return
|
||||
}
|
||||
closeAfterBusy.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -1317,7 +1514,6 @@ export default {
|
||||
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
|
||||
files,
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipUploadDecisionPrompt: true,
|
||||
extraContext: {
|
||||
draft_claim_id: claimId,
|
||||
selected_claim_id: claimId,
|
||||
@@ -1469,6 +1665,12 @@ export default {
|
||||
}
|
||||
|
||||
const href = String(anchor.getAttribute('href') || '').trim()
|
||||
if (href.startsWith('/app/')) {
|
||||
event.preventDefault()
|
||||
router.push(href)
|
||||
return
|
||||
}
|
||||
|
||||
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
|
||||
return
|
||||
}
|
||||
@@ -1492,8 +1694,25 @@ export default {
|
||||
return handleSaveDraftDirectlyInternal(message, actionType)
|
||||
}
|
||||
|
||||
function isDraftSavedReviewMessage(message) {
|
||||
if (!message?.reviewPayload) {
|
||||
return false
|
||||
}
|
||||
return Boolean(
|
||||
String(message?.draftPayload?.claim_no || message?.draftPayload?.claim_id || '').trim()
|
||||
|| String(draftClaimId.value || '').trim()
|
||||
|| String(resolveActiveClaimId() || '').trim()
|
||||
)
|
||||
}
|
||||
|
||||
function buildReviewPlainFollowupForMessage(message) {
|
||||
return buildReviewPlainFollowupCopy(message?.reviewPayload, {
|
||||
savedDraft: isDraftSavedReviewMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
function canUseInlineSaveDraft(message) {
|
||||
if (!message?.reviewPayload || message?.draftPayload?.claim_no) {
|
||||
if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(resolveReviewSaveDraftAction(message.reviewPayload))
|
||||
@@ -1515,16 +1734,16 @@ export default {
|
||||
emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
|
||||
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
|
||||
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
|
||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
||||
reviewDrawerTitle, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
||||
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, uploadDecisionDialogOpen,
|
||||
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
|
||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, 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, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
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, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards,
|
||||
buildClaimSummaryRiskCards,
|
||||
buildItemClaimRiskState,
|
||||
extractRiskTagsFromText,
|
||||
normalizeRiskTone,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
resolveRiskTags
|
||||
} from './travelRequestDetailInsights.js'
|
||||
import {
|
||||
EXPENSE_TYPE_OPTIONS,
|
||||
@@ -95,6 +96,26 @@ function normalizeDetailNoteDraftValue(value) {
|
||||
return isPlaceholderValue(text) ? '' : text
|
||||
}
|
||||
|
||||
function stripRiskTagsForDisplay(value) {
|
||||
return String(value || '')
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/:\s+第/g, ':第')
|
||||
.trim()
|
||||
)
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function mergeVisibleNoteWithHiddenTags(visibleText, rawText) {
|
||||
const cleanText = normalizeDetailNoteDraftValue(visibleText)
|
||||
const tags = extractRiskTagsFromText(rawText).join(' ')
|
||||
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, requestModel) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
@@ -612,13 +633,36 @@ export default {
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||||
const stripDetailNoteRiskTags = (value) =>
|
||||
String(value || '')
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/:\s+第/g, ':第')
|
||||
.trim()
|
||||
)
|
||||
.join('\n')
|
||||
.trim()
|
||||
const mergeDetailNoteVisibleTextWithTags = (visibleText, rawText) => {
|
||||
const cleanText = normalizeDetailNoteDraftValue(visibleText)
|
||||
const tags = extractRiskTagsFromText(rawText).join(' ')
|
||||
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
|
||||
const detailNote = computed(() => {
|
||||
if (detailNoteSource.value) {
|
||||
return detailNoteSource.value
|
||||
return stripDetailNoteRiskTags(detailNoteSource.value)
|
||||
}
|
||||
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
||||
})
|
||||
const detailNoteEditorView = computed({
|
||||
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
|
||||
set: (value) => {
|
||||
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
|
||||
}
|
||||
})
|
||||
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
||||
const detailNoteTags = computed(() =>
|
||||
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
|
||||
@@ -689,6 +733,11 @@ export default {
|
||||
return expenseAttachmentMeta[item.id] || null
|
||||
}
|
||||
|
||||
function resolveClaimRiskFlags() {
|
||||
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
|
||||
return Array.isArray(flags) ? flags : []
|
||||
}
|
||||
|
||||
function resolveAttachmentDisplayName(item) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||
@@ -790,10 +839,6 @@ export default {
|
||||
}
|
||||
|
||||
function resolveExpenseRiskState(item) {
|
||||
if (!item.invoiceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (uploadingExpenseId.value === item.id) {
|
||||
return {
|
||||
label: 'AI识别中',
|
||||
@@ -818,6 +863,15 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
|
||||
if (claimRiskState) {
|
||||
return claimRiskState
|
||||
}
|
||||
|
||||
if (!item.invoiceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: '已上传',
|
||||
tone: 'low',
|
||||
@@ -843,13 +897,20 @@ export default {
|
||||
}
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
const completionItems = isEditableRequest.value
|
||||
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
: []
|
||||
const directRiskCards = buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: resolveClaimRiskFlags()
|
||||
})
|
||||
const hasActionableRiskCards = directRiskCards.some(
|
||||
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
|
||||
)
|
||||
const riskCards = [
|
||||
...buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||||
}),
|
||||
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
|
||||
...directRiskCards,
|
||||
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
|
||||
]
|
||||
|
||||
@@ -859,6 +920,14 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
|
||||
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
|
||||
const aiAdviceHint = computed(() => (
|
||||
isEditableRequest.value
|
||||
? '按建议顺序补齐信息或处理风险后,再发起审批。'
|
||||
: '展示系统已识别的风险点,便于审批和后续整改。'
|
||||
))
|
||||
|
||||
const submitRiskWarnings = computed(() =>
|
||||
aiAdvice.value.riskCards
|
||||
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
|
||||
@@ -904,10 +973,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskTagClass(tag) {
|
||||
return resolveRiskTagTone(tag)
|
||||
}
|
||||
|
||||
function openRiskOverrideDialog() {
|
||||
const warnings = submitRiskWarnings.value
|
||||
if (!warnings.length) {
|
||||
@@ -1619,11 +1684,18 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const claimId = String(request.value?.claimId || '').trim()
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
request: request.value,
|
||||
restoreLatestConversation: true
|
||||
restoreLatestConversation: false,
|
||||
scope: claimId
|
||||
? {
|
||||
type: 'claim',
|
||||
claimId
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1632,7 +1704,7 @@ export default {
|
||||
})
|
||||
|
||||
return {
|
||||
emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
|
||||
@@ -1646,7 +1718,7 @@ export default {
|
||||
currentSubmitRiskWarning,
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
||||
detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
||||
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
@@ -1655,12 +1727,12 @@ export default {
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass,
|
||||
resolveExpenseRiskIndicatorTitle,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
}
|
||||
|
||||
@@ -8,3 +8,12 @@ export function resolveInitialKnowledgeFolder(folders, currentFolder = '') {
|
||||
|
||||
return normalizedFolders[0]?.name || ''
|
||||
}
|
||||
|
||||
export function resolveKnowledgeFolderIcon(folder, activeFolder = '') {
|
||||
const folderName = String(folder?.name || folder || '').trim()
|
||||
const normalizedActiveFolder = String(activeFolder || '').trim()
|
||||
|
||||
return folderName && folderName === normalizedActiveFolder
|
||||
? 'mdi mdi-folder-open'
|
||||
: 'mdi mdi-folder'
|
||||
}
|
||||
|
||||
@@ -6,26 +6,26 @@ import {
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
expense: '??',
|
||||
accounts_receivable: '??',
|
||||
accounts_payable: '??',
|
||||
knowledge: '??',
|
||||
unknown: '??'
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
accounts_payable: '应付',
|
||||
knowledge: '知识',
|
||||
unknown: '通用'
|
||||
}
|
||||
|
||||
const INTENT_LABELS = {
|
||||
query: '??',
|
||||
explain: '??',
|
||||
compare: '??',
|
||||
risk_check: '????',
|
||||
draft: '????',
|
||||
operate: '????'
|
||||
query: '查询',
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '信息核对',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '???'
|
||||
if (status === 'blocked') return '???'
|
||||
return '??'
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'blocked') return '已阻断'
|
||||
return '处理中'
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
@@ -123,6 +123,12 @@ function buildAssociationDocumentContentLines(document) {
|
||||
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
|
||||
}
|
||||
|
||||
function buildAssociationDocumentCard(lines) {
|
||||
return (Array.isArray(lines) ? lines : [])
|
||||
.map((line) => String(line || '').trim() ? `> ${line}` : '>')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function buildAttachmentAssociationConfirmationMessage({
|
||||
claimNo = '',
|
||||
claimTitle = '',
|
||||
@@ -144,13 +150,14 @@ export function buildAttachmentAssociationConfirmationMessage({
|
||||
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
|
||||
const typeLabel = resolveAssociationDocumentTypeLabel(document)
|
||||
const contentLines = buildAssociationDocumentContentLines(document)
|
||||
return [
|
||||
`附件 ${index + 1}:${filename}`,
|
||||
.map((line) => String(line || '').replace(/^-\s*/, ''))
|
||||
return buildAssociationDocumentCard([
|
||||
`**附件 ${index + 1}:${filename}**`,
|
||||
'',
|
||||
`附件类型:${typeLabel}`,
|
||||
'',
|
||||
...contentLines
|
||||
].join('\n')
|
||||
])
|
||||
})
|
||||
|
||||
return [
|
||||
@@ -158,14 +165,17 @@ export function buildAttachmentAssociationConfirmationMessage({
|
||||
'',
|
||||
documentBlocks.join('\n\n'),
|
||||
'',
|
||||
'',
|
||||
'请问是否确定将票据信息归集到单据:',
|
||||
'',
|
||||
targetLines.join('\n'),
|
||||
'',
|
||||
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。`
|
||||
'',
|
||||
`如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。`
|
||||
]
|
||||
.filter((part) => String(part || '').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function normalizeReviewDocumentFieldKey(label) {
|
||||
@@ -235,6 +245,9 @@ export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
|
||||
document_type_label: resolveDocumentTypeLabel(item?.document_type),
|
||||
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
preview_kind: String(item?.preview_kind || '').trim(),
|
||||
preview_data_url: String(item?.preview_data_url || '').trim(),
|
||||
preview_url: String(item?.preview_url || '').trim(),
|
||||
document_fields: fields,
|
||||
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||
}
|
||||
@@ -373,12 +386,32 @@ export function mergeFilePreviews(existingPreviews, incomingPreviews) {
|
||||
return result
|
||||
}
|
||||
|
||||
function inferPreviewKindFromUrl(url) {
|
||||
const normalized = String(url || '').trim().toLowerCase()
|
||||
if (!normalized) return ''
|
||||
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
|
||||
return 'image'
|
||||
}
|
||||
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
|
||||
return 'pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveDocumentPreviewKind(item) {
|
||||
const explicit = String(item?.preview_kind || '').trim()
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
|
||||
}
|
||||
|
||||
export function buildOcrFilePreviews(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
kind: resolveDocumentPreviewKind(item),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
@@ -389,7 +422,7 @@ export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
kind: resolveDocumentPreviewKind(item),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
|
||||
@@ -166,6 +166,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
queryPayload: null,
|
||||
draftPayload: null,
|
||||
reviewPayload: null,
|
||||
reviewPanelScope: '',
|
||||
riskFlags: [],
|
||||
pendingAttachmentAssociation: null,
|
||||
...extras
|
||||
@@ -299,6 +300,7 @@ export function sanitizeRequest(request) {
|
||||
if (!request || typeof request !== 'object') return null
|
||||
|
||||
const normalized = {
|
||||
claimId: String(request.claimId || request.claim_id || '').trim(),
|
||||
id: String(request.id || '').trim(),
|
||||
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
||||
reason: String(request.reason || request.title || '').trim(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
export const EXPENSE_QUERY_PAGE_SIZE = 5
|
||||
export const EXPENSE_CENTER_HREF = '/app/requests'
|
||||
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
|
||||
const EXPENSE_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
@@ -14,6 +15,36 @@ const EXPENSE_STATUS_LABELS = {
|
||||
approved: '已审核',
|
||||
paid: '已入账'
|
||||
}
|
||||
const EXPENSE_RISK_LEVEL_LABELS = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
warning: '中风险',
|
||||
low: '低风险',
|
||||
info: '低风险'
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryRiskItem(item, index = 0) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawLevel = String(item.level || item.severity || '').trim().toLowerCase()
|
||||
const level = EXPENSE_RISK_LEVEL_LABELS[rawLevel] ? rawLevel : 'medium'
|
||||
const summary = String(item.summary || item.message || item.content || '').trim()
|
||||
const detail = String(item.detail || item.description || summary).trim()
|
||||
if (!summary && !detail) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
key: String(item.key || `${level}-${index}`).trim() || `${level}-${index}`,
|
||||
level,
|
||||
levelLabel: String(item.level_label || item.levelLabel || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
|
||||
title: String(item.title || item.label || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
|
||||
summary: summary || detail,
|
||||
detail: detail || summary
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryStatusGroup(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
@@ -57,6 +88,9 @@ export function normalizeExpenseQueryRecord(item) {
|
||||
occurredAt,
|
||||
reason,
|
||||
location: String(item.location || '').trim(),
|
||||
riskItems: (Array.isArray(item.risk_flags) ? item.risk_flags : [])
|
||||
.map((riskItem, index) => normalizeExpenseQueryRiskItem(riskItem, index))
|
||||
.filter(Boolean),
|
||||
summary: reason || `${expenseTypeLabel}报销`,
|
||||
dateDisplay: documentDate || occurredAt || '待补充日期'
|
||||
}
|
||||
@@ -164,6 +198,7 @@ export function normalizeExpenseQueryPayload(payload) {
|
||||
|
||||
const rawRecordCount = Number(payload.record_count || 0)
|
||||
const rawPreviewCount = Number(payload.preview_count || records.length)
|
||||
const rawPreviewLimit = Number(payload.preview_limit || EXPENSE_QUERY_PAGE_SIZE)
|
||||
const rawOlderRecordCount = Number(payload.older_record_count || 0)
|
||||
const totalAmount = Number(payload.total_amount || 0)
|
||||
const rawWindowDays = Number(payload.window_days || 0)
|
||||
@@ -187,6 +222,7 @@ export function normalizeExpenseQueryPayload(payload) {
|
||||
windowEndDate: windowEndDate || '',
|
||||
recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0,
|
||||
previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length,
|
||||
previewLimit: Number.isFinite(rawPreviewLimit) ? Math.max(1, rawPreviewLimit) : EXPENSE_QUERY_PAGE_SIZE,
|
||||
olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0,
|
||||
hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more),
|
||||
totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0,
|
||||
@@ -250,18 +286,13 @@ export function buildExpenseQueryHint(queryPayload) {
|
||||
}
|
||||
|
||||
const parts = []
|
||||
const windowText = buildExpenseQueryWindowLabel(queryPayload)
|
||||
const previewLimit = Math.max(1, Number(queryPayload.previewLimit || EXPENSE_QUERY_PAGE_SIZE))
|
||||
const totalCount = Math.max(0, Number(queryPayload.recordCount || 0))
|
||||
|
||||
if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) {
|
||||
parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`)
|
||||
}
|
||||
|
||||
if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) {
|
||||
parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`)
|
||||
}
|
||||
|
||||
if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) {
|
||||
parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`)
|
||||
if (totalCount > previewLimit) {
|
||||
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到报销中心查看。`)
|
||||
} else if (totalCount > 0) {
|
||||
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入报销中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
|
||||
}
|
||||
|
||||
return parts.join('。')
|
||||
|
||||
@@ -30,6 +30,7 @@ export function buildReviewDocumentDrafts(reviewPayload) {
|
||||
expenseTypeLabel: String(item.expenseTypeLabel || '').trim(),
|
||||
preview_kind: String(item.preview_kind || '').trim(),
|
||||
preview_data_url: String(item.preview_data_url || '').trim(),
|
||||
preview_url: String(item.preview_url || '').trim(),
|
||||
warnings: Array.isArray(item.warnings) ? [...item.warnings] : [],
|
||||
fields: Array.isArray(item.fields)
|
||||
? item.fields.map((field) => ({
|
||||
|
||||
@@ -1260,6 +1260,19 @@ const REVIEW_PENDING_SUMMARY_TEMPLATES = [
|
||||
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
|
||||
]
|
||||
|
||||
const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [
|
||||
({ issueSummary }) => `当前还有 ${issueSummary}。草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`,
|
||||
({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中。`,
|
||||
({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步。`,
|
||||
({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单。`,
|
||||
({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`,
|
||||
({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`,
|
||||
({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`,
|
||||
({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`,
|
||||
({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`,
|
||||
({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
|
||||
]
|
||||
|
||||
function buildStableTemplateIndex(signature, total) {
|
||||
const source = String(signature || '')
|
||||
let hash = 0
|
||||
@@ -1269,7 +1282,7 @@ function buildStableTemplateIndex(signature, total) {
|
||||
return total ? hash % total : 0
|
||||
}
|
||||
|
||||
function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
|
||||
function buildReviewPendingSummary(pendingCount, riskCount, signature = '', options = {}) {
|
||||
const issueParts = []
|
||||
if (pendingCount) {
|
||||
issueParts.push(`${pendingCount} 项信息待补充`)
|
||||
@@ -1278,11 +1291,15 @@ function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
|
||||
issueParts.push(`${riskCount} 条风险提醒`)
|
||||
}
|
||||
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
|
||||
const templateIndex = buildStableTemplateIndex(signature || issueSummary, REVIEW_PENDING_SUMMARY_TEMPLATES.length)
|
||||
return REVIEW_PENDING_SUMMARY_TEMPLATES[templateIndex]({ issueSummary })
|
||||
const templates = options.savedDraft
|
||||
? REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES
|
||||
: REVIEW_PENDING_SUMMARY_TEMPLATES
|
||||
const templateIndex = buildStableTemplateIndex(signature || issueSummary, templates.length)
|
||||
return templates[templateIndex]({ issueSummary })
|
||||
}
|
||||
|
||||
export function buildReviewPlainFollowupCopy(reviewPayload) {
|
||||
export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
|
||||
const savedDraft = Boolean(options?.savedDraft)
|
||||
const todoItems = buildReviewTodoItems(reviewPayload)
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
|
||||
@@ -1297,7 +1314,9 @@ export function buildReviewPlainFollowupCopy(reviewPayload) {
|
||||
return {
|
||||
lead: '补充信息:',
|
||||
tone: 'danger',
|
||||
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature),
|
||||
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature, {
|
||||
savedDraft
|
||||
}),
|
||||
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
|
||||
notes: []
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ export const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'train_ticket', label: '火车票' },
|
||||
{ value: 'flight_ticket', label: '机票' },
|
||||
{ value: 'ship_ticket', label: '轮船票' },
|
||||
{ value: 'ferry_ticket', label: '轮船票' },
|
||||
{ value: 'hotel_ticket', label: '住宿票' },
|
||||
{ value: 'ride_ticket', label: '乘车' },
|
||||
{ value: 'entertainment', label: '业务招待费' },
|
||||
@@ -116,6 +118,13 @@ export function resolveExpenseReasonHelper(itemType) {
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
|
||||
if (isRouteDescriptionExpenseType(itemType) || isHotelDescriptionExpenseType(itemType)) {
|
||||
return resolveExpenseReasonHelper(itemType)
|
||||
}
|
||||
return resolveLocationDisplay(itemLocation, itemType)
|
||||
}
|
||||
|
||||
export function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
@@ -345,7 +354,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
|
||||
amount: amountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
isActionableRiskFlag,
|
||||
isRiskSummaryWithRisk,
|
||||
normalizeRiskFlagTone
|
||||
} from '../../utils/riskFlags.js'
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
@@ -34,6 +40,62 @@ export function normalizeRiskTone(value) {
|
||||
return normalizeTone(value)
|
||||
}
|
||||
|
||||
function resolveFlagTone(flag) {
|
||||
return normalizeRiskFlagTone(flag)
|
||||
}
|
||||
|
||||
function isRiskTone(tone) {
|
||||
return ['medium', 'high'].includes(normalizeText(tone).toLowerCase())
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
return normalizeText(value)
|
||||
}
|
||||
|
||||
function resolveItemRiskFlag(item, claimRiskFlags) {
|
||||
const itemId = normalizeId(item?.id)
|
||||
if (!itemId || !Array.isArray(claimRiskFlags)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return claimRiskFlags.find((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isActionableRiskFlag(flag)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
||||
const tone = resolveFlagTone(flag)
|
||||
return flagItemId === itemId && isRiskTone(tone)
|
||||
}) || null
|
||||
}
|
||||
|
||||
export function buildItemClaimRiskState(item, claimRiskFlags = []) {
|
||||
const flag = resolveItemRiskFlag(item, claimRiskFlags)
|
||||
if (!flag) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tone = resolveFlagTone(flag)
|
||||
const label = normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险')
|
||||
const points = Array.isArray(flag.points)
|
||||
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
|
||||
: []
|
||||
const summary = normalizeText(flag.summary || flag.message || flag.reason)
|
||||
|
||||
return {
|
||||
label,
|
||||
tone,
|
||||
headline: normalizeText(flag.headline || flag.title) || label,
|
||||
summary,
|
||||
points,
|
||||
suggestion: normalizeText(flag.suggestion) || '如业务确需提交,请在附加说明中补充特殊情况原因后继续提交。'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRiskTagTone(tag) {
|
||||
const normalized = normalizeText(tag).toLowerCase()
|
||||
if (normalized === '#high_risk') return 'high'
|
||||
@@ -99,6 +161,68 @@ function normalizeRuleBasis(value) {
|
||||
return text ? [text] : []
|
||||
}
|
||||
|
||||
function resolveClaimRiskRuleBasis(flag = {}, { risk = '', summary = '', tone = 'medium' } = {}) {
|
||||
const explicitBasis = normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
|
||||
if (explicitBasis.length) {
|
||||
return explicitBasis
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
const label = normalizeText(flag.label || flag.title || flag.name)
|
||||
const corpus = [risk, summary, label].map((item) => normalizeText(item)).join(' ')
|
||||
const basis = []
|
||||
|
||||
if (/高风险|中风险/.test(corpus)) {
|
||||
basis.push(`风险文本已明确标记为${tone === 'high' ? '高风险' : '中风险'}。`)
|
||||
}
|
||||
if (source === 'attachment_analysis' || /附件|票据|OCR|识别|发票/.test(corpus)) {
|
||||
basis.push('附件识别或票据核验未完全通过,系统将该项同步为单据风险。')
|
||||
}
|
||||
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
|
||||
basis.push('审批链校验未匹配到完整审批人信息,因此按中风险提醒。')
|
||||
}
|
||||
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
|
||||
basis.push('金额或标准核算命中制度阈值,需要补充说明或人工复核。')
|
||||
}
|
||||
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
|
||||
basis.push('历史报销风险次数达到预警条件,系统提示审批人重点关注。')
|
||||
}
|
||||
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
|
||||
basis.push('单据、附件或审批信息存在缺失、不一致或待补充项。')
|
||||
}
|
||||
if (summary) {
|
||||
basis.push(`风险汇总:${summary}`)
|
||||
}
|
||||
|
||||
return uniqueTexts(basis.length ? basis : [`系统预审根据“${label || '单据风险'}”将该项列为${tone === 'high' ? '高风险' : '中风险'}。`])
|
||||
}
|
||||
|
||||
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '' } = {}) {
|
||||
const explicitSuggestion = normalizeText(flag.suggestion)
|
||||
if (explicitSuggestion) {
|
||||
return explicitSuggestion
|
||||
}
|
||||
|
||||
const corpus = [risk, summary, flag.label, flag.title].map((item) => normalizeText(item)).join(' ')
|
||||
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
|
||||
return '请先核对员工档案中的直属领导和审批链配置;如果信息无误,可由审批环节人工补充分配说明。'
|
||||
}
|
||||
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
|
||||
return '请核对金额、天数、地点和职级标准;如确需超标,请在附加说明中写清楚业务原因和佐证材料。'
|
||||
}
|
||||
if (/附件|票据|OCR|识别|发票/.test(corpus)) {
|
||||
return '请打开对应费用明细的附件预览,核对票据类型、金额、日期和说明;识别有误时先修正明细或重新上传附件。'
|
||||
}
|
||||
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
|
||||
return '请核对近期同类报销记录,必要时补充本次费用与历史单据不同的业务背景。'
|
||||
}
|
||||
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
|
||||
return '请按风险点补齐缺失信息,并核对费用明细与附件内容是否一致。'
|
||||
}
|
||||
|
||||
return '请先核对上方触发原因;如属于真实业务例外,在附加说明中写清楚原因和佐证后再继续流转。'
|
||||
}
|
||||
|
||||
export function buildAttachmentInsightViewModel(metadata, item = {}) {
|
||||
if (!metadata) {
|
||||
return null
|
||||
@@ -245,6 +369,7 @@ export function buildAttachmentRiskCards({
|
||||
attachmentMetaByItemId = {},
|
||||
claimRiskFlags = []
|
||||
} = {}) {
|
||||
const attachmentRiskItemIds = new Set()
|
||||
const attachmentCards = expenseItems.flatMap((item, index) => {
|
||||
if (!item?.invoiceId) {
|
||||
return []
|
||||
@@ -257,6 +382,10 @@ export function buildAttachmentRiskCards({
|
||||
if (!analysis || !['medium', 'high'].includes(tone)) {
|
||||
return []
|
||||
}
|
||||
const itemId = normalizeId(item.id)
|
||||
if (itemId) {
|
||||
attachmentRiskItemIds.add(itemId)
|
||||
}
|
||||
|
||||
const points = Array.isArray(analysis.points) && analysis.points.length
|
||||
? analysis.points
|
||||
@@ -276,6 +405,10 @@ export function buildAttachmentRiskCards({
|
||||
}
|
||||
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
if (!isActionableRiskFlag(flag)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const risk = normalizeText(flag)
|
||||
return risk
|
||||
? [withRiskTags({
|
||||
@@ -285,29 +418,41 @@ export function buildAttachmentRiskCards({
|
||||
title: '单据风险提示',
|
||||
risk,
|
||||
summary: '',
|
||||
ruleBasis: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
|
||||
ruleBasis: resolveClaimRiskRuleBasis({}, { risk, tone: 'medium' }),
|
||||
suggestion: resolveClaimRiskSuggestion({}, { risk })
|
||||
})]
|
||||
: []
|
||||
}
|
||||
|
||||
const tone = normalizeTone(flag.severity)
|
||||
if (!['medium', 'high'].includes(tone)) {
|
||||
if (!isActionableRiskFlag(flag)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
||||
if (source === 'attachment_analysis' && flagItemId && attachmentRiskItemIds.has(flagItemId)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tone = resolveFlagTone(flag)
|
||||
if (!isRiskTone(tone)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const flagPoints = Array.isArray(flag.points)
|
||||
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
|
||||
: []
|
||||
const primaryRisk = normalizeText(flag.message || flag.reason || flag.summary)
|
||||
const fallbackRisk = normalizeText(flag.description || flag.detail || flag.title || flag.label || flag.name)
|
||||
const risks = flagPoints.length
|
||||
? flagPoints
|
||||
: [normalizeText(flag.message || flag.reason || flag.summary)].filter(Boolean)
|
||||
const summary = normalizeText(flag.summary)
|
||||
const ruleBasis = uniqueTexts([
|
||||
...normalizeRuleBasis(flag.rule_basis || flag.ruleBasis),
|
||||
summary ? `风险汇总:${summary}` : '',
|
||||
'系统预审规则命中该风险提示。'
|
||||
])
|
||||
: [primaryRisk || fallbackRisk].filter(Boolean)
|
||||
const summary = normalizeText(flag.summary || flag.message || flag.reason)
|
||||
const ruleBasis = resolveClaimRiskRuleBasis(flag, {
|
||||
risk: risks[0] || primaryRisk || fallbackRisk,
|
||||
summary,
|
||||
tone
|
||||
})
|
||||
|
||||
return risks.map((risk, pointIndex) => withRiskTags({
|
||||
id: `claim-risk-${index}-${pointIndex}`,
|
||||
@@ -317,7 +462,7 @@ export function buildAttachmentRiskCards({
|
||||
risk,
|
||||
summary,
|
||||
ruleBasis,
|
||||
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
|
||||
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
|
||||
}))
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -328,6 +473,52 @@ export function buildAttachmentRiskCards({
|
||||
return [...attachmentCards, ...claimCards]
|
||||
}
|
||||
|
||||
function isNoRiskSummary(value) {
|
||||
return !isRiskSummaryWithRisk(value)
|
||||
}
|
||||
|
||||
function resolveClaimSummaryTone(request) {
|
||||
const explicitTone = normalizeText(request?.riskTone || request?.risk_tone || request?.severity)
|
||||
if (explicitTone) {
|
||||
return normalizeTone(explicitTone)
|
||||
}
|
||||
|
||||
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
|
||||
if (/高风险|重大风险|严重|超标|违规/.test(summary)) {
|
||||
return 'high'
|
||||
}
|
||||
if (/中风险|待关注|待复核|提醒|异常|风险/.test(summary)) {
|
||||
return 'medium'
|
||||
}
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
export function buildClaimSummaryRiskCards(request = {}) {
|
||||
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
|
||||
if (isNoRiskSummary(summary)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tone = resolveClaimSummaryTone(request)
|
||||
if (!isRiskTone(tone)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [withRiskTags({
|
||||
id: 'claim-risk-summary',
|
||||
tone,
|
||||
label: tone === 'high' ? '高风险' : '中风险',
|
||||
title: '单据风险提示',
|
||||
risk: summary,
|
||||
summary,
|
||||
ruleBasis: resolveClaimRiskRuleBasis(
|
||||
{ source: 'risk_summary', message: summary, severity: tone },
|
||||
{ risk: summary, summary, tone }
|
||||
),
|
||||
suggestion: resolveClaimRiskSuggestion({ source: 'risk_summary' }, { risk: summary, summary })
|
||||
})]
|
||||
}
|
||||
|
||||
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
|
||||
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedRiskCards = riskCards.filter(Boolean)
|
||||
|
||||
@@ -1,5 +1,171 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const COMMON_DESTINATION_PREFIXES = [
|
||||
'上海',
|
||||
'北京',
|
||||
'广州',
|
||||
'深圳',
|
||||
'杭州',
|
||||
'南京',
|
||||
'苏州',
|
||||
'成都',
|
||||
'重庆',
|
||||
'武汉',
|
||||
'西安',
|
||||
'天津',
|
||||
'宁波',
|
||||
'青岛',
|
||||
'长沙',
|
||||
'郑州',
|
||||
'济南',
|
||||
'合肥',
|
||||
'福州',
|
||||
'厦门',
|
||||
'昆明',
|
||||
'南昌',
|
||||
'沈阳',
|
||||
'大连',
|
||||
'无锡',
|
||||
'佛山',
|
||||
'东莞'
|
||||
]
|
||||
|
||||
const CHINESE_DAY_NUMBERS = {
|
||||
一: 1,
|
||||
二: 2,
|
||||
两: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
十: 10
|
||||
}
|
||||
|
||||
function normalizeComposerText(value) {
|
||||
return String(value || '').trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function parseDayCount(value) {
|
||||
const text = String(value || '').trim()
|
||||
const numericValue = Number.parseInt(text, 10)
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return numericValue
|
||||
}
|
||||
if (text === '十') {
|
||||
return 10
|
||||
}
|
||||
if (/^十[一二三四五六七八九]$/.test(text)) {
|
||||
return 10 + (CHINESE_DAY_NUMBERS[text.slice(1)] || 0)
|
||||
}
|
||||
if (/^[一二两三四五六七八九]十$/.test(text)) {
|
||||
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10
|
||||
}
|
||||
if (/^[一二两三四五六七八九]十[一二三四五六七八九]$/.test(text)) {
|
||||
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10 + (CHINESE_DAY_NUMBERS[text.slice(2)] || 0)
|
||||
}
|
||||
return CHINESE_DAY_NUMBERS[text] || 0
|
||||
}
|
||||
|
||||
function calculateBusinessDays(businessTimeContext) {
|
||||
const startDate = String(businessTimeContext?.start_date || '').trim()
|
||||
const endDate = String(businessTimeContext?.end_date || startDate).trim()
|
||||
if (!startDate || !endDate || startDate > endDate) {
|
||||
return 0
|
||||
}
|
||||
const startAt = Date.parse(`${startDate}T00:00:00Z`)
|
||||
const endAt = Date.parse(`${endDate}T00:00:00Z`)
|
||||
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
|
||||
}
|
||||
|
||||
function stripBusinessTimePrefix(text) {
|
||||
return normalizeComposerText(text)
|
||||
.replace(/^(?:业务)?发生时间[::]\s*[^,,。\n]+(?:至\s*[^,,。\n]+)?[,,。\s]*/u, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveDestinationFromText(text) {
|
||||
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
|
||||
const targetMatch = normalized.match(/(?:去|到|赴|前往)([^,,。;;]+)/u)
|
||||
const targetText = String(targetMatch?.[1] || '').trim()
|
||||
if (!targetText) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const knownDestination = COMMON_DESTINATION_PREFIXES.find((item) => targetText.startsWith(item))
|
||||
if (knownDestination) {
|
||||
return knownDestination
|
||||
}
|
||||
|
||||
const verbIndex = targetText.search(/支撑|支持|部署|实施|驻场|出差|拜访|处理|办理|参加|进行|协助|服务器|项目/u)
|
||||
if (verbIndex > 0) {
|
||||
return targetText.slice(0, verbIndex)
|
||||
}
|
||||
return targetText.slice(0, 12)
|
||||
}
|
||||
|
||||
function resolveTripDaysFromText(text, businessTimeContext) {
|
||||
const dayMatch = normalizeComposerText(text).match(/(?:出差|共|总计)?\s*([0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u)
|
||||
const explicitDays = parseDayCount(dayMatch?.[1])
|
||||
return explicitDays || calculateBusinessDays(businessTimeContext)
|
||||
}
|
||||
|
||||
function resolveReasonFromText(text, destination) {
|
||||
let reason = normalizeComposerText(text)
|
||||
.replace(/^(?:去|到|赴|前往)\s*/u, '')
|
||||
.trim()
|
||||
|
||||
if (destination && reason.startsWith(destination)) {
|
||||
reason = reason.slice(destination.length).trim()
|
||||
}
|
||||
|
||||
return reason
|
||||
.replace(/[,,。\s]*(?:出差|共|总计)?\s*(?:[0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u, '')
|
||||
.replace(/[,,。\s]*(?:申请|发起|办理)?(?:差旅费|差旅|费用)?报销(?:申请)?[。.!!]?$/u, '')
|
||||
.replace(/^[,,。;;\s]+|[,,。;;\s]+$/gu, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function buildStructuredComposerSubmitText(rawText, businessTimeContext = null) {
|
||||
const normalizedText = normalizeComposerText(rawText)
|
||||
const timeDisplay = String(
|
||||
businessTimeContext?.business_time ||
|
||||
businessTimeContext?.time_range ||
|
||||
businessTimeContext?.display_value ||
|
||||
''
|
||||
).trim()
|
||||
if (!timeDisplay || !normalizedText) {
|
||||
return normalizedText
|
||||
}
|
||||
|
||||
const bodyText = stripBusinessTimePrefix(normalizedText)
|
||||
if (!bodyText) {
|
||||
return `发生时间:${timeDisplay}`
|
||||
}
|
||||
|
||||
const destination = resolveDestinationFromText(bodyText)
|
||||
const reason = resolveReasonFromText(bodyText, destination)
|
||||
const days = resolveTripDaysFromText(bodyText, businessTimeContext)
|
||||
const lines = [`发生时间:${timeDisplay}`]
|
||||
|
||||
if (destination) {
|
||||
lines.push(`地点:${destination}`)
|
||||
}
|
||||
if (reason) {
|
||||
lines.push(`事由:${reason}`)
|
||||
}
|
||||
if (days > 0 && (days > 1 || /出差|差旅|至/.test(timeDisplay) || /出差|差旅/.test(bodyText))) {
|
||||
lines.push(`天数:${days}天`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function useTravelReimbursementComposerTools({
|
||||
currentUser,
|
||||
activeReviewPayload,
|
||||
@@ -51,12 +217,12 @@ export function useTravelReimbursementComposerTools({
|
||||
)
|
||||
function buildComposerBusinessTimeLabel() {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return `业务发生时间:${composerSingleDate.value}`
|
||||
return `发生时间:${composerSingleDate.value}`
|
||||
}
|
||||
if (composerRangeStartDate.value === composerRangeEndDate.value) {
|
||||
return `业务发生时间:${composerRangeStartDate.value}`
|
||||
return `发生时间:${composerRangeStartDate.value}`
|
||||
}
|
||||
return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
return `发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
function hasComposerBusinessTimeSelection() {
|
||||
@@ -156,6 +322,14 @@ export function useTravelReimbursementComposerTools({
|
||||
return `${tagPart},${draftPart}`
|
||||
}
|
||||
|
||||
function resolveComposerDisplaySubmitText(rawText) {
|
||||
const businessTimeContext = buildComposerBusinessTimeContext()
|
||||
if (!businessTimeContext) {
|
||||
return String(rawText || '').trim()
|
||||
}
|
||||
return buildStructuredComposerSubmitText(rawText, businessTimeContext)
|
||||
}
|
||||
|
||||
function toggleComposerDatePicker() {
|
||||
composerDatePickerOpen.value = !composerDatePickerOpen.value
|
||||
if (composerDatePickerOpen.value) {
|
||||
@@ -377,6 +551,7 @@ export function useTravelReimbursementComposerTools({
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
syncComposerBusinessTimeToReviewCard,
|
||||
resolveComposerSubmitText,
|
||||
resolveComposerDisplaySubmitText,
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
|
||||
export function useTravelReimbursementReviewDrawer({
|
||||
activeReviewPayload,
|
||||
activeReviewPanelScope,
|
||||
reviewFilePreviews,
|
||||
flowSteps,
|
||||
submitting,
|
||||
@@ -92,6 +93,11 @@ export function useTravelReimbursementReviewDrawer({
|
||||
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
|
||||
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
|
||||
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
|
||||
const normalizedReviewPanelScope = computed(() => {
|
||||
const scope = String(activeReviewPanelScope?.value || '').trim()
|
||||
return ['overview', 'documents', 'risk'].includes(scope) ? scope : ''
|
||||
})
|
||||
const reviewOverviewDrawerAvailable = computed(() => normalizedReviewPanelScope.value === 'overview')
|
||||
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
|
||||
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
|
||||
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
|
||||
@@ -135,22 +141,31 @@ export function useTravelReimbursementReviewDrawer({
|
||||
: 'mdi mdi-timeline-clock-outline'
|
||||
))
|
||||
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
|
||||
const activeReviewDocumentPreview = computed(() =>
|
||||
activeReviewDocument.value
|
||||
? (
|
||||
resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename)
|
||||
|| (
|
||||
activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url
|
||||
? {
|
||||
filename: activeReviewDocument.value.filename,
|
||||
kind: activeReviewDocument.value.preview_kind,
|
||||
url: activeReviewDocument.value.preview_data_url
|
||||
}
|
||||
: null
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
const activeReviewDocumentPreview = computed(() => {
|
||||
const document = activeReviewDocument.value
|
||||
if (!document) return null
|
||||
|
||||
const matchedPreview = resolveDocumentPreview(activeReviewFilePreviews.value, document.filename)
|
||||
if (matchedPreview?.url) {
|
||||
return matchedPreview
|
||||
}
|
||||
|
||||
const inlineUrl = String(document.preview_url || document.preview_data_url || '').trim()
|
||||
if (!inlineUrl) {
|
||||
return null
|
||||
}
|
||||
const explicitKind = String(document.preview_kind || '').trim()
|
||||
const inferredKind = inlineUrl.startsWith('data:image/') ? 'image' : explicitKind
|
||||
if (inferredKind !== 'image') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
filename: document.filename,
|
||||
kind: 'image',
|
||||
url: inlineUrl
|
||||
}
|
||||
})
|
||||
const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url))
|
||||
const reviewDocumentDirty = computed(() => {
|
||||
const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue))
|
||||
@@ -170,9 +185,22 @@ export function useTravelReimbursementReviewDrawer({
|
||||
activeReviewDocumentIndex.value = nextDocumentDrafts.length
|
||||
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
|
||||
: 0
|
||||
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
|
||||
? REVIEW_DRAWER_MODE_RISK
|
||||
: REVIEW_DRAWER_MODE_REVIEW
|
||||
const hasDocuments = nextDocumentDrafts.length > 0
|
||||
const hasRisks = resolveReviewRiskBriefs(payload).length > 0
|
||||
const scope = normalizedReviewPanelScope.value
|
||||
if (scope === 'documents' && hasDocuments) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
} else if (scope === 'risk' && hasRisks) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
|
||||
} else if (scope === 'overview') {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
} else if (hasDocuments) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
} else if (hasRisks) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
|
||||
} else {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
reviewInlinePendingFiles.value = []
|
||||
reviewInlineEditorKey.value = ''
|
||||
reviewInlineErrors.value = {}
|
||||
@@ -348,14 +376,27 @@ export function useTravelReimbursementReviewDrawer({
|
||||
}
|
||||
|
||||
function enforceReviewDrawerAvailability() {
|
||||
if (!reviewOverviewDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW) {
|
||||
if (reviewDocumentDrawerAvailable.value) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
} else if (reviewRiskDrawerAvailable.value) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
|
||||
}
|
||||
}
|
||||
if (!reviewDocumentDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_RISK
|
||||
}
|
||||
if (!reviewRiskDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
}
|
||||
if (!reviewFlowDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: (reviewDocumentDrawerAvailable.value ? REVIEW_DRAWER_MODE_DOCUMENTS : REVIEW_DRAWER_MODE_RISK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +431,7 @@ export function useTravelReimbursementReviewDrawer({
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
reviewDocumentCount,
|
||||
reviewOverviewDrawerAvailable,
|
||||
isReviewDocumentDrawer,
|
||||
isReviewRiskDrawer,
|
||||
isReviewFlowDrawer,
|
||||
|
||||
@@ -29,7 +29,6 @@ export function useTravelReimbursementSessionState({
|
||||
linkedRequest,
|
||||
toast,
|
||||
composerDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
adjustComposerTextareaHeight,
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs = () => ({})
|
||||
@@ -122,6 +121,7 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
const initialSessionType = resolveInitialSessionType(props.initialConversation)
|
||||
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
|
||||
const conversationInitialState = props.initialConversation
|
||||
? buildConversationSessionState(props.initialConversation, initialSessionType)
|
||||
: buildEmptySessionState(initialSessionType)
|
||||
@@ -172,6 +172,10 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
function persistSessionState(sessionState = null) {
|
||||
if (!shouldPersistLocalSnapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = sessionState || captureCurrentSessionState()
|
||||
const persistedState = buildPersistableSessionState(state)
|
||||
const meaningful = Boolean(
|
||||
@@ -240,7 +244,6 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
|
||||
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
|
||||
uploadDecisionDialogOpen.value = false
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
|
||||
@@ -61,6 +61,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
refreshFlowRunDetail,
|
||||
rememberFilePreviews,
|
||||
replaceMessage,
|
||||
resolveComposerDisplaySubmitText,
|
||||
resetFlowRun,
|
||||
resolveComposerSubmitText,
|
||||
reviewInlineForm,
|
||||
@@ -76,7 +77,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
startSemanticFlowPreview,
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
toast
|
||||
} = ctx
|
||||
|
||||
@@ -109,6 +109,33 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveReviewPanelScope({
|
||||
reviewPayload = null,
|
||||
reviewAction = '',
|
||||
fileCount = 0,
|
||||
rawText = ''
|
||||
} = {}) {
|
||||
if (!reviewPayload || typeof reviewPayload !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizedAction = String(reviewAction || '').trim()
|
||||
const documentCount = Array.isArray(reviewPayload.document_cards) ? reviewPayload.document_cards.length : 0
|
||||
const riskCount = Array.isArray(reviewPayload.risk_briefs) ? reviewPayload.risk_briefs.length : 0
|
||||
const asksRisk = /风险|隐患|超标|异常|重复|待整改|风险项|高风险|中风险|低风险/.test(String(rawText || ''))
|
||||
|
||||
if (fileCount > 0 && documentCount > 0) {
|
||||
return 'documents'
|
||||
}
|
||||
if (riskCount > 0 && (asksRisk || ['next_step', 'submit', 'submit_claim'].includes(normalizedAction))) {
|
||||
return 'risk'
|
||||
}
|
||||
if (!normalizedAction && fileCount === 0) {
|
||||
return 'overview'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function confirmPendingAttachmentAssociation(message) {
|
||||
if (submitting.value || sessionSwitchBusy.value) return null
|
||||
|
||||
@@ -137,7 +164,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||||
files: runtime.files,
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipUploadDecisionPrompt: true,
|
||||
skipDraftAssociationPrompt: true,
|
||||
pendingText: runtime.claimNo
|
||||
? `正在将票据归集到草稿 ${runtime.claimNo}...`
|
||||
@@ -189,26 +215,57 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function resolveDetailScopedClaimId() {
|
||||
if (props.entrySource !== 'detail' || isKnowledgeSession.value) {
|
||||
return ''
|
||||
}
|
||||
return String(
|
||||
linkedRequest.value?.claimId ||
|
||||
linkedRequest.value?.claim_id ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
async function submitComposer(options = {}) {
|
||||
if (submitting.value || sessionSwitchBusy.value) return null
|
||||
|
||||
const rawText = resolveComposerSubmitText(options.rawText).trim()
|
||||
const systemGenerated = Boolean(options.systemGenerated)
|
||||
const resolvedUploadDisposition =
|
||||
String(options.uploadDisposition || '').trim() ||
|
||||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '')
|
||||
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
||||
const files = fileMergeResult.files
|
||||
const detailScopedClaimId = resolveDetailScopedClaimId()
|
||||
const detailScopedUpload = Boolean(detailScopedClaimId && files.length)
|
||||
if (detailScopedClaimId) {
|
||||
draftClaimId.value = detailScopedClaimId
|
||||
}
|
||||
const resolvedUploadDisposition =
|
||||
String(options.uploadDisposition || '').trim() ||
|
||||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') ||
|
||||
(detailScopedUpload ? 'continue_existing' : '')
|
||||
if (fileMergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
if (!rawText && !files.length) return
|
||||
const fileNames = files.map((file) => file.name)
|
||||
|
||||
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
const optionExtraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const detailScopedClaimNo = String(
|
||||
linkedRequest.value?.documentNo ||
|
||||
linkedRequest.value?.id ||
|
||||
''
|
||||
).trim()
|
||||
const initialExtraContext = detailScopedClaimId
|
||||
? {
|
||||
...optionExtraContext,
|
||||
draft_claim_id: detailScopedClaimId,
|
||||
selected_claim_id: detailScopedClaimId,
|
||||
selected_claim_no: detailScopedClaimNo,
|
||||
detail_scope_claim_id: detailScopedClaimId
|
||||
}
|
||||
: optionExtraContext
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
? initialExtraContext
|
||||
@@ -217,7 +274,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const attachmentAssociationConfirmed = Boolean(
|
||||
options.associationConfirmed ||
|
||||
extraContext.attachment_association_confirmed ||
|
||||
reviewAction === 'link_to_existing_draft'
|
||||
reviewAction === 'link_to_existing_draft' ||
|
||||
detailScopedUpload
|
||||
)
|
||||
const hasSelectedExpenseType = Boolean(
|
||||
extraContext.expense_scene_selection ||
|
||||
@@ -238,10 +296,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
hasSelectedExpenseType
|
||||
})
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
|
||||
const userText =
|
||||
String(options.userText || '').trim() ||
|
||||
resolveComposerDisplaySubmitText(rawText) ||
|
||||
rawText ||
|
||||
(isKnowledgeSession.value
|
||||
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
|
||||
@@ -254,19 +311,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
if (
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
hasExistingDocumentEvent &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipUploadDecisionPrompt &&
|
||||
!reviewAction
|
||||
) {
|
||||
uploadDecisionDialogOpen.value = true
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
!hasExistingDocumentEvent &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipDraftAssociationPrompt &&
|
||||
!reviewAction
|
||||
@@ -300,7 +344,22 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load draft claims before attachment recognition:', error)
|
||||
toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。')
|
||||
resetFlowRun()
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
'我暂时没能查询到可关联的草稿/待补单据,所以先不识别这批附件。请稍后重试,或从对应草稿进入后继续上传票据。',
|
||||
[],
|
||||
{
|
||||
meta: ['单据查询失败']
|
||||
}
|
||||
))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
toast(error?.message || '查询可关联草稿失败,请稍后重试。')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,14 +661,24 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewPanelScope: resolveReviewPanelScope({
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
reviewAction: reviewActionResult,
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
currentInsight.value = buildAgentInsight(
|
||||
const nextInsight = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
if (nextInsight.agent) {
|
||||
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
|
||||
}
|
||||
currentInsight.value = nextInsight
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
Reference in New Issue
Block a user