feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -68,9 +68,11 @@
|
||||
:detail-alerts="resolvedDetailAlerts"
|
||||
:detail-kpis="resolvedDetailKpis"
|
||||
:custom-range="customRange"
|
||||
:overview-dashboard="overviewDashboard"
|
||||
@update:search="search = $event"
|
||||
@update:active-range="activeRange = $event"
|
||||
@update:custom-range="customRange = $event"
|
||||
@update:overview-dashboard="overviewDashboard = $event"
|
||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||
@new-application="openExpenseApplicationCreate"
|
||||
/>
|
||||
@@ -101,6 +103,9 @@
|
||||
<OverviewView
|
||||
v-if="activeView === 'overview'"
|
||||
:filtered-requests="filteredRequests"
|
||||
:dashboard="overviewDashboard"
|
||||
:active-range="activeRange"
|
||||
:custom-range="customRange"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
/>
|
||||
@@ -138,6 +143,8 @@
|
||||
<ReceiptFolderView
|
||||
v-else-if="activeView === 'receiptFolder'"
|
||||
@open-assistant="openSmartEntry"
|
||||
@detail-open-change="receiptFolderDetailOpen = $event"
|
||||
@detail-topbar-change="detailTopBarPayload = $event"
|
||||
/>
|
||||
|
||||
<BudgetCenterView
|
||||
@@ -168,58 +175,43 @@
|
||||
:initial-prompt="smartEntryContext.prompt"
|
||||
:initial-files="smartEntryContext.files"
|
||||
:initial-conversation="smartEntryContext.conversation"
|
||||
:initial-session-type="smartEntryContext.sessionType"
|
||||
:entry-source="smartEntryContext.source"
|
||||
:request-context="smartEntryContext.request"
|
||||
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
|
||||
:reopen-token="smartEntryRevealToken"
|
||||
@close="closeSmartEntry"
|
||||
@draft-saved="handleDraftSaved"
|
||||
@request-updated="handleRequestUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
import FloatingLightBandWindow from '../components/shared/FloatingLightBandWindow.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import AuditView from './AuditView.vue'
|
||||
import BudgetCenterView from './BudgetCenterView.vue'
|
||||
import DigitalEmployeesView from './DigitalEmployeesView.vue'
|
||||
import DocumentsCenterView from './DocumentsCenterView.vue'
|
||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import ReceiptFolderView from './ReceiptFolderView.vue'
|
||||
import SettingsView from './SettingsView.vue'
|
||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
|
||||
import { useAppShell } from '../composables/useAppShell.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
|
||||
|
||||
const OverviewView = defineAsyncComponent(() => import('./OverviewView.vue'))
|
||||
const PersonalWorkbenchView = defineAsyncComponent(() => import('./PersonalWorkbenchView.vue'))
|
||||
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
|
||||
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
|
||||
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue'))
|
||||
const ReceiptFolderView = defineAsyncComponent(() => import('./ReceiptFolderView.vue'))
|
||||
const BudgetCenterRouteLoading = {
|
||||
name: 'BudgetCenterRouteLoading',
|
||||
render: () =>
|
||||
h(TableLoadingState, {
|
||||
title: '预算数据同步中',
|
||||
message: '正在加载预算中心模块与预算数据',
|
||||
icon: 'mdi mdi-chart-donut',
|
||||
floating: true,
|
||||
blocking: true
|
||||
})
|
||||
}
|
||||
const BudgetCenterView = defineAsyncComponent({
|
||||
loader: () => import('./BudgetCenterView.vue'),
|
||||
loadingComponent: BudgetCenterRouteLoading,
|
||||
delay: 0
|
||||
})
|
||||
const PoliciesView = defineAsyncComponent(() => import('./PoliciesView.vue'))
|
||||
const AuditView = defineAsyncComponent(() => import('./AuditView.vue'))
|
||||
const DigitalEmployeesView = defineAsyncComponent(() => import('./DigitalEmployeesView.vue'))
|
||||
const EmployeeManagementView = defineAsyncComponent(() => import('./EmployeeManagementView.vue'))
|
||||
const SettingsView = defineAsyncComponent(() => import('./SettingsView.vue'))
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
const knowledgeSummary = ref(null)
|
||||
const documentSummary = ref(null)
|
||||
@@ -227,9 +219,11 @@ const digitalEmployeeSummary = ref(null)
|
||||
const detailTopBarPayload = ref(null)
|
||||
const auditDetailOpen = ref(false)
|
||||
const digitalEmployeeDetailOpen = ref(false)
|
||||
const receiptFolderDetailOpen = ref(false)
|
||||
const loginEntryAnimating = ref(false)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileSidebarOpen = ref(false)
|
||||
const overviewDashboard = ref('finance')
|
||||
let loginEntryTimer = null
|
||||
|
||||
function stopLoginEntryAnimation() {
|
||||
@@ -310,11 +304,16 @@ const DETAIL_TOPBAR_FALLBACKS = {
|
||||
digitalEmployees: {
|
||||
title: '数字员工详情',
|
||||
desc: '查看数字员工配置、执行计划、运行记录与源文件。'
|
||||
},
|
||||
receiptFolder: {
|
||||
title: '票据详情',
|
||||
desc: '查看票据源文件、OCR 识别信息与关联状态。'
|
||||
}
|
||||
}
|
||||
const customDetailTopBarActive = computed(() => (
|
||||
(activeView.value === 'audit' && auditDetailOpen.value) ||
|
||||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value)
|
||||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value) ||
|
||||
(activeView.value === 'receiptFolder' && receiptFolderDetailOpen.value)
|
||||
))
|
||||
const resolvedTopBarView = computed(() => (
|
||||
customDetailTopBarActive.value
|
||||
|
||||
@@ -384,6 +384,26 @@
|
||||
<i class="mdi mdi-flask-outline"></i>
|
||||
<span>测试规则</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canEditRiskRuleDraft"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="detailBusy"
|
||||
@click="openRiskRuleEditDialog('draft')"
|
||||
>
|
||||
<i class="mdi mdi-square-edit-outline"></i>
|
||||
<span>编辑规则</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canCreateRiskRuleRevision"
|
||||
class="minor-action"
|
||||
type="button"
|
||||
:disabled="detailBusy"
|
||||
@click="openRiskRuleEditDialog('revision')"
|
||||
>
|
||||
<i class="mdi mdi-source-branch-plus"></i>
|
||||
<span>创建修订版本</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteRiskRule"
|
||||
class="minor-action danger-action"
|
||||
@@ -511,6 +531,10 @@
|
||||
:risk-rule-delete-open="riskRuleDeleteOpen"
|
||||
:risk-rule-return-open="riskRuleReturnOpen"
|
||||
:risk-rule-publish-open="riskRulePublishOpen"
|
||||
:risk-rule-edit-open="riskRuleEditOpen"
|
||||
:risk-rule-edit-mode="riskRuleEditMode"
|
||||
:risk-rule-edit-form="riskRuleEditForm"
|
||||
:risk-rule-edit-busy="actionState === 'save-risk-rule-edit'"
|
||||
:risk-rule-test-passed="riskRuleTestPassed"
|
||||
:review-submit-open="reviewSubmitOpen"
|
||||
:review-submit-reviewer-loading="reviewSubmitReviewerLoading"
|
||||
@@ -521,6 +545,8 @@
|
||||
@submit-risk-rule-create="submitRiskRuleCreate"
|
||||
@close-risk-rule-test="closeRiskRuleTestDialog"
|
||||
@report-saved="handleRiskRuleReportSaved"
|
||||
@close-risk-rule-edit="closeRiskRuleEditDialog"
|
||||
@submit-risk-rule-edit="submitRiskRuleEdit"
|
||||
@close-delete-risk-rule="closeDeleteRiskRuleDialog"
|
||||
@delete-selected-risk-rule="deleteSelectedRiskRule"
|
||||
@close-return-risk-rule="closeReturnRiskRuleDialog"
|
||||
|
||||
@@ -114,10 +114,7 @@
|
||||
class="agent-answer-content agent-answer-markdown"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
></div>
|
||||
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
|
||||
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
|
||||
</div>
|
||||
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
|
||||
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
|
||||
<strong>风险标签</strong>
|
||||
<div class="agent-detail-chip-row">
|
||||
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
:class="{ active: activeSection === 'skills' }"
|
||||
@click="activeSection = 'skills'"
|
||||
>
|
||||
员工能力
|
||||
员工技能
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -107,6 +107,7 @@
|
||||
<DigitalEmployeeWorkRecords
|
||||
v-else
|
||||
class="digital-work-records-section"
|
||||
:focus-run-id="workRecordFocusRunId"
|
||||
@summary-change="emit('summary-change', $event)"
|
||||
@detail-open-change="workRecordDetailOpen = $event"
|
||||
@detail-topbar-change="workRecordDetailTopBar = $event"
|
||||
@@ -129,7 +130,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue'
|
||||
import DigitalEmployeeListPanel from '../components/audit/DigitalEmployeeListPanel.vue'
|
||||
@@ -184,6 +185,7 @@ const selectedEmployeeId = ref('')
|
||||
const activeSection = ref('skills')
|
||||
const workRecordDetailOpen = ref(false)
|
||||
const workRecordDetailTopBar = ref(null)
|
||||
const workRecordFocusRunId = ref('')
|
||||
const isDetailOpen = computed(() => Boolean(selectedEmployee.value) || (activeSection.value === 'workRecords' && workRecordDetailOpen.value))
|
||||
const digitalEmployeeDetailTopBar = computed(() => {
|
||||
const employee = selectedEmployee.value
|
||||
@@ -511,7 +513,17 @@ async function runDigitalEmployeeNow(employee) {
|
||||
entry: 'digital_employees'
|
||||
}
|
||||
})
|
||||
toast(`已发起立即运行,Run ID:${result?.run_id || '-'}`)
|
||||
const runId = String(result?.run_id || '').trim()
|
||||
if (runId) {
|
||||
closeEmployeeDetail()
|
||||
activeSection.value = 'workRecords'
|
||||
workRecordFocusRunId.value = ''
|
||||
await nextTick()
|
||||
workRecordFocusRunId.value = runId
|
||||
toast(`已发起立即运行,已打开本次工作记录。Run ID:${runId}`)
|
||||
} else {
|
||||
toast('已发起立即运行,请在工作记录中查看结果。')
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error?.message || '立即运行失败,请稍后重试。')
|
||||
} finally {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<section class="dashboard">
|
||||
<section class="dashboard" :class="`dashboard-${activeDashboard}`">
|
||||
<div class="kpi-grid">
|
||||
<article
|
||||
v-for="metric in kpiMetrics"
|
||||
v-for="metric in activeKpiMetrics"
|
||||
:key="metric.label"
|
||||
class="kpi-card panel"
|
||||
:style="{ '--accent': metric.accent, '--delay': `${metric.delay}ms` }"
|
||||
@@ -22,122 +22,297 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="content-grid top-grid">
|
||||
<article class="panel dashboard-card trend-panel">
|
||||
<div class="card-head">
|
||||
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeTrendRange"
|
||||
class="card-select"
|
||||
:options="trendRanges"
|
||||
aria-label="趋势时间范围"
|
||||
size="small"
|
||||
<template v-if="activeDashboard === 'finance'">
|
||||
<div class="content-grid top-grid">
|
||||
<article class="panel dashboard-card trend-panel">
|
||||
<div class="card-head">
|
||||
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeTrendRange"
|
||||
class="card-select"
|
||||
:options="trendRanges"
|
||||
aria-label="趋势时间范围"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TrendChart
|
||||
:labels="activeTrend.labels"
|
||||
:applications="activeTrend.applications"
|
||||
:approved="activeTrend.approved"
|
||||
:avg-hours="activeTrend.avgHours"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<TrendChart
|
||||
:labels="activeTrend.labels"
|
||||
:applications="activeTrend.applications"
|
||||
:approved="activeTrend.approved"
|
||||
:avg-hours="activeTrend.avgHours"
|
||||
/>
|
||||
</article>
|
||||
<article class="panel dashboard-card donut-panel">
|
||||
<div class="card-head">
|
||||
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<DonutChart :items="spendLegend" :center-value="spendCenterValue" center-label="费用总额" />
|
||||
<p class="panel-note">* 百分比按当前时间范围内的费用金额计算</p>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card donut-panel">
|
||||
<div class="card-head">
|
||||
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<DonutChart :items="spendLegend" center-value="¥361.6K" center-label="待处理金额" />
|
||||
<p class="panel-note">* 百分比为占待处理金额比例</p>
|
||||
</article>
|
||||
<article class="panel dashboard-card donut-panel">
|
||||
<div class="card-head">
|
||||
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
|
||||
<p class="panel-note">* 近 30 天数据</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="panel dashboard-card donut-panel">
|
||||
<div class="card-head">
|
||||
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
|
||||
<p class="panel-note">* 近 30 天数据</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="content-grid bottom-grid">
|
||||
<article class="panel dashboard-card rank-panel">
|
||||
<div class="card-head">
|
||||
<h3>部门报销排行(待处理金额)<i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeDepartmentRange"
|
||||
class="card-select"
|
||||
:options="departmentRangeOptions"
|
||||
aria-label="部门排行时间范围"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content-grid bottom-grid">
|
||||
<article class="panel dashboard-card rank-panel">
|
||||
<div class="card-head">
|
||||
<h3>部门报销排行(待处理金额)<i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeDepartmentRange"
|
||||
class="card-select"
|
||||
:options="departmentRangeOptions"
|
||||
aria-label="部门排行时间范围"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<BarChart :items="rankedDepartments" />
|
||||
</article>
|
||||
|
||||
<BarChart :items="rankedDepartments" />
|
||||
</article>
|
||||
<article class="panel dashboard-card bottleneck-panel">
|
||||
<div class="card-head">
|
||||
<h3>审批瓶颈(平均处理时长) <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<article class="panel dashboard-card bottleneck-panel">
|
||||
<div class="card-head">
|
||||
<h3>审批瓶颈(平均处理时长) <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<div class="bottleneck-list">
|
||||
<div
|
||||
v-for="(item, index) in bottlenecks"
|
||||
:key="item.name"
|
||||
class="bottleneck-row"
|
||||
:style="{ '--delay': `${index * 70}ms` }"
|
||||
>
|
||||
<div class="reviewer">
|
||||
<div class="reviewer-avatar">{{ item.avatar }}</div>
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.role }}</span>
|
||||
<div class="bottleneck-list">
|
||||
<div
|
||||
v-for="(item, index) in bottlenecks"
|
||||
:key="item.name"
|
||||
class="bottleneck-row"
|
||||
:style="{ '--delay': `${index * 70}ms` }"
|
||||
>
|
||||
<div class="reviewer">
|
||||
<div class="reviewer-avatar">{{ item.avatar }}</div>
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reviewer-stats">
|
||||
<strong>{{ item.duration }}</strong>
|
||||
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reviewer-stats">
|
||||
<strong>{{ item.duration }}</strong>
|
||||
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card budget-panel">
|
||||
<div class="card-head">
|
||||
<h3>预算执行率(本月)<i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<GaugeChart
|
||||
:ratio="budgetSummary.ratio"
|
||||
:total="budgetSummary.total"
|
||||
:used="budgetSummary.used"
|
||||
:left="budgetSummary.left"
|
||||
/>
|
||||
|
||||
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<RiskObservationDashboard
|
||||
v-else-if="activeDashboard === 'risk'"
|
||||
:dashboard="riskDashboard"
|
||||
:loading="riskDashboardLoading"
|
||||
:error="riskDashboardError"
|
||||
:level-legend="riskLevelLegend"
|
||||
:source-legend="riskSourceLegend"
|
||||
:signal-ranking="riskSignalRanking"
|
||||
:daily-rows="riskDailyTrendRows"
|
||||
:window-options="riskWindowOptions"
|
||||
:active-window-days="activeRiskWindowDays"
|
||||
@update:window-days="setRiskWindowDays"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="system-observability-grid">
|
||||
<article class="panel dashboard-card system-agent-ratio-panel">
|
||||
<div class="card-head">
|
||||
<h3>智能体调用占比(日) <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<p class="card-subtitle">按天查看几个核心智能体的调用构成,比例变化比单纯总量更容易定位偏移。</p>
|
||||
|
||||
<SystemAgentRatioBar
|
||||
:labels="systemAgentDailyRatio.labels"
|
||||
:agents="systemAgentDailyRatio.agents"
|
||||
:series="systemAgentDailyRatio.series"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card system-token-pie-panel">
|
||||
<div class="card-head">
|
||||
<h3>用户 Token 消耗占比 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<p class="card-subtitle">左侧看每日 Token 消耗波动,右侧看高消耗用户,便于排查重复问答或异常长上下文。</p>
|
||||
|
||||
<div class="system-token-panel-grid">
|
||||
<SystemTokenDailyWaveChart
|
||||
:labels="systemTokenDailyWave.labels"
|
||||
:input-tokens="systemTokenDailyWave.inputTokens"
|
||||
:output-tokens="systemTokenDailyWave.outputTokens"
|
||||
:total-tokens="systemTokenDailyWave.totalTokens"
|
||||
/>
|
||||
<SystemUserTokenPie :items="systemUserTokenUsage" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card system-accuracy-panel">
|
||||
<div class="card-head">
|
||||
<h3>正确 / 错误对比 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<p class="card-subtitle">按智能体对比正确与错误次数,错误柱越靠前越需要优先追踪日志。</p>
|
||||
|
||||
<SystemAccuracyCompareBar
|
||||
:categories="systemAccuracyComparison.categories"
|
||||
:correct="systemAccuracyComparison.correct"
|
||||
:wrong="systemAccuracyComparison.wrong"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card system-tool-detail-panel">
|
||||
<div class="card-head">
|
||||
<h3>工具调用明细 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<div class="system-tool-table">
|
||||
<div
|
||||
v-for="item in systemToolDetailItems"
|
||||
:key="item.name"
|
||||
class="system-tool-row"
|
||||
>
|
||||
<div class="system-tool-row-head">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.callLabel }}</span>
|
||||
</div>
|
||||
<div class="system-tool-meter" aria-hidden="true">
|
||||
<i :style="{ width: item.width, background: item.color }"></i>
|
||||
</div>
|
||||
<div class="system-tool-row-meta">
|
||||
<span>成功率 {{ item.successRate }}%</span>
|
||||
<span>平均 {{ item.avgLatency }}</span>
|
||||
<span>{{ item.tokenLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</article>
|
||||
<aside class="system-side-stack">
|
||||
<article class="panel dashboard-card system-side-card system-login-wave-panel">
|
||||
<div class="card-head">
|
||||
<h3>用户在线波动 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<p class="card-subtitle">登录人数与互动次数的时段波动。</p>
|
||||
|
||||
<article class="panel dashboard-card budget-panel">
|
||||
<div class="card-head">
|
||||
<h3>预算执行率(本月)<i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<SystemLoginWaveChart
|
||||
compact
|
||||
:labels="systemLoginWave.labels"
|
||||
:login-users="systemLoginWave.loginUsers"
|
||||
:interactions="systemLoginWave.interactions"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<GaugeChart
|
||||
:ratio="budgetSummary.ratio"
|
||||
:total="budgetSummary.total"
|
||||
:used="budgetSummary.used"
|
||||
:left="budgetSummary.left"
|
||||
/>
|
||||
<article class="panel dashboard-card system-side-card system-duration-panel">
|
||||
<div class="card-head">
|
||||
<h3>用户使用时长 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</article>
|
||||
</div>
|
||||
<div class="duration-summary">
|
||||
<div>
|
||||
<strong>{{ systemUsageDurationSummary.average }}</strong>
|
||||
<span>平均使用时长</span>
|
||||
</div>
|
||||
<em>{{ systemUsageDurationSummary.trend }}</em>
|
||||
</div>
|
||||
|
||||
<div class="duration-meta">
|
||||
<span>中位数 {{ systemUsageDurationSummary.median }}</span>
|
||||
<span>峰值 {{ systemUsageDurationSummary.peak }}</span>
|
||||
</div>
|
||||
|
||||
<div class="duration-bars">
|
||||
<div
|
||||
v-for="item in systemUsageDurationRows"
|
||||
:key="item.label"
|
||||
class="duration-bar-row"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<i><b :style="{ width: item.width, background: item.color }"></b></i>
|
||||
<strong>{{ item.value }} 人</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card feedback-panel system-feedback-panel">
|
||||
<div class="card-head">
|
||||
<h3>用户反馈概览 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<div class="feedback-list">
|
||||
<div
|
||||
v-for="item in systemFeedbackSummary"
|
||||
:key="item.label"
|
||||
class="feedback-row"
|
||||
:class="item.tone"
|
||||
>
|
||||
<span class="feedback-icon"><i :class="item.icon"></i></span>
|
||||
<div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="panel-note">* 反馈用于衡量智能体回答和工具执行体验</p>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import TrendChart from '../components/charts/TrendChart.vue'
|
||||
import DonutChart from '../components/charts/DonutChart.vue'
|
||||
import BarChart from '../components/charts/BarChart.vue'
|
||||
import GaugeChart from '../components/charts/GaugeChart.vue'
|
||||
import SystemAccuracyCompareBar from '../components/charts/SystemAccuracyCompareBar.vue'
|
||||
import SystemAgentRatioBar from '../components/charts/SystemAgentRatioBar.vue'
|
||||
import SystemLoginWaveChart from '../components/charts/SystemLoginWaveChart.vue'
|
||||
import SystemTokenDailyWaveChart from '../components/charts/SystemTokenDailyWaveChart.vue'
|
||||
import SystemUserTokenPie from '../components/charts/SystemUserTokenPie.vue'
|
||||
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
|
||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||
|
||||
import { useOverviewView } from '../composables/useOverviewView.js'
|
||||
|
||||
defineProps({
|
||||
filteredRequests: { type: Array, required: true }
|
||||
const props = defineProps({
|
||||
filteredRequests: { type: Array, required: true },
|
||||
dashboard: { type: String, default: 'finance' },
|
||||
activeRange: { type: String, default: '近10日' },
|
||||
customRange: {
|
||||
type: Object,
|
||||
default: () => ({ start: '', end: '' })
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
activeDepartmentRange,
|
||||
activeRiskWindowDays,
|
||||
activeTrend,
|
||||
activeTrendRange,
|
||||
bottlenecks,
|
||||
@@ -145,11 +320,45 @@ const {
|
||||
departmentRangeOptions,
|
||||
kpiMetrics,
|
||||
rankedDepartments,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLoading,
|
||||
riskDailyTrendRows,
|
||||
riskLegend,
|
||||
riskKpiMetrics,
|
||||
riskLevelLegend,
|
||||
riskSignalRanking,
|
||||
riskSourceLegend,
|
||||
riskTotal,
|
||||
riskWindowOptions,
|
||||
setRiskWindowDays,
|
||||
spendCenterValue,
|
||||
spendLegend,
|
||||
systemAccuracyComparison,
|
||||
systemAgentDailyRatio,
|
||||
systemFeedbackSummary,
|
||||
systemKpiMetrics,
|
||||
systemLoginWave,
|
||||
systemTokenDailyWave,
|
||||
systemToolDetailItems,
|
||||
systemUsageDurationRows,
|
||||
systemUsageDurationSummary,
|
||||
systemUserTokenUsage,
|
||||
trendRanges
|
||||
} = useOverviewView()
|
||||
} = useOverviewView(props)
|
||||
|
||||
const activeDashboard = computed(() => {
|
||||
if (props.dashboard === 'system') return 'system'
|
||||
if (props.dashboard === 'risk') return 'risk'
|
||||
return 'finance'
|
||||
})
|
||||
const activeKpiMetrics = computed(() => (
|
||||
activeDashboard.value === 'system'
|
||||
? systemKpiMetrics.value
|
||||
: activeDashboard.value === 'risk'
|
||||
? riskKpiMetrics.value
|
||||
: kpiMetrics.value
|
||||
))
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/overview-view.css"></style>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<col class="col-money">
|
||||
<col class="col-date">
|
||||
<col class="col-score">
|
||||
<col class="col-status">
|
||||
<col v-if="showStatusColumn" class="col-status">
|
||||
<col class="col-updated">
|
||||
</colgroup>
|
||||
<thead>
|
||||
@@ -86,7 +86,7 @@
|
||||
<th>金额</th>
|
||||
<th>票据日期</th>
|
||||
<th>置信度</th>
|
||||
<th>关联状态</th>
|
||||
<th v-if="showStatusColumn">关联状态</th>
|
||||
<th>上传时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -101,7 +101,7 @@
|
||||
<td>{{ row.amount || '待补充' }}</td>
|
||||
<td>{{ row.document_date || '待补充' }}</td>
|
||||
<td>{{ formatScore(row.avg_score) }}</td>
|
||||
<td>
|
||||
<td v-if="showStatusColumn">
|
||||
<span class="status-tag" :class="row.status === 'linked' ? 'completed' : 'warning'">
|
||||
{{ row.status_label }}
|
||||
</span>
|
||||
@@ -136,109 +136,95 @@
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<article v-else class="receipt-folder-detail panel">
|
||||
<header class="receipt-detail-head">
|
||||
<button class="back-btn" type="button" @click="backToList">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
<span>返回票据夹</span>
|
||||
</button>
|
||||
<div>
|
||||
<span class="assistant-badge">票据详情</span>
|
||||
<h2>{{ detailForm.file_name }}</h2>
|
||||
<p>{{ selectedReceipt?.summary || '核对并修正票据基础信息,后续关联报销单时会带入当前票据。' }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="detailLoading" class="detail-loading">
|
||||
<TableLoadingState title="票据详情加载中" message="正在读取票据源文件与 OCR 元数据" icon="mdi mdi-receipt-text-outline" floating />
|
||||
</div>
|
||||
|
||||
<div v-else class="receipt-detail-layout">
|
||||
<section class="receipt-basic-panel">
|
||||
<header>
|
||||
<strong>基本票据信息</strong>
|
||||
<button class="apply-btn" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<EnterpriseDetailPage
|
||||
v-else
|
||||
variant="receipt-folder-detail"
|
||||
back-label="返回票据夹"
|
||||
:loading="detailLoading"
|
||||
loading-title="票据详情加载中"
|
||||
loading-message="正在读取票据源文件与 OCR 元数据"
|
||||
loading-icon="mdi mdi-receipt-text-outline"
|
||||
@back="backToList"
|
||||
>
|
||||
<template #main>
|
||||
<EnterpriseDetailCard class="receipt-basic-panel" title="票据关键字段">
|
||||
<template #actions>
|
||||
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<div class="receipt-form-grid">
|
||||
<label>
|
||||
<span>票据类型</span>
|
||||
<input v-model="detailForm.document_type_label" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>费用场景</span>
|
||||
<input v-model="detailForm.scene_label" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>金额</span>
|
||||
<input v-model="detailForm.amount" type="text" placeholder="待补充" />
|
||||
</label>
|
||||
<label>
|
||||
<span>票据日期</span>
|
||||
<input v-model="detailForm.document_date" type="text" placeholder="YYYY-MM-DD" />
|
||||
</label>
|
||||
<label>
|
||||
<span>商户</span>
|
||||
<input v-model="detailForm.merchant_name" type="text" placeholder="待补充" />
|
||||
</label>
|
||||
<label>
|
||||
<span>OCR 置信度</span>
|
||||
<input :value="formatScore(selectedReceipt?.avg_score)" type="text" disabled />
|
||||
</label>
|
||||
<label class="field-wide">
|
||||
<span>摘要</span>
|
||||
<textarea v-model="detailForm.summary" rows="3" />
|
||||
<div class="receipt-key-grid">
|
||||
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
|
||||
<span>{{ field.label }}</span>
|
||||
<input
|
||||
:value="field.value"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
@input="updateReceiptField(field, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="receipt-field-list">
|
||||
<div class="receipt-field-list-head">
|
||||
<strong>识别字段</strong>
|
||||
<button class="ghost-btn" type="button" @click="addField">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新增字段</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="(field, index) in detailForm.fields" :key="`${field.key}-${index}`" class="receipt-field-row">
|
||||
<input v-model="field.label" type="text" placeholder="字段名" />
|
||||
<input v-model="field.value" type="text" placeholder="字段值" />
|
||||
<button type="button" aria-label="删除字段" @click="removeField(index)">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="receipt-other-info">
|
||||
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse">
|
||||
<ElCollapseItem name="other">
|
||||
<template #title>
|
||||
<div class="receipt-collapse-title">
|
||||
<strong>其他信息</strong>
|
||||
<small>{{ editableOtherFields.length }} 项</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section class="receipt-preview-panel">
|
||||
<header>
|
||||
<strong>原始文件</strong>
|
||||
<button v-if="selectedReceipt?.source_url" class="preview-source-btn" type="button" @click="openSourceFile">
|
||||
打开源文件
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="editableOtherFields.length" class="receipt-other-scroll">
|
||||
<div
|
||||
v-for="(field, index) in editableOtherFields"
|
||||
:key="`${field.key || field.label}-${index}`"
|
||||
class="receipt-edit-field-row"
|
||||
>
|
||||
<label>
|
||||
<span>字段名</span>
|
||||
<input v-model="field.label" type="text" placeholder="字段名" />
|
||||
</label>
|
||||
<label>
|
||||
<span>字段值</span>
|
||||
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="receipt-field-empty">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<span>暂无其他可编辑信息</span>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<template #side>
|
||||
<EnterpriseDetailCard class="receipt-preview-panel" title="原始文件">
|
||||
<div class="receipt-preview-box">
|
||||
<img v-if="previewKind === 'image' && previewObjectUrl" :src="previewObjectUrl" alt="票据预览" />
|
||||
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
|
||||
<div v-else class="preview-empty">
|
||||
<i class="mdi mdi-file-eye-outline"></i>
|
||||
<strong>当前文件暂不支持内嵌预览</strong>
|
||||
<p>可以点击右上角打开源文件查看。</p>
|
||||
<p>请确认源文件是否支持预览,或重新上传清晰图片/PDF。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</EnterpriseDetailCard>
|
||||
</template>
|
||||
|
||||
<footer class="receipt-detail-foot">
|
||||
<button class="ghost-btn" type="button" @click="backToList">返回列表</button>
|
||||
<button class="danger-btn" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
|
||||
<template #actions>
|
||||
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
|
||||
<i class="mdi mdi-delete-outline"></i>
|
||||
<span>{{ deleting ? '删除中' : '删除票据' }}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
</EnterpriseDetailPage>
|
||||
|
||||
<ElDialog
|
||||
v-model="associateDialogOpen"
|
||||
@@ -301,9 +287,12 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
|
||||
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
|
||||
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||
|
||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
|
||||
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
@@ -315,12 +304,13 @@ import {
|
||||
fetchReceiptFolderItems,
|
||||
updateReceiptFolderItem
|
||||
} from '../services/receiptFolder.js'
|
||||
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
|
||||
|
||||
const NEW_CLAIM_VALUE = '__new_claim__'
|
||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
const emit = defineEmits(['open-assistant'])
|
||||
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
|
||||
|
||||
const activeStatus = ref('unlinked')
|
||||
const activeStatus = ref('all')
|
||||
const keyword = ref('')
|
||||
const receipts = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -338,6 +328,7 @@ const selectedReceiptIds = ref([])
|
||||
const targetDraftId = ref(NEW_CLAIM_VALUE)
|
||||
const draftClaims = ref([])
|
||||
const associateBusy = ref(false)
|
||||
const expandedFieldPanels = ref([])
|
||||
|
||||
const detailForm = reactive({
|
||||
file_name: '',
|
||||
@@ -356,12 +347,16 @@ const detailMode = computed(() => Boolean(selectedReceipt.value))
|
||||
const unlinkedReceipts = computed(() => receipts.value.filter((item) => item.status !== 'linked'))
|
||||
const linkedReceipts = computed(() => receipts.value.filter((item) => item.status === 'linked'))
|
||||
const receiptTabs = computed(() => [
|
||||
{ value: 'all', label: '全部', count: receipts.value.length },
|
||||
{ value: 'unlinked', label: '未关联票据', count: unlinkedReceipts.value.length },
|
||||
{ value: 'linked', label: '已关联票据', count: linkedReceipts.value.length }
|
||||
])
|
||||
const activeRows = computed(() => (
|
||||
activeStatus.value === 'linked' ? linkedReceipts.value : unlinkedReceipts.value
|
||||
))
|
||||
const activeRows = computed(() => {
|
||||
if (activeStatus.value === 'linked') return linkedReceipts.value
|
||||
if (activeStatus.value === 'unlinked') return unlinkedReceipts.value
|
||||
return receipts.value
|
||||
})
|
||||
const showStatusColumn = computed(() => activeStatus.value !== 'linked')
|
||||
const filteredRows = computed(() => {
|
||||
const normalized = keyword.value.trim().toLowerCase()
|
||||
if (!normalized) return activeRows.value
|
||||
@@ -382,15 +377,67 @@ const visibleRows = computed(() => {
|
||||
})
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||
const emptyTitle = computed(() => keyword.value.trim() ? '没有符合条件的票据' : `${activeStatus.value === 'linked' ? '已关联票据' : '未关联票据'}为空`)
|
||||
const emptyDesc = computed(() => activeStatus.value === 'linked'
|
||||
? '已关联到报销单的票据会显示在这里,方便后续回溯。'
|
||||
: '上传并完成 OCR 的票据会先进入这里,稍后可以再关联到报销草稿。'
|
||||
)
|
||||
const emptyTips = computed(() => activeStatus.value === 'linked'
|
||||
? ['可从报销明细或票据夹关联流程形成已关联票据', '点击票据可查看原始文件与识别字段']
|
||||
: ['票据不会因为未立即建单而丢失', '可多选票据后一次性带入报销对话']
|
||||
)
|
||||
const activeStatusLabel = computed(() => {
|
||||
if (activeStatus.value === 'linked') return '已关联票据'
|
||||
if (activeStatus.value === 'unlinked') return '未关联票据'
|
||||
return '全部票据'
|
||||
})
|
||||
const emptyTitle = computed(() => keyword.value.trim() ? '没有符合条件的票据' : `${activeStatusLabel.value}为空`)
|
||||
const emptyDesc = computed(() => {
|
||||
if (activeStatus.value === 'linked') return '已关联到报销单的票据会显示在这里,方便后续回溯。'
|
||||
if (activeStatus.value === 'unlinked') return '上传并完成 OCR 的票据会先进入这里,稍后可以再关联到报销草稿。'
|
||||
return '上传并完成 OCR 的票据会统一进入票据夹,可按关联状态继续筛选。'
|
||||
})
|
||||
const emptyTips = computed(() => {
|
||||
if (activeStatus.value === 'linked') return ['可从报销明细或票据夹关联流程形成已关联票据', '点击票据可查看原始文件与识别字段']
|
||||
if (activeStatus.value === 'unlinked') return ['票据不会因为未立即建单而丢失', '可多选票据后一次性带入报销对话']
|
||||
return ['全部视图同时展示未关联和已关联票据', '可切换标签快速定位待处理票据']
|
||||
})
|
||||
const receiptDetailSubtitle = computed(() => {
|
||||
const receipt = selectedReceipt.value || {}
|
||||
const documentType = String(detailForm.document_type_label || receipt.document_type_label || '').trim()
|
||||
const scene = String(detailForm.scene_label || receipt.scene_label || '').trim()
|
||||
const merchant = String(detailForm.merchant_name || receipt.merchant_name || '').trim()
|
||||
const sceneLabel = scene && /^[((].*[))]$/.test(scene) ? scene : (scene ? `(${scene})` : '')
|
||||
const parts = [documentType, sceneLabel, merchant].filter(Boolean)
|
||||
return parts.length
|
||||
? parts.join(';')
|
||||
: (selectedReceipt.value?.summary || '核对并修正票据基础信息,后续关联报销单时会带入当前票据。')
|
||||
})
|
||||
const receiptDetailTitle = computed(() => (
|
||||
String(selectedReceipt.value?.file_name || detailForm.file_name || '').trim() || '票据详情'
|
||||
))
|
||||
const isTrainTicket = computed(() => {
|
||||
const type = String(detailForm.document_type || selectedReceipt.value?.document_type || '').trim().toLowerCase()
|
||||
const label = [
|
||||
detailForm.document_type_label,
|
||||
selectedReceipt.value?.document_type_label,
|
||||
detailForm.scene_label,
|
||||
selectedReceipt.value?.scene_label
|
||||
].filter(Boolean).join('')
|
||||
return type === 'train_ticket' || /火车|高铁|动车|铁路|电子客票/.test(label)
|
||||
})
|
||||
const {
|
||||
buildDetailPayload,
|
||||
editableOtherFields,
|
||||
ensureEditableReceiptFields,
|
||||
keyReceiptFields,
|
||||
syncEditableFieldsToTopLevel,
|
||||
updateReceiptField
|
||||
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
|
||||
const receiptDetailTopBarPayload = computed(() => (
|
||||
detailMode.value
|
||||
? {
|
||||
view: {
|
||||
eyebrow: '票据详情',
|
||||
title: receiptDetailTitle.value,
|
||||
desc: receiptDetailSubtitle.value
|
||||
},
|
||||
alerts: [],
|
||||
kpis: []
|
||||
}
|
||||
: null
|
||||
))
|
||||
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '')
|
||||
const canProceedAssociate = computed(() => (
|
||||
associateStep.value === 1
|
||||
@@ -406,6 +453,14 @@ watch([activeStatus, keyword, pageSize], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch(detailMode, (value) => {
|
||||
emit('detail-open-change', value)
|
||||
}, { immediate: true })
|
||||
|
||||
watch(receiptDetailTopBarPayload, (payload) => {
|
||||
emit('detail-topbar-change', payload)
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
void reloadReceipts()
|
||||
})
|
||||
@@ -460,6 +515,9 @@ function fillDetailForm(detail) {
|
||||
detailForm.fields = Array.isArray(detail.fields)
|
||||
? detail.fields.map((field) => ({ ...field }))
|
||||
: []
|
||||
expandedFieldPanels.value = []
|
||||
ensureEditableReceiptFields()
|
||||
syncEditableFieldsToTopLevel()
|
||||
}
|
||||
|
||||
async function loadPreview(detail) {
|
||||
@@ -479,42 +537,16 @@ function revokePreviewUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSourceFile() {
|
||||
if (!selectedReceipt.value?.source_url) return
|
||||
const blob = await fetchReceiptFolderAsset(selectedReceipt.value.source_url)
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
window.open(objectUrl, '_blank', 'noopener,noreferrer')
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30000)
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
selectedReceipt.value = null
|
||||
revokePreviewUrl()
|
||||
}
|
||||
|
||||
function addField() {
|
||||
detailForm.fields.push({ key: '', label: '', value: '' })
|
||||
}
|
||||
|
||||
function removeField(index) {
|
||||
detailForm.fields.splice(index, 1)
|
||||
}
|
||||
|
||||
async function saveDetail() {
|
||||
if (!selectedReceipt.value?.id || savingDetail.value) return
|
||||
savingDetail.value = true
|
||||
try {
|
||||
const updated = await updateReceiptFolderItem(selectedReceipt.value.id, {
|
||||
document_type: detailForm.document_type,
|
||||
document_type_label: detailForm.document_type_label,
|
||||
scene_code: detailForm.scene_code,
|
||||
scene_label: detailForm.scene_label,
|
||||
summary: detailForm.summary,
|
||||
amount: detailForm.amount,
|
||||
document_date: detailForm.document_date,
|
||||
merchant_name: detailForm.merchant_name,
|
||||
fields: detailForm.fields
|
||||
})
|
||||
const updated = await updateReceiptFolderItem(selectedReceipt.value.id, buildDetailPayload())
|
||||
selectedReceipt.value = updated
|
||||
fillDetailForm(updated)
|
||||
await reloadReceipts()
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
class="tool-btn composer-side-btn"
|
||||
:class="{ active: composerDatePickerOpen }"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="选择业务发生时间"
|
||||
aria-label="选择日期"
|
||||
:aria-expanded="composerDatePickerOpen"
|
||||
@click.stop="toggleComposerDatePicker"
|
||||
>
|
||||
@@ -211,7 +211,7 @@
|
||||
v-if="composerDatePickerOpen"
|
||||
class="composer-date-popover"
|
||||
role="dialog"
|
||||
aria-label="业务发生时间"
|
||||
aria-label="日期选择"
|
||||
@click.stop
|
||||
>
|
||||
<div class="composer-date-mode-tabs">
|
||||
@@ -235,13 +235,13 @@
|
||||
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
|
||||
<label class="composer-date-field">
|
||||
<span>日期</span>
|
||||
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange" />
|
||||
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange('single')" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="composer-date-fields composer-date-fields-range">
|
||||
<label class="composer-date-field">
|
||||
<span>开始</span>
|
||||
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange" />
|
||||
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange('range-start')" />
|
||||
</label>
|
||||
<span class="composer-date-range-sep">至</span>
|
||||
<label class="composer-date-field">
|
||||
@@ -250,26 +250,13 @@
|
||||
v-model="composerRangeEndDate"
|
||||
type="date"
|
||||
:min="composerRangeStartDate"
|
||||
@change="handleComposerDateInputChange"
|
||||
@change="handleComposerDateInputChange('range-end')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
|
||||
请确认结束日期不早于开始日期。
|
||||
</p>
|
||||
<div class="composer-date-popover-actions">
|
||||
<button type="button" class="composer-date-cancel-btn" @click="closeComposerDatePicker">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-date-apply-btn"
|
||||
:disabled="!composerCanApplyDateSelection"
|
||||
@click="applyComposerDateSelection"
|
||||
>
|
||||
插入标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canShowTravelCalculator" class="travel-calculator-anchor">
|
||||
@@ -353,7 +340,7 @@
|
||||
type="button"
|
||||
class="composer-biz-time-tag-remove"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="移除业务发生时间"
|
||||
aria-label="移除日期"
|
||||
@click="removeComposerBusinessTimeTag(tag.id)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
|
||||
@@ -88,42 +88,24 @@
|
||||
<article v-if="!isApplicationDocument" class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>附加说明</h3>
|
||||
<p>用于说明本次出差或办事目的,例如去哪里、拜访谁、处理什么事项。</p>
|
||||
<h3>关联单据信息</h3>
|
||||
<p>展示本次报销关联的前置申请,便于核对申请内容、天数、事由和预计金额。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canEditDetailNote" class="detail-note-editor">
|
||||
<textarea
|
||||
v-model="detailNoteEditorView"
|
||||
maxlength="500"
|
||||
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
|
||||
aria-label="附加说明"
|
||||
></textarea>
|
||||
<div class="detail-note-editor-meta">
|
||||
<span>仅草稿待提交状态可编辑,提交后将作为明确说明展示。</span>
|
||||
<div class="detail-note-actions">
|
||||
<button
|
||||
v-if="detailNoteDirty"
|
||||
class="inline-action"
|
||||
type="button"
|
||||
:disabled="savingDetailNote"
|
||||
@click="resetDetailNote"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
class="inline-action primary"
|
||||
type="button"
|
||||
:disabled="!detailNoteDirty || savingDetailNote"
|
||||
@click="saveDetailNote"
|
||||
>
|
||||
{{ savingDetailNote ? '保存中' : '保存说明' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="relatedApplicationFactItems.length" class="application-detail-facts related-application-facts">
|
||||
<div
|
||||
v-for="item in relatedApplicationFactItems"
|
||||
:key="item.key"
|
||||
class="application-detail-fact related-application-fact"
|
||||
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail-note readonly">
|
||||
<p>{{ detailNote }}</p>
|
||||
<div v-else class="related-application-empty">
|
||||
<strong>暂未识别到关联申请单</strong>
|
||||
<p>差旅报销应先关联已审批的申请单,请核对本单据是否由申请单生成或已在智能录入中完成关联。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="detail-card panel">
|
||||
@@ -475,6 +457,10 @@
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
<RiskObservationEvidenceCard
|
||||
v-if="request.claimId"
|
||||
:claim-id="request.claimId"
|
||||
/>
|
||||
<EmployeeProfileRiskCard
|
||||
v-if="showEmployeeRiskProfile"
|
||||
:profile="employeeRiskProfile"
|
||||
|
||||
@@ -152,6 +152,23 @@ export default {
|
||||
const canToggleRiskRuleEnabled = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
|
||||
)
|
||||
const canEditRiskRuleDraft = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
(canEditSelected.value || canManageSelected.value) &&
|
||||
!detailBusy.value &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
|
||||
)
|
||||
const canCreateRiskRuleRevision = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
(canEditSelected.value || canManageSelected.value) &&
|
||||
!detailBusy.value &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!riskRuleGenerationFailed.value &&
|
||||
Boolean(normalizeText(selectedSkill.value?.publishedVersion).replace('-', ''))
|
||||
)
|
||||
const canEditMarkdown = computed(() => selectedSkillIsRule.value && canEditSelected.value)
|
||||
const isDisplayingWorkingVersion = computed(
|
||||
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||
@@ -330,10 +347,16 @@ export default {
|
||||
riskRuleReturnOpen,
|
||||
riskRulePublishOpen,
|
||||
riskRuleReturnNote,
|
||||
riskRuleEditOpen,
|
||||
riskRuleEditMode,
|
||||
riskRuleEditForm,
|
||||
resetRiskRuleActionDialogs,
|
||||
openRiskRuleTestDialog,
|
||||
closeRiskRuleTestDialog,
|
||||
handleRiskRuleReportSaved,
|
||||
openRiskRuleEditDialog,
|
||||
closeRiskRuleEditDialog,
|
||||
submitRiskRuleEdit,
|
||||
openDeleteRiskRuleDialog,
|
||||
closeDeleteRiskRuleDialog,
|
||||
deleteSelectedRiskRule,
|
||||
@@ -353,6 +376,8 @@ export default {
|
||||
canReturnRiskRule,
|
||||
canPublishRiskRule,
|
||||
canToggleRiskRuleEnabled,
|
||||
canEditRiskRuleDraft,
|
||||
canCreateRiskRuleRevision,
|
||||
riskRuleTestPassed,
|
||||
refreshCurrentAssets,
|
||||
loadSelectedAssetDetail,
|
||||
@@ -719,6 +744,9 @@ export default {
|
||||
riskRuleReturnOpen,
|
||||
riskRulePublishOpen,
|
||||
riskRuleReturnNote,
|
||||
riskRuleEditOpen,
|
||||
riskRuleEditMode,
|
||||
riskRuleEditForm,
|
||||
riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS,
|
||||
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
|
||||
showReviewNote,
|
||||
@@ -762,6 +790,9 @@ export default {
|
||||
openRiskRuleTestDialog,
|
||||
closeRiskRuleTestDialog,
|
||||
handleRiskRuleReportSaved,
|
||||
openRiskRuleEditDialog,
|
||||
closeRiskRuleEditDialog,
|
||||
submitRiskRuleEdit,
|
||||
openDeleteRiskRuleDialog,
|
||||
closeDeleteRiskRuleDialog,
|
||||
deleteSelectedRiskRule,
|
||||
|
||||
@@ -16,8 +16,13 @@ import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSu
|
||||
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
|
||||
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
|
||||
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
|
||||
import {
|
||||
buildOperationFeedbackPayload,
|
||||
normalizeOperationFeedbackContext
|
||||
} from '../../composables/useOperationFeedback.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { createOperationFeedback } from '../../services/operationFeedback.js'
|
||||
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
import { renderMarkdown } from '../../utils/markdown.js'
|
||||
import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js'
|
||||
@@ -46,6 +51,7 @@ import {
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
calculateTravelReimbursement,
|
||||
createExpenseClaimItem,
|
||||
fetchExpenseClaims,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
fetchExpenseClaimDetail,
|
||||
@@ -526,6 +532,10 @@ export default {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
initialSessionType: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
entrySource: {
|
||||
type: String,
|
||||
default: 'requests'
|
||||
@@ -543,7 +553,7 @@ export default {
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
emits: ['close', 'draft-saved'],
|
||||
emits: ['close', 'draft-saved', 'request-updated'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const { currentUser } = useSystemState()
|
||||
@@ -605,14 +615,42 @@ export default {
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
commitApplicationPreviewDateEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
setApplicationPreviewDateMode,
|
||||
canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
} = useApplicationPreviewEditor({
|
||||
persistSessionState,
|
||||
toast
|
||||
})
|
||||
|
||||
function applyLinkedApplicationPreviewDateSelection(selection) {
|
||||
const editor = applicationPreviewEditor.value
|
||||
if (editor.fieldKey !== 'time' || !editor.messageId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const targetMessage = messages.value.find((item) =>
|
||||
String(item.id || '') === String(editor.messageId || '')
|
||||
)
|
||||
if (!targetMessage?.applicationPreview) {
|
||||
return false
|
||||
}
|
||||
|
||||
applicationPreviewEditor.value = {
|
||||
...editor,
|
||||
dateMode: selection.mode === 'range' ? 'range' : 'single',
|
||||
singleDate: selection.startDate,
|
||||
rangeStartDate: selection.startDate,
|
||||
rangeEndDate: selection.endDate || selection.startDate
|
||||
}
|
||||
return commitApplicationPreviewDateEditor(targetMessage)
|
||||
}
|
||||
|
||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
|
||||
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
|
||||
@@ -884,8 +922,34 @@ export default {
|
||||
buildReviewSlotMap,
|
||||
isValidIsoDateString,
|
||||
buildLocallySyncedReviewPayload,
|
||||
formatDateInputValue
|
||||
formatDateInputValue,
|
||||
onComposerDateSelection: applyLinkedApplicationPreviewDateSelection
|
||||
})
|
||||
|
||||
function syncComposerDateFromApplicationEditor() {
|
||||
const editor = applicationPreviewEditor.value
|
||||
const today = formatDateInputValue()
|
||||
composerDateMode.value = editor.dateMode === 'range' ? 'range' : 'single'
|
||||
composerSingleDate.value = editor.singleDate || today
|
||||
composerRangeStartDate.value = editor.rangeStartDate || composerSingleDate.value || today
|
||||
composerRangeEndDate.value = editor.rangeEndDate || composerRangeStartDate.value || today
|
||||
composerDatePickerOpen.value = true
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
|
||||
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
|
||||
openApplicationPreviewEditor(message, fieldKey, value)
|
||||
if (fieldKey === 'time' && isApplicationPreviewEditing(message, 'time')) {
|
||||
syncComposerDateFromApplicationEditor()
|
||||
}
|
||||
}
|
||||
|
||||
watch(composerDatePickerOpen, (open, previousOpen) => {
|
||||
if (!open && previousOpen && applicationPreviewEditor.value.fieldKey === 'time') {
|
||||
cancelApplicationPreviewEditor()
|
||||
}
|
||||
})
|
||||
|
||||
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
|
||||
const {
|
||||
fileInputMode,
|
||||
@@ -918,6 +982,7 @@ export default {
|
||||
reviewActionBusy,
|
||||
toast,
|
||||
fileInputRef,
|
||||
createExpenseClaimItem,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
@@ -939,6 +1004,32 @@ export default {
|
||||
composerFilesExpanded,
|
||||
guidedFlowState
|
||||
}
|
||||
const promptedOperationFeedbackRunIds = new Set()
|
||||
|
||||
function emitOperationCompleted(payload = {}, extras = {}) {
|
||||
const runId = String(payload?.run_id || payload?.runId || '').trim()
|
||||
const operationStatus = String(payload?.status || '').trim()
|
||||
if (!runId || promptedOperationFeedbackRunIds.has(runId) || operationStatus !== 'succeeded') {
|
||||
return null
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
promptedOperationFeedbackRunIds.add(runId)
|
||||
return normalizeOperationFeedbackContext({
|
||||
run_id: runId,
|
||||
conversation_id: String(payload?.conversation_id || payload?.conversationId || conversationId.value || '').trim(),
|
||||
user_id: resolveCurrentUserId(),
|
||||
selected_agent: String(payload?.selected_agent || payload?.selectedAgent || '').trim(),
|
||||
source: 'user_message',
|
||||
session_type: activeSessionType.value,
|
||||
operation_type: String(extras.operationType || 'assistant_round').trim(),
|
||||
operation_status: operationStatus,
|
||||
status: operationStatus,
|
||||
route_reason: String(payload?.route_reason || payload?.routeReason || '').trim(),
|
||||
entry_source: props.entrySource,
|
||||
trace_summary: payload?.trace_summary || payload?.traceSummary || null,
|
||||
result_summary: String(result.answer || result.message || '').trim()
|
||||
}, currentUser.value || {})
|
||||
}
|
||||
const {
|
||||
confirmPendingAttachmentAssociationInternal,
|
||||
submitComposerInternal
|
||||
@@ -1016,6 +1107,8 @@ export default {
|
||||
startSemanticFlowPreview,
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
emitOperationCompleted,
|
||||
emitRequestUpdated: (payload) => emit('request-updated', payload),
|
||||
toast
|
||||
})
|
||||
const canSubmit = computed(
|
||||
@@ -1757,6 +1850,121 @@ export default {
|
||||
return buildApplicationPreviewFooterMessage(message.applicationPreview)
|
||||
}
|
||||
|
||||
function isApplicationDraftPayload(draftPayload) {
|
||||
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
}
|
||||
|
||||
function resolveDraftPayloadBodyField(draftPayload, label) {
|
||||
const body = String(draftPayload?.body || '')
|
||||
const pattern = new RegExp(`^${label}:(.+)$`, 'm')
|
||||
return String(body.match(pattern)?.[1] || '').trim()
|
||||
}
|
||||
|
||||
function resolveApplicationDraftStatusLabel(draftPayload) {
|
||||
const status = String(draftPayload?.status || '').trim()
|
||||
if (status === 'submitted') return '审批中'
|
||||
return status || '已生成'
|
||||
}
|
||||
|
||||
function buildApplicationDraftSummaryItems(draftPayload) {
|
||||
if (!isApplicationDraftPayload(draftPayload)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{ label: '单号', value: String(draftPayload?.claim_no || '').trim() || '待生成' },
|
||||
{ label: '类型', value: String(draftPayload?.title || '').trim() || '费用申请' },
|
||||
{ label: '节点', value: String(draftPayload?.approval_stage || '').trim() || '直属领导审批' },
|
||||
{ label: '时间', value: resolveDraftPayloadBodyField(draftPayload, '发生时间') },
|
||||
{ label: '费用', value: resolveDraftPayloadBodyField(draftPayload, '用户预估费用') }
|
||||
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
|
||||
}
|
||||
|
||||
function updateMessageOperationFeedback(message, patch = {}) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
}
|
||||
messages.value = messages.value.map((item) => (
|
||||
item.id === message.id
|
||||
? {
|
||||
...item,
|
||||
operationFeedback: {
|
||||
...(item.operationFeedback || {}),
|
||||
...patch
|
||||
}
|
||||
}
|
||||
: item
|
||||
))
|
||||
}
|
||||
|
||||
function isOperationFeedbackVisible(message) {
|
||||
const feedback = message?.operationFeedback || null
|
||||
return Boolean(
|
||||
feedback?.context
|
||||
&& !feedback.dismissed
|
||||
)
|
||||
}
|
||||
|
||||
function dismissOperationFeedbackForMessage(message) {
|
||||
updateMessageOperationFeedback(message, {
|
||||
dismissed: true,
|
||||
error: ''
|
||||
})
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
async function submitOperationFeedbackForMessage(message, feedback = {}) {
|
||||
const rating = Number(feedback.rating || 0)
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||
updateMessageOperationFeedback(message, { error: '请选择 1 到 5 星评分。' })
|
||||
return
|
||||
}
|
||||
|
||||
const context = message?.operationFeedback?.context || null
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: true,
|
||||
rating,
|
||||
reason: String(feedback.reason || '').trim(),
|
||||
error: ''
|
||||
})
|
||||
try {
|
||||
await createOperationFeedback(
|
||||
buildOperationFeedbackPayload(context, feedback, currentUser.value || {})
|
||||
)
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: false,
|
||||
submitted: true,
|
||||
dismissed: false,
|
||||
rating,
|
||||
reason: String(feedback.reason || '').trim(),
|
||||
error: ''
|
||||
})
|
||||
persistSessionState()
|
||||
} catch (error) {
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: false,
|
||||
error: error?.message || '评价提交失败,请稍后重试。'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function openApplicationDraftDetail(message) {
|
||||
const draftPayload = message?.draftPayload || {}
|
||||
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
if (!claimId) {
|
||||
toast('暂未获取到申请单据 ID,稍后可在单据中心查看。')
|
||||
return
|
||||
}
|
||||
await router.push({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewMissingFields(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return []
|
||||
@@ -1818,6 +2026,7 @@ export default {
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true,
|
||||
skipScopeGuard: true,
|
||||
feedbackOperationType: 'submit_application',
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
user_input_text: applicationSubmitText
|
||||
@@ -2181,10 +2390,21 @@ export default {
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveApplicationPreviewMissingFields,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor: openApplicationPreviewEditorFromUi,
|
||||
commitApplicationPreviewEditor,
|
||||
commitApplicationPreviewDateEditor,
|
||||
setApplicationPreviewDateMode,
|
||||
canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown,
|
||||
buildApplicationPreviewFooterText,
|
||||
isApplicationDraftPayload,
|
||||
resolveApplicationDraftStatusLabel,
|
||||
buildApplicationDraftSummaryItems,
|
||||
openApplicationDraftDetail,
|
||||
isOperationFeedbackVisible,
|
||||
dismissOperationFeedbackForMessage,
|
||||
submitOperationFeedbackForMessage,
|
||||
runWelcomeQuickAction: runShortcut,
|
||||
handleSuggestedAction,
|
||||
isSuggestedActionSelected,
|
||||
@@ -2297,7 +2517,7 @@ export default {
|
||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import TravelRequestApprovalDialog from '../../components/travel/TravelRequestAp
|
||||
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
||||
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
||||
import EmployeeProfileRiskCard from '../../components/travel/EmployeeProfileRiskCard.vue'
|
||||
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
|
||||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||||
import {
|
||||
approveExpenseClaim,
|
||||
@@ -39,7 +40,10 @@ import {
|
||||
buildLeaderApprovalInfo,
|
||||
resolveGeneratedDraftClaimNo
|
||||
} from '../../utils/applicationApproval.js'
|
||||
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
|
||||
import {
|
||||
buildApplicationDetailFactItems,
|
||||
buildRelatedApplicationFactItems
|
||||
} from '../../utils/expenseApplicationDetail.js'
|
||||
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
@@ -374,6 +378,7 @@ export default {
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect,
|
||||
EmployeeProfileRiskCard,
|
||||
RiskObservationEvidenceCard,
|
||||
TravelRequestApprovalDialog,
|
||||
TravelRequestBudgetAnalysis,
|
||||
TravelRequestDeleteDialog,
|
||||
@@ -793,6 +798,7 @@ export default {
|
||||
return formatCurrency(total)
|
||||
})
|
||||
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
||||
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const expenseTableColumnCount = computed(
|
||||
@@ -1920,7 +1926,7 @@ export default {
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalOpinionHint,
|
||||
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
||||
applicationDetailFactItems,
|
||||
applicationDetailFactItems, relatedApplicationFactItems,
|
||||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
|
||||
@@ -3,7 +3,8 @@ export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
daily_risk_scan: '每日风险巡检',
|
||||
global_risk_scan: '全局风险巡检',
|
||||
global_risk_scan: '财务风险图谱巡检',
|
||||
employee_behavior_profile_scan: '员工行为画像巡检',
|
||||
weekly_ar_summary: '周度应收账龄汇总',
|
||||
weekly_expense_report: '周度费用洞察',
|
||||
rule_review_digest: '规则待审摘要',
|
||||
@@ -16,6 +17,7 @@ const TASK_TYPE_LABELS = {
|
||||
const TASK_TYPE_SKILL_CATEGORIES = {
|
||||
daily_risk_scan: '评估',
|
||||
global_risk_scan: '评估',
|
||||
employee_behavior_profile_scan: '评估',
|
||||
weekly_ar_summary: '整理',
|
||||
weekly_expense_report: '整理',
|
||||
rule_review_digest: '升级',
|
||||
|
||||
@@ -247,28 +247,39 @@ export function resolveRiskRuleConditionSummary(payload) {
|
||||
|
||||
export function resolveRiskRuleFlow(payload, fields) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const flowModel = resolveFlowModel(payload)
|
||||
const flow = metadata && typeof metadata.flow === 'object' ? metadata.flow : {}
|
||||
const fieldSummary = buildRiskRuleFieldSummary(fields)
|
||||
const conditionSummary = resolveRiskRuleConditionSummary(payload)
|
||||
const severityLabel = resolveRiskRuleSeverityLabel(payload)
|
||||
const isCityRouteRule = isCityRouteConsistencyPayload(payload)
|
||||
const modelNodes = Array.isArray(flowModel?.nodes) ? flowModel.nodes : []
|
||||
const startNode = modelNodes.find((node) => node?.type === 'start')
|
||||
const evidenceNode = modelNodes.find((node) => node?.type === 'evidence')
|
||||
const riskNode = modelNodes.find((node) => node?.type === 'risk')
|
||||
const passNode = modelNodes.find((node) => node?.type === 'pass')
|
||||
|
||||
return {
|
||||
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
|
||||
evidence: isCityRouteRule
|
||||
start: normalizeRiskRuleText(startNode?.description) || normalizeRiskRuleText(flow.start) || '业务单据提交',
|
||||
evidence: normalizeRiskRuleText(evidenceNode?.description) || (isCityRouteRule
|
||||
? CITY_ROUTE_FLOW_EVIDENCE
|
||||
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
|
||||
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`),
|
||||
decision: isCityRouteRule
|
||||
? CITY_ROUTE_FLOW_DECISION
|
||||
: normalizeRiskRuleText(flow.decision) || conditionSummary,
|
||||
basis: conditionSummary,
|
||||
...resolveRiskRuleFlowDetails(payload, fields),
|
||||
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
|
||||
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
|
||||
...resolveRiskRuleFlowDetails(payload, fields, flowModel),
|
||||
flowModel,
|
||||
pass: normalizeRiskRuleText(passNode?.description) || normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
|
||||
fail: normalizeRiskRuleText(riskNode?.description) || normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskRuleFlowDetails(payload, fields) {
|
||||
function resolveRiskRuleFlowDetails(payload, fields, flowModel = null) {
|
||||
const modelDetails = resolveFlowModelDetails(flowModel, fields)
|
||||
if (modelDetails) {
|
||||
return modelDetails
|
||||
}
|
||||
const params = payload && typeof payload === 'object' && payload.params && typeof payload.params === 'object'
|
||||
? payload.params
|
||||
: {}
|
||||
@@ -283,6 +294,44 @@ function resolveRiskRuleFlowDetails(payload, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFlowModel(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
|
||||
const flowModel = payload.flow_model && typeof payload.flow_model === 'object'
|
||||
? payload.flow_model
|
||||
: metadata.flow_model
|
||||
return flowModel && typeof flowModel === 'object' ? flowModel : null
|
||||
}
|
||||
|
||||
function resolveFlowModelDetails(flowModel, fields) {
|
||||
const nodes = Array.isArray(flowModel?.nodes) ? flowModel.nodes : []
|
||||
if (!nodes.length) {
|
||||
return null
|
||||
}
|
||||
const labelByKey = buildLabelByKey(fields)
|
||||
const evidenceNodes = nodes.filter((node) => node?.type === 'evidence')
|
||||
const decisionNodes = nodes.filter((node) => node?.type === 'decision')
|
||||
const facts = evidenceNodes.flatMap((node) => {
|
||||
const keys = readStringList(node?.fields)
|
||||
const fieldText = keys.slice(0, 4).map((key) => `${labelByKey[key] || key}[${key}]`)
|
||||
return fieldText.length
|
||||
? fieldText
|
||||
: [normalizeRiskRuleText(node?.description)]
|
||||
}).filter(Boolean)
|
||||
const conditions = decisionNodes.map((node, index) => {
|
||||
const title = normalizeRiskRuleText(node?.title || `判断 ${index + 1}`)
|
||||
const description = normalizeRiskRuleText(node?.description)
|
||||
return description ? `${title}:${description}` : title
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
facts: facts.length ? facts : buildFieldFactLines(fields),
|
||||
conditions,
|
||||
hitLogic: conditions.join(' AND ')
|
||||
}
|
||||
}
|
||||
|
||||
function buildFactLines(facts, fields) {
|
||||
const labelByKey = buildLabelByKey(fields)
|
||||
const rows = facts
|
||||
|
||||
@@ -18,6 +18,47 @@ const KNOWLEDGE_JOB_TYPES = new Set([
|
||||
'finance_policy_knowledge_organize'
|
||||
])
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
global_risk_scan: '财务风险图谱巡检',
|
||||
employee_behavior_profile_scan: '员工行为画像巡检',
|
||||
finance_policy_knowledge_organize: '知识制度整理',
|
||||
knowledge_index_sync: '知识制度整理',
|
||||
llm_wiki_sync: '知识制度整理',
|
||||
llm_wiki_rule_formation: '知识制度整理'
|
||||
}
|
||||
|
||||
const TASK_CODE_TO_TYPE = {
|
||||
'task.hermes.global_risk_scan': 'global_risk_scan',
|
||||
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
|
||||
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
|
||||
}
|
||||
|
||||
function toObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function normalizeTaskType(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
return TASK_CODE_TO_TYPE[normalized] || normalized
|
||||
}
|
||||
|
||||
function resolveTaskTypeFromToolName(value) {
|
||||
const name = String(value || '').trim()
|
||||
if (name.includes('financial_risk_graph')) {
|
||||
return 'global_risk_scan'
|
||||
}
|
||||
if (name.includes('employee_behavior_profile')) {
|
||||
return 'employee_behavior_profile_scan'
|
||||
}
|
||||
if (name.includes('finance_policy_knowledge')) {
|
||||
return 'finance_policy_knowledge_organize'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatWorkRecordDateTime(value) {
|
||||
if (!value) {
|
||||
return '未结束'
|
||||
@@ -46,8 +87,89 @@ export function resolveWorkRecordSourceLabel(source) {
|
||||
return SOURCE_LABELS[source] || source || '未标记'
|
||||
}
|
||||
|
||||
export function resolveWorkRecordTaskType(run) {
|
||||
const routeJson = toObject(run?.route_json)
|
||||
const routeCandidates = [
|
||||
routeJson.job_type,
|
||||
routeJson.task_type,
|
||||
routeJson.report_type,
|
||||
routeJson.task_code
|
||||
].map(normalizeTaskType)
|
||||
|
||||
for (const candidate of routeCandidates) {
|
||||
if (candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
for (const toolCall of run?.tool_calls || []) {
|
||||
const requestJson = toObject(toolCall?.request_json)
|
||||
const responseJson = toObject(toolCall?.response_json)
|
||||
const candidates = [
|
||||
requestJson.task_type,
|
||||
requestJson.job_type,
|
||||
responseJson.report_type,
|
||||
responseJson.task_type,
|
||||
responseJson.job_type,
|
||||
resolveTaskTypeFromToolName(toolCall?.tool_name)
|
||||
].map(normalizeTaskType)
|
||||
|
||||
const matched = candidates.find(Boolean)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveWorkRecordTaskLabel(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
return TASK_TYPE_LABELS[taskType] || ''
|
||||
}
|
||||
|
||||
export function resolveWorkRecordProductKind(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
if (taskType === 'global_risk_scan') {
|
||||
return 'risk_graph'
|
||||
}
|
||||
if (taskType === 'employee_behavior_profile_scan') {
|
||||
return 'employee_profile'
|
||||
}
|
||||
if (KNOWLEDGE_JOB_TYPES.has(taskType)) {
|
||||
return 'knowledge'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function extractWorkRecordToolSummary(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const matchedCall = toolCalls.find((toolCall) => {
|
||||
const requestJson = toObject(toolCall?.request_json)
|
||||
const responseJson = toObject(toolCall?.response_json)
|
||||
const candidates = [
|
||||
requestJson.task_type,
|
||||
requestJson.job_type,
|
||||
responseJson.report_type,
|
||||
responseJson.task_type,
|
||||
responseJson.job_type,
|
||||
resolveTaskTypeFromToolName(toolCall?.tool_name)
|
||||
].map(normalizeTaskType)
|
||||
return candidates.includes(taskType)
|
||||
}) || toolCalls[0]
|
||||
|
||||
const responseJson = toObject(matchedCall?.response_json)
|
||||
const nestedSummary = toObject(responseJson.summary)
|
||||
return Object.keys(nestedSummary).length ? nestedSummary : responseJson
|
||||
}
|
||||
|
||||
export function resolveWorkRecordModuleLabel(run) {
|
||||
const routeJson = run?.route_json || {}
|
||||
const taskLabel = resolveWorkRecordTaskLabel(run)
|
||||
if (taskLabel) {
|
||||
return taskLabel
|
||||
}
|
||||
if (KNOWLEDGE_JOB_TYPES.has(routeJson.job_type)) {
|
||||
return '知识制度整理'
|
||||
}
|
||||
@@ -62,6 +184,11 @@ export function resolveWorkRecordModuleLabel(run) {
|
||||
|
||||
export function resolveWorkRecordTitle(run) {
|
||||
const routeJson = run?.route_json || {}
|
||||
const taskLabel = resolveWorkRecordTaskLabel(run)
|
||||
if (taskLabel) {
|
||||
const suffix = String(routeJson.task_name || routeJson.folder || '本次运行').trim()
|
||||
return suffix && suffix !== taskLabel ? `${taskLabel} · ${suffix}` : taskLabel
|
||||
}
|
||||
if (KNOWLEDGE_JOB_TYPES.has(routeJson.job_type)) {
|
||||
return `知识制度整理 · ${routeJson.folder || '未指定目录'}`
|
||||
}
|
||||
|
||||
224
web/src/views/scripts/receiptFolderDetailFields.js
Normal file
224
web/src/views/scripts/receiptFolderDetailFields.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const TRAIN_KEY_FIELD_DEFINITIONS = [
|
||||
{
|
||||
id: 'invoice_number',
|
||||
label: '发票号码',
|
||||
placeholder: '待识别',
|
||||
keys: ['invoice_number', 'ticket_number'],
|
||||
labels: ['发票号码', '票据号码', '票号']
|
||||
},
|
||||
{
|
||||
id: 'invoice_date',
|
||||
label: '开票日期',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
keys: ['invoice_date', 'issue_date'],
|
||||
labels: ['开票日期', '发票日期']
|
||||
},
|
||||
{
|
||||
id: 'fare',
|
||||
label: '票价',
|
||||
placeholder: '待识别',
|
||||
keys: ['fare', 'amount'],
|
||||
labels: ['票价', '金额']
|
||||
},
|
||||
{
|
||||
id: 'passenger_name',
|
||||
label: '姓名',
|
||||
placeholder: '待识别',
|
||||
keys: ['passenger_name'],
|
||||
labels: ['乘车人', '旅客姓名', '姓名']
|
||||
}
|
||||
]
|
||||
|
||||
const DEFAULT_KEY_FIELD_DEFINITIONS = [
|
||||
{
|
||||
id: 'invoice_number',
|
||||
label: '发票号码',
|
||||
placeholder: '待识别',
|
||||
keys: ['invoice_number', 'ticket_number'],
|
||||
labels: ['发票号码', '票据号码', '票号']
|
||||
},
|
||||
{
|
||||
id: 'invoice_date',
|
||||
label: '开票日期',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
keys: ['invoice_date', 'issue_date'],
|
||||
labels: ['开票日期', '发票日期']
|
||||
},
|
||||
{
|
||||
id: 'amount',
|
||||
label: '金额',
|
||||
placeholder: '待识别',
|
||||
keys: ['amount', 'fare'],
|
||||
labels: ['金额', '价税合计', '合计金额', '票价']
|
||||
},
|
||||
{
|
||||
id: 'merchant_name',
|
||||
label: '商户',
|
||||
placeholder: '待识别',
|
||||
keys: ['merchant_name'],
|
||||
labels: ['商户', '销售方', '开票方']
|
||||
}
|
||||
]
|
||||
|
||||
const RECEIPT_META_FIELD_DEFINITIONS = [
|
||||
{
|
||||
id: 'document_type_label',
|
||||
label: '票据类型',
|
||||
placeholder: '待识别',
|
||||
keys: ['document_type_label'],
|
||||
labels: ['票据类型', '识别类型']
|
||||
},
|
||||
{
|
||||
id: 'scene_label',
|
||||
label: '费用场景',
|
||||
placeholder: '待识别',
|
||||
keys: ['scene_label'],
|
||||
labels: ['费用场景', '场景']
|
||||
},
|
||||
{
|
||||
id: 'merchant_name',
|
||||
label: '商户',
|
||||
placeholder: '待识别',
|
||||
keys: ['merchant_name'],
|
||||
labels: ['商户', '销售方', '开票方']
|
||||
}
|
||||
]
|
||||
|
||||
export function createReceiptDetailFieldModel({ detailForm, isTrainTicket }) {
|
||||
const activeKeyFieldDefinitions = computed(() => (
|
||||
isTrainTicket.value ? TRAIN_KEY_FIELD_DEFINITIONS : DEFAULT_KEY_FIELD_DEFINITIONS
|
||||
))
|
||||
const keyReceiptFields = computed(() => (
|
||||
activeKeyFieldDefinitions.value.map((definition) => ({
|
||||
...definition,
|
||||
value: getReceiptFieldValue(definition)
|
||||
}))
|
||||
))
|
||||
const keyReceiptFieldTokens = computed(() => {
|
||||
const tokens = new Set()
|
||||
activeKeyFieldDefinitions.value.forEach((definition) => {
|
||||
for (const token of [definition.id, ...(definition.keys || []), ...(definition.labels || [])]) {
|
||||
const normalized = normalizeReceiptFieldToken(token)
|
||||
if (normalized) tokens.add(normalized)
|
||||
}
|
||||
})
|
||||
return tokens
|
||||
})
|
||||
const editableOtherFields = computed(() => (
|
||||
detailForm.fields.filter((field) => {
|
||||
const key = normalizeReceiptFieldToken(field?.key)
|
||||
const label = normalizeReceiptFieldToken(field?.label)
|
||||
return !keyReceiptFieldTokens.value.has(key) && !keyReceiptFieldTokens.value.has(label)
|
||||
})
|
||||
))
|
||||
|
||||
function findReceiptFieldForDefinition(definition) {
|
||||
const keys = (definition.keys || []).map(normalizeReceiptFieldToken).filter(Boolean)
|
||||
const labels = (definition.labels || []).map(normalizeReceiptFieldToken).filter(Boolean)
|
||||
|
||||
return detailForm.fields.find((field) => keys.includes(normalizeReceiptFieldToken(field?.key)))
|
||||
|| detailForm.fields.find((field) => labels.includes(normalizeReceiptFieldToken(field?.label)))
|
||||
|| null
|
||||
}
|
||||
|
||||
function getReceiptFieldFallback(definition) {
|
||||
if (definition.id === 'invoice_date') return detailForm.document_date
|
||||
if (definition.id === 'fare' || definition.id === 'amount') return detailForm.amount
|
||||
if (definition.id === 'merchant_name') return detailForm.merchant_name
|
||||
if (definition.id === 'document_type_label') return detailForm.document_type_label
|
||||
if (definition.id === 'scene_label') return detailForm.scene_label
|
||||
return ''
|
||||
}
|
||||
|
||||
function getReceiptFieldValue(definition) {
|
||||
const field = findReceiptFieldForDefinition(definition)
|
||||
return String(field?.value || getReceiptFieldFallback(definition) || '')
|
||||
}
|
||||
|
||||
function ensureReceiptField(definition) {
|
||||
const field = findReceiptFieldForDefinition(definition)
|
||||
if (field) {
|
||||
field.key = field.key || definition.keys?.[0] || definition.id
|
||||
field.label = field.label || definition.label
|
||||
return field
|
||||
}
|
||||
|
||||
const created = {
|
||||
key: definition.keys?.[0] || definition.id,
|
||||
label: definition.label,
|
||||
value: getReceiptFieldFallback(definition)
|
||||
}
|
||||
detailForm.fields.push(created)
|
||||
return created
|
||||
}
|
||||
|
||||
function ensureEditableReceiptFields() {
|
||||
for (const definition of [...activeKeyFieldDefinitions.value, ...RECEIPT_META_FIELD_DEFINITIONS]) {
|
||||
const field = ensureReceiptField(definition)
|
||||
const fallback = getReceiptFieldFallback(definition)
|
||||
if (!String(field.value || '').trim() && fallback) {
|
||||
field.value = fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateReceiptField(definition, value) {
|
||||
const field = ensureReceiptField(definition)
|
||||
field.value = value
|
||||
syncEditableFieldsToTopLevel()
|
||||
}
|
||||
|
||||
function readFieldValue(definition) {
|
||||
return String(findReceiptFieldForDefinition(definition)?.value || '').trim()
|
||||
}
|
||||
|
||||
function syncEditableFieldsToTopLevel() {
|
||||
const invoiceDate = readFieldValue(TRAIN_KEY_FIELD_DEFINITIONS[1]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[1])
|
||||
const amount = readFieldValue(TRAIN_KEY_FIELD_DEFINITIONS[2]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[2])
|
||||
const merchant = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[2]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[3])
|
||||
const documentTypeLabel = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[0])
|
||||
const sceneLabel = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[1])
|
||||
|
||||
if (invoiceDate) detailForm.document_date = invoiceDate
|
||||
if (amount) detailForm.amount = amount
|
||||
if (merchant) detailForm.merchant_name = merchant
|
||||
if (documentTypeLabel) detailForm.document_type_label = documentTypeLabel
|
||||
if (sceneLabel) detailForm.scene_label = sceneLabel
|
||||
}
|
||||
|
||||
function buildDetailPayload() {
|
||||
syncEditableFieldsToTopLevel()
|
||||
return {
|
||||
document_type: detailForm.document_type,
|
||||
document_type_label: detailForm.document_type_label,
|
||||
scene_code: detailForm.scene_code,
|
||||
scene_label: detailForm.scene_label,
|
||||
summary: detailForm.summary,
|
||||
amount: detailForm.amount,
|
||||
document_date: detailForm.document_date,
|
||||
merchant_name: detailForm.merchant_name,
|
||||
fields: detailForm.fields
|
||||
.map((field) => ({
|
||||
key: String(field?.key || '').trim(),
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.key || field.label || field.value)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildDetailPayload,
|
||||
editableOtherFields,
|
||||
ensureEditableReceiptFields,
|
||||
keyReceiptFields,
|
||||
syncEditableFieldsToTopLevel,
|
||||
updateReceiptField
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReceiptFieldToken(value) {
|
||||
return String(value || '').trim().toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
@@ -108,6 +109,8 @@ export const SOURCE_LABELS = {
|
||||
requests: '来自报销列表'
|
||||
}
|
||||
|
||||
export { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
|
||||
|
||||
export const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
@@ -157,6 +160,12 @@ export const FLOW_STEP_FALLBACKS = {
|
||||
runningText: '正在把已确认信息保存为草稿...',
|
||||
completedText: '草稿已保存'
|
||||
},
|
||||
'application-submit-success': {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
runningText: '正在提交费用申请...',
|
||||
completedText: '申请单提交成功'
|
||||
},
|
||||
'attachment-association': {
|
||||
title: '票据关联草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
@@ -286,7 +295,7 @@ export function nowTime() {
|
||||
|
||||
export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
messageSeed += 1
|
||||
return {
|
||||
const message = {
|
||||
id: `msg-${messageSeed}`,
|
||||
role,
|
||||
text,
|
||||
@@ -308,8 +317,11 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
pendingAttachmentAssociation: null,
|
||||
applicationPreview: null,
|
||||
budgetReport: null,
|
||||
operationFeedback: null,
|
||||
...extras
|
||||
}
|
||||
message.meta = filterVisibleMessageMeta(message.meta)
|
||||
return message
|
||||
}
|
||||
|
||||
export function buildExpenseIntentConfirmationMessage(rawText) {
|
||||
@@ -471,18 +483,6 @@ export function resolveStatusTone(status) {
|
||||
export function buildMessageMeta(payload, fileNames = []) {
|
||||
const items = []
|
||||
|
||||
if (payload?.selected_agent) {
|
||||
items.push(`Agent: ${payload.selected_agent}`)
|
||||
}
|
||||
|
||||
if (payload?.permission_level) {
|
||||
items.push(`权限: ${payload.permission_level}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.tool_count) {
|
||||
items.push(`工具: ${payload.trace_summary.tool_count}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.degraded) {
|
||||
items.push('已降级')
|
||||
}
|
||||
@@ -491,15 +491,11 @@ export function buildMessageMeta(payload, fileNames = []) {
|
||||
items.push('待确认')
|
||||
}
|
||||
|
||||
if (payload?.run_id) {
|
||||
items.push(`Run: ${payload.run_id}`)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
items.push(`附件: ${fileNames.length}`)
|
||||
}
|
||||
|
||||
return items
|
||||
return filterVisibleMessageMeta(items)
|
||||
}
|
||||
|
||||
export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
|
||||
@@ -515,7 +511,7 @@ export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
|
||||
if (attachmentNames.length) {
|
||||
items.push(`附件: ${attachmentNames.length}`)
|
||||
}
|
||||
return items
|
||||
return filterVisibleMessageMeta(items)
|
||||
}
|
||||
|
||||
export function buildWelcomeUserContext(user = {}) {
|
||||
@@ -870,7 +866,7 @@ export function serializeSessionMessages(messages) {
|
||||
text: message.text,
|
||||
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
|
||||
time: message.time,
|
||||
meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [],
|
||||
meta: filterVisibleMessageMeta(message.meta),
|
||||
metaTone: message.metaTone || '',
|
||||
citations: Array.isArray(message.citations) ? message.citations : [],
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
@@ -883,10 +879,11 @@ export function serializeSessionMessages(messages) {
|
||||
draftPayload: message.draftPayload || null,
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
budgetReport: message.budgetReport || null,
|
||||
assistantName: message.assistantName || '',
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
budgetReport: message.budgetReport || null,
|
||||
operationFeedback: message.operationFeedback || null,
|
||||
assistantName: message.assistantName || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
}))
|
||||
@@ -908,6 +905,7 @@ export function hasMeaningfulSessionMessages(messages) {
|
||||
|| message.draftPayload
|
||||
|| message.applicationPreview
|
||||
|| message.budgetReport
|
||||
|| message.operationFeedback
|
||||
|| message.pendingAttachmentAssociation
|
||||
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
|
||||
)
|
||||
|
||||
@@ -164,6 +164,7 @@ export function buildFallbackProgressSteps(requestModel = {}) {
|
||||
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
|
||||
const paid = /已付款/.test(node)
|
||||
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
|
||||
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
|
||||
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
|
||||
@@ -197,13 +198,14 @@ export function buildFallbackProgressSteps(requestModel = {}) {
|
||||
}
|
||||
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication },
|
||||
{ index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
|
||||
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
|
||||
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
|
||||
{ index: 8, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,48 @@ import {
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
getTodayDateValue
|
||||
} from '../../utils/workbenchComposerDate.js'
|
||||
|
||||
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
|
||||
const applicationPreviewEditor = ref({
|
||||
function parseEditorDateValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
const matches = [...text.matchAll(/20\d{2}-\d{1,2}-\d{1,2}/g)].map((item) => item[0])
|
||||
const startDate = matches[0] || getTodayDateValue()
|
||||
const endDate = matches[1] || startDate
|
||||
return {
|
||||
dateMode: matches.length > 1 && startDate !== endDate ? 'range' : 'single',
|
||||
singleDate: startDate,
|
||||
rangeStartDate: startDate,
|
||||
rangeEndDate: endDate
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmptyEditor() {
|
||||
return {
|
||||
messageId: '',
|
||||
fieldKey: '',
|
||||
draftValue: ''
|
||||
})
|
||||
draftValue: '',
|
||||
dateMode: 'single',
|
||||
singleDate: getTodayDateValue(),
|
||||
rangeStartDate: getTodayDateValue(),
|
||||
rangeEndDate: getTodayDateValue()
|
||||
}
|
||||
}
|
||||
|
||||
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
|
||||
const applicationPreviewEditor = ref(buildEmptyEditor())
|
||||
|
||||
function resolveApplicationPreviewRows(message) {
|
||||
return buildApplicationPreviewRows(message?.applicationPreview || {})
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorControl(fieldKey) {
|
||||
return fieldKey === 'transportMode' ? 'select' : 'text'
|
||||
if (fieldKey === 'transportMode') return 'select'
|
||||
if (fieldKey === 'time') return 'date'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorOptions(fieldKey) {
|
||||
@@ -39,21 +67,47 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
.find((row) => row.key === fieldKey)
|
||||
if (targetRow && targetRow.editable === false) return
|
||||
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
|
||||
const dateState = fieldKey === 'time' ? parseEditorDateValue(normalizedValue) : {}
|
||||
applicationPreviewEditor.value = {
|
||||
messageId: String(message.id || ''),
|
||||
fieldKey,
|
||||
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||
? ''
|
||||
: normalizedValue
|
||||
: normalizedValue,
|
||||
...dateState
|
||||
}
|
||||
}
|
||||
|
||||
function cancelApplicationPreviewEditor() {
|
||||
applicationPreviewEditor.value = {
|
||||
messageId: '',
|
||||
fieldKey: '',
|
||||
draftValue: ''
|
||||
}
|
||||
applicationPreviewEditor.value = buildEmptyEditor()
|
||||
}
|
||||
|
||||
function isApplicationPreviewDateEditorOpen(message) {
|
||||
return isApplicationPreviewEditing(message, 'time')
|
||||
}
|
||||
|
||||
function setApplicationPreviewDateMode(mode) {
|
||||
applicationPreviewEditor.value.dateMode = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function canApplyApplicationPreviewDateSelection() {
|
||||
const editor = applicationPreviewEditor.value
|
||||
return canApplyWorkbenchDateSelection({
|
||||
mode: editor.dateMode,
|
||||
singleDate: editor.singleDate,
|
||||
rangeStartDate: editor.rangeStartDate,
|
||||
rangeEndDate: editor.rangeEndDate
|
||||
})
|
||||
}
|
||||
|
||||
function buildApplicationPreviewDateDraftValue() {
|
||||
const editor = applicationPreviewEditor.value
|
||||
return buildWorkbenchDateLabel({
|
||||
mode: editor.dateMode,
|
||||
singleDate: editor.singleDate,
|
||||
rangeStartDate: editor.rangeStartDate,
|
||||
rangeEndDate: editor.rangeEndDate
|
||||
})
|
||||
}
|
||||
|
||||
function commitApplicationPreviewEditor(message) {
|
||||
@@ -63,7 +117,13 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
return false
|
||||
}
|
||||
|
||||
const nextValue = String(editor.draftValue || '').trim()
|
||||
const nextValue = editor.fieldKey === 'time'
|
||||
? buildApplicationPreviewDateDraftValue()
|
||||
: String(editor.draftValue || '').trim()
|
||||
if (editor.fieldKey === 'time' && !nextValue) {
|
||||
toast?.('请先选择有效日期。')
|
||||
return false
|
||||
}
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
...message.applicationPreview,
|
||||
fields: {
|
||||
@@ -79,6 +139,14 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
return true
|
||||
}
|
||||
|
||||
function commitApplicationPreviewDateEditor(message) {
|
||||
if (!canApplyApplicationPreviewDateSelection()) {
|
||||
toast?.('请确认结束日期不早于开始日期。')
|
||||
return false
|
||||
}
|
||||
return commitApplicationPreviewEditor(message)
|
||||
}
|
||||
|
||||
function handleApplicationPreviewEditorKeydown(event, message) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
@@ -97,9 +165,13 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
commitApplicationPreviewDateEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
setApplicationPreviewDateMode,
|
||||
canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
createRiskRuleRevision,
|
||||
deleteAgentAsset,
|
||||
fetchAgentAssetDetail,
|
||||
publishRiskRuleAsset,
|
||||
returnRiskRuleAsset,
|
||||
setRiskRuleAssetEnabled
|
||||
setRiskRuleAssetEnabled,
|
||||
updateRiskRuleDraft
|
||||
} from '../../services/agentAssets.js'
|
||||
import { normalizeText } from './auditViewModel.js'
|
||||
|
||||
const DEFAULT_EXPENSE_CATEGORY = 'travel'
|
||||
|
||||
export function useAuditRiskRuleActions({
|
||||
selectedSkill,
|
||||
detailBusy,
|
||||
@@ -18,6 +22,8 @@ export function useAuditRiskRuleActions({
|
||||
canReturnRiskRule,
|
||||
canPublishRiskRule,
|
||||
canToggleRiskRuleEnabled,
|
||||
canEditRiskRuleDraft,
|
||||
canCreateRiskRuleRevision,
|
||||
riskRuleTestPassed,
|
||||
refreshCurrentAssets,
|
||||
loadSelectedAssetDetail,
|
||||
@@ -31,6 +37,9 @@ export function useAuditRiskRuleActions({
|
||||
const riskRuleReturnOpen = ref(false)
|
||||
const riskRulePublishOpen = ref(false)
|
||||
const riskRuleReturnNote = ref('')
|
||||
const riskRuleEditOpen = ref(false)
|
||||
const riskRuleEditMode = ref('draft')
|
||||
const riskRuleEditForm = ref(createRiskRuleEditForm())
|
||||
|
||||
function resetRiskRuleActionDialogs() {
|
||||
riskRuleTestOpen.value = false
|
||||
@@ -38,6 +47,9 @@ export function useAuditRiskRuleActions({
|
||||
riskRuleReturnOpen.value = false
|
||||
riskRulePublishOpen.value = false
|
||||
riskRuleReturnNote.value = ''
|
||||
riskRuleEditOpen.value = false
|
||||
riskRuleEditMode.value = 'draft'
|
||||
riskRuleEditForm.value = createRiskRuleEditForm()
|
||||
}
|
||||
|
||||
function openRiskRuleTestDialog() {
|
||||
@@ -68,6 +80,68 @@ export function useAuditRiskRuleActions({
|
||||
}
|
||||
}
|
||||
|
||||
function openRiskRuleEditDialog(mode = 'draft') {
|
||||
const normalizedMode = mode === 'revision' ? 'revision' : 'draft'
|
||||
if (normalizedMode === 'revision' && !canCreateRiskRuleRevision.value) {
|
||||
return
|
||||
}
|
||||
if (normalizedMode === 'draft' && !canEditRiskRuleDraft.value) {
|
||||
return
|
||||
}
|
||||
riskRuleEditMode.value = normalizedMode
|
||||
riskRuleEditForm.value = createRiskRuleEditForm(selectedSkill.value, normalizedMode)
|
||||
riskRuleEditOpen.value = true
|
||||
}
|
||||
|
||||
function closeRiskRuleEditDialog() {
|
||||
if (actionState.value === 'save-risk-rule-edit') {
|
||||
return
|
||||
}
|
||||
riskRuleEditOpen.value = false
|
||||
}
|
||||
|
||||
async function submitRiskRuleEdit() {
|
||||
const isRevision = riskRuleEditMode.value === 'revision'
|
||||
if (!selectedSkill.value || detailBusy.value) {
|
||||
return
|
||||
}
|
||||
if (isRevision && !canCreateRiskRuleRevision.value) {
|
||||
return
|
||||
}
|
||||
if (!isRevision && !canEditRiskRuleDraft.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = normalizeRiskRuleEditPayload(riskRuleEditForm.value, isRevision)
|
||||
if (payload.rule_title.length < 2) {
|
||||
toast('请输入至少 2 个字的规则标题。')
|
||||
return
|
||||
}
|
||||
if (payload.natural_language.length < 8) {
|
||||
toast('请至少输入 8 个字的风险规则描述。')
|
||||
return
|
||||
}
|
||||
if (isRevision && !payload.change_reason) {
|
||||
toast('请填写修订原因。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'save-risk-rule-edit'
|
||||
try {
|
||||
const detail = isRevision
|
||||
? await createRiskRuleRevision(selectedSkill.value.id, payload, { actor: resolveActor() })
|
||||
: await updateRiskRuleDraft(selectedSkill.value.id, payload, { actor: resolveActor() })
|
||||
riskRuleEditOpen.value = false
|
||||
mergeSelectedRuleLifecycle(detail)
|
||||
await refreshCurrentAssets()
|
||||
toast(isRevision ? '已创建风险规则修订草稿。' : '风险规则草稿已更新。')
|
||||
} catch (error) {
|
||||
toast(error?.message || (isRevision ? '创建修订版本失败,请稍后重试。' : '编辑规则草稿失败,请稍后重试。'))
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteRiskRuleDialog() {
|
||||
if (!canDeleteRiskRule.value) {
|
||||
return
|
||||
@@ -208,10 +282,16 @@ export function useAuditRiskRuleActions({
|
||||
riskRuleReturnOpen,
|
||||
riskRulePublishOpen,
|
||||
riskRuleReturnNote,
|
||||
riskRuleEditOpen,
|
||||
riskRuleEditMode,
|
||||
riskRuleEditForm,
|
||||
resetRiskRuleActionDialogs,
|
||||
openRiskRuleTestDialog,
|
||||
closeRiskRuleTestDialog,
|
||||
handleRiskRuleReportSaved,
|
||||
openRiskRuleEditDialog,
|
||||
closeRiskRuleEditDialog,
|
||||
submitRiskRuleEdit,
|
||||
openDeleteRiskRuleDialog,
|
||||
closeDeleteRiskRuleDialog,
|
||||
deleteSelectedRiskRule,
|
||||
@@ -224,3 +304,27 @@ export function useAuditRiskRuleActions({
|
||||
toggleSelectedRiskRuleEnabled
|
||||
}
|
||||
}
|
||||
|
||||
function createRiskRuleEditForm(rule = null, mode = 'draft') {
|
||||
const config = rule?.configJson || {}
|
||||
return {
|
||||
rule_title: normalizeText(rule?.name),
|
||||
expense_category: normalizeText(config.expense_category) || DEFAULT_EXPENSE_CATEGORY,
|
||||
requires_attachment: Boolean(rule?.riskRuleRequiresAttachment || config.requires_attachment),
|
||||
natural_language: normalizeText(rule?.summary || rule?.riskRuleSubtitle),
|
||||
change_reason: mode === 'revision' ? '' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRiskRuleEditPayload(form, includeReason) {
|
||||
const payload = {
|
||||
rule_title: normalizeText(form?.rule_title),
|
||||
expense_category: normalizeText(form?.expense_category) || DEFAULT_EXPENSE_CATEGORY,
|
||||
requires_attachment: Boolean(form?.requires_attachment),
|
||||
natural_language: normalizeText(form?.natural_language)
|
||||
}
|
||||
if (includeReason) {
|
||||
payload.change_reason = normalizeText(form?.change_reason)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useTravelReimbursementAttachments({
|
||||
reviewActionBusy,
|
||||
toast,
|
||||
fileInputRef,
|
||||
createExpenseClaimItem,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
@@ -149,7 +150,7 @@ export function useTravelReimbursementAttachments({
|
||||
async function syncComposerFilesToDraft(claimId, files) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) {
|
||||
return
|
||||
return { uploadedCount: 0, skippedCount: Array.isArray(files) ? files.length : 0 }
|
||||
}
|
||||
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
@@ -157,16 +158,30 @@ export function useTravelReimbursementAttachments({
|
||||
const exactMatchBuckets = new Map()
|
||||
const normalizedMatchBuckets = new Map()
|
||||
const placeholderQueue = []
|
||||
const emptyAttachmentQueue = []
|
||||
const usedItemIds = new Set()
|
||||
let uploadedCount = 0
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
if (!itemId) continue
|
||||
if (invoiceId && !invoiceId.includes('/')) {
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
const isSystemGenerated = Boolean(
|
||||
item?.isSystemGenerated ||
|
||||
item?.is_system_generated ||
|
||||
itemType === 'travel_allowance'
|
||||
)
|
||||
if (!invoiceId && !isSystemGenerated) {
|
||||
emptyAttachmentQueue.push(item)
|
||||
continue
|
||||
}
|
||||
if (!invoiceId || invoiceId.includes('/')) {
|
||||
continue
|
||||
}
|
||||
if (invoiceId) {
|
||||
placeholderQueue.push(item)
|
||||
}
|
||||
if (!invoiceId) continue
|
||||
const bucket = exactMatchBuckets.get(invoiceId) || []
|
||||
bucket.push(item)
|
||||
exactMatchBuckets.set(invoiceId, bucket)
|
||||
@@ -185,17 +200,41 @@ export function useTravelReimbursementAttachments({
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
|
||||
const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch
|
||||
const targetItemId = String(targetItem?.id || '').trim()
|
||||
const emptyFallbackMatch = emptyAttachmentQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
let targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch || emptyFallbackMatch
|
||||
let targetItemId = String(targetItem?.id || '').trim()
|
||||
if (!targetItemId && typeof createExpenseClaimItem === 'function') {
|
||||
const updatedClaim = await createExpenseClaimItem(normalizedClaimId, {})
|
||||
const createdItems = Array.isArray(updatedClaim?.items) ? updatedClaim.items : []
|
||||
targetItem = createdItems.find((item) => {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
return (
|
||||
itemId &&
|
||||
!usedItemIds.has(itemId) &&
|
||||
!invoiceId &&
|
||||
itemType !== 'travel_allowance' &&
|
||||
!item?.isSystemGenerated &&
|
||||
!item?.is_system_generated
|
||||
)
|
||||
}) || null
|
||||
targetItemId = String(targetItem?.id || '').trim()
|
||||
}
|
||||
if (!targetItemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
usedItemIds.add(targetItemId)
|
||||
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
|
||||
uploadedCount += 1
|
||||
}
|
||||
|
||||
await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true })
|
||||
return {
|
||||
uploadedCount,
|
||||
skippedCount: Math.max(0, files.length - uploadedCount)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileUpload(mode = 'composer') {
|
||||
|
||||
@@ -44,6 +44,9 @@ const CHINESE_DAY_NUMBERS = {
|
||||
十: 10
|
||||
}
|
||||
|
||||
const COMPOSER_DATE_RANGE_PREFIX_RE = /^20\d{2}-\d{1,2}-\d{1,2}(?:\s*至\s*20\d{2}-\d{1,2}-\d{1,2})?[,,。\s]*/u
|
||||
const COMPOSER_LABELED_TIME_PREFIX_RE = /^(?:业务)?发生时间[::]\s*[^,,。\n]+(?:至\s*[^,,。\n]+)?[,,。\s]*/u
|
||||
|
||||
function normalizeComposerText(value) {
|
||||
return String(value || '').trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
@@ -85,7 +88,8 @@ function calculateBusinessDays(businessTimeContext) {
|
||||
|
||||
function stripBusinessTimePrefix(text) {
|
||||
return normalizeComposerText(text)
|
||||
.replace(/^(?:业务)?发生时间[::]\s*[^,,。\n]+(?:至\s*[^,,。\n]+)?[,,。\s]*/u, '')
|
||||
.replace(COMPOSER_LABELED_TIME_PREFIX_RE, '')
|
||||
.replace(COMPOSER_DATE_RANGE_PREFIX_RE, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -183,7 +187,8 @@ export function useTravelReimbursementComposerTools({
|
||||
buildReviewSlotMap,
|
||||
isValidIsoDateString,
|
||||
buildLocallySyncedReviewPayload,
|
||||
formatDateInputValue
|
||||
formatDateInputValue,
|
||||
onComposerDateSelection
|
||||
}) {
|
||||
const composerDatePickerOpen = ref(false)
|
||||
const composerDateMode = ref('single')
|
||||
@@ -217,23 +222,19 @@ 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() {
|
||||
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContextFromSelection() {
|
||||
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
|
||||
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
|
||||
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
|
||||
@@ -255,6 +256,28 @@ export function useTravelReimbursementComposerTools({
|
||||
}
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildComposerBusinessTimeContextFromSelection()
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeSelection() {
|
||||
const context = buildComposerBusinessTimeContextFromSelection()
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label: buildComposerBusinessTimeLabel(),
|
||||
context,
|
||||
mode: context.mode,
|
||||
startDate: context.start_date,
|
||||
endDate: context.end_date
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
|
||||
if (!businessTimeContext) {
|
||||
return extraContext
|
||||
@@ -345,9 +368,60 @@ export function useTravelReimbursementComposerTools({
|
||||
composerDateMode.value = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange() {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
async function commitComposerDateSelection({ closePicker = true, focusComposer = true } = {}) {
|
||||
if (!composerCanApplyDateSelection.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selection = buildComposerBusinessTimeSelection()
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
|
||||
const handled = onComposerDateSelection?.(selection) === true
|
||||
if (handled) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
composerBusinessTimeTags.value = []
|
||||
} else {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: selection.label
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(selection.context)
|
||||
}
|
||||
|
||||
if (closePicker) {
|
||||
composerDatePickerOpen.value = false
|
||||
}
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
if (focusComposer) {
|
||||
composerTextareaRef.value?.focus()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange(part = 'single') {
|
||||
if (composerDateMode.value !== 'range' || part === 'single') {
|
||||
void commitComposerDateSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (part === 'range-start') {
|
||||
if (!composerRangeEndDate.value || composerRangeEndDate.value < composerRangeStartDate.value) {
|
||||
composerRangeEndDate.value = composerRangeStartDate.value
|
||||
}
|
||||
if (!onComposerDateSelection) {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContextFromSelection())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
void commitComposerDateSelection()
|
||||
}
|
||||
|
||||
function removeComposerBusinessTimeTag(tagId) {
|
||||
@@ -376,22 +450,7 @@ export function useTravelReimbursementComposerTools({
|
||||
}
|
||||
|
||||
async function applyComposerDateSelection() {
|
||||
if (!composerCanApplyDateSelection.value) {
|
||||
return
|
||||
}
|
||||
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
await commitComposerDateSelection()
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialDays() {
|
||||
@@ -547,6 +606,7 @@ export function useTravelReimbursementComposerTools({
|
||||
travelCalculatorCanSubmit,
|
||||
buildComposerBusinessTimeLabel,
|
||||
hasComposerBusinessTimeSelection,
|
||||
buildComposerBusinessTimeSelection,
|
||||
buildComposerBusinessTimeContext,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
syncComposerBusinessTimeToReviewCard,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
function formatFlowDuration(ms) {
|
||||
if (ms === null || ms === undefined || ms === '') {
|
||||
return '--'
|
||||
}
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return '--'
|
||||
}
|
||||
if (numericValue < 1000) {
|
||||
@@ -15,18 +18,122 @@ function formatFlowDuration(ms) {
|
||||
}
|
||||
|
||||
function parseFlowTimestamp(value) {
|
||||
const timestamp = new Date(value || '').getTime()
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 0 && value < 10000000000 ? Math.round(value * 1000) : Math.round(value)
|
||||
}
|
||||
const timestamp = new Date(value).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
const FLOW_DURATION_MS_FIELDS = [
|
||||
'duration_ms',
|
||||
'elapsed_ms',
|
||||
'latency_ms',
|
||||
'total_duration_ms',
|
||||
'execution_time_ms'
|
||||
]
|
||||
const FLOW_DURATION_SECOND_FIELDS = [
|
||||
'duration_seconds',
|
||||
'elapsed_seconds',
|
||||
'latency_seconds',
|
||||
'execution_time_seconds'
|
||||
]
|
||||
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
|
||||
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
|
||||
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
|
||||
|
||||
function normalizeDurationValue(value, unit = 'ms') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
let numericValue = Number(value)
|
||||
let normalizedUnit = unit
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim()
|
||||
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|毫秒|s|秒)?$/i)
|
||||
if (match) {
|
||||
numericValue = Number(match[1])
|
||||
if (match[2]) {
|
||||
normalizedUnit = ['s', '秒'].includes(match[2].toLowerCase()) ? 'seconds' : 'ms'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return null
|
||||
}
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return Math.round(numericValue * 1000)
|
||||
}
|
||||
if (normalizedUnit === 'auto') {
|
||||
return Math.round(numericValue <= 300 ? numericValue * 1000 : numericValue)
|
||||
}
|
||||
return Math.round(numericValue)
|
||||
}
|
||||
|
||||
function readFirstDurationField(source, fields, unit) {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return null
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, field)) {
|
||||
continue
|
||||
}
|
||||
const durationMs = normalizeDurationValue(source[field], unit)
|
||||
if (durationMs) {
|
||||
return durationMs
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDurationFromFields(source) {
|
||||
return (
|
||||
readFirstDurationField(source, FLOW_DURATION_MS_FIELDS, 'ms')
|
||||
|| readFirstDurationField(source, FLOW_DURATION_SECOND_FIELDS, 'seconds')
|
||||
|| readFirstDurationField(source, FLOW_DURATION_AUTO_FIELDS, 'auto')
|
||||
)
|
||||
}
|
||||
|
||||
function readFirstTimestampField(source, fields) {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return 0
|
||||
}
|
||||
for (const field of fields) {
|
||||
const timestamp = parseFlowTimestamp(source[field])
|
||||
if (timestamp) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function resolveStartedTimestamp(source) {
|
||||
return readFirstTimestampField(source, FLOW_STARTED_AT_FIELDS)
|
||||
}
|
||||
|
||||
function resolveFinishedTimestamp(source) {
|
||||
return readFirstTimestampField(source, FLOW_FINISHED_AT_FIELDS)
|
||||
}
|
||||
|
||||
function resolveTimeRangeDurationMs(source) {
|
||||
const startedAt = resolveStartedTimestamp(source)
|
||||
const finishedAt = resolveFinishedTimestamp(source)
|
||||
return finishedAt > startedAt ? finishedAt - startedAt : null
|
||||
}
|
||||
|
||||
function resolveSemanticPhaseDurations(run) {
|
||||
const runStart = parseFlowTimestamp(run?.started_at)
|
||||
const runStart = resolveStartedTimestamp(run)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const firstToolStartedAt = toolCalls
|
||||
.map((item) => parseFlowTimestamp(item?.created_at))
|
||||
.map((item) => resolveStartedTimestamp(item))
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => left - right)[0] || 0
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
|
||||
|
||||
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
|
||||
@@ -43,18 +150,24 @@ function resolveSemanticPhaseDurations(run) {
|
||||
}
|
||||
|
||||
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
const explicitDuration = Number(toolCall?.duration_ms)
|
||||
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const explicitDuration = resolveDurationFromFields(toolCall)
|
||||
|| resolveTimeRangeDurationMs(toolCall)
|
||||
|| resolveDurationFromFields(response)
|
||||
|| resolveTimeRangeDurationMs(response)
|
||||
if (explicitDuration) {
|
||||
return explicitDuration
|
||||
}
|
||||
|
||||
const startedAt = parseFlowTimestamp(toolCall?.created_at)
|
||||
const startedAt = resolveStartedTimestamp(toolCall)
|
||||
if (!startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at)
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const nextStartedAt = resolveStartedTimestamp(toolCalls[index + 1])
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
|
||||
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
@@ -64,6 +177,19 @@ function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
return finishedAt - startedAt
|
||||
}
|
||||
|
||||
function summarizeVisibleToolText(value) {
|
||||
const text = String(value || '')
|
||||
.replace(/\|[^\n]*\|/g, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) || ''
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
return text.length > 80 ? `${text.slice(0, 80)}...` : text
|
||||
}
|
||||
|
||||
export function useTravelReimbursementFlow({
|
||||
activeSessionType,
|
||||
reviewDrawerMode,
|
||||
@@ -238,7 +364,8 @@ export function useTravelReimbursementFlow({
|
||||
startedAt: normalizedPatch.startedAt || 0,
|
||||
finishedAt: normalizedPatch.finishedAt || 0,
|
||||
error: normalizedPatch.error || '',
|
||||
deferredCompletion: Boolean(normalizedPatch.deferredCompletion)
|
||||
deferredCompletion: Boolean(normalizedPatch.deferredCompletion),
|
||||
syntheticTiming: Boolean(normalizedPatch.syntheticTiming)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +403,8 @@ export function useTravelReimbursementFlow({
|
||||
startedAt,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
error: '',
|
||||
syntheticTiming: Boolean(normalizedPatch.syntheticTiming)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,16 +414,22 @@ export function useTravelReimbursementFlow({
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const explicitDuration = Number(durationMs)
|
||||
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
|
||||
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
|
||||
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : 0)
|
||||
const measuredDuration = hasExplicitDuration
|
||||
? explicitDuration
|
||||
: startedAt && !currentStep?.syntheticTiming
|
||||
? Math.max(0, now - startedAt)
|
||||
: null
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || definition?.completedText || '',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
|
||||
durationMs: measuredDuration,
|
||||
error: '',
|
||||
deferredCompletion: false
|
||||
deferredCompletion: false,
|
||||
syntheticTiming: false
|
||||
})
|
||||
if (
|
||||
flowSteps.value.length
|
||||
@@ -323,15 +457,21 @@ export function useTravelReimbursementFlow({
|
||||
}
|
||||
|
||||
function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const patchObject = patch && typeof patch === 'object' ? { ...patch } : {}
|
||||
const refreshCompleted = Boolean(patchObject.refreshCompleted)
|
||||
delete patchObject.refreshCompleted
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return
|
||||
}
|
||||
const normalizedDuration = Number(durationMs)
|
||||
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
if (refreshCompleted && hasMeasuredDuration) {
|
||||
completeFlowStep(key, detail, normalizedDuration, patchObject)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
|
||||
const revealOrder = flowSteps.value.length
|
||||
startFlowStep(key, { ...patch, deferredCompletion: true })
|
||||
startFlowStep(key, { ...patchObject, deferredCompletion: true, syntheticTiming: !hasMeasuredDuration })
|
||||
const completionTimer = window.setTimeout(() => {
|
||||
completeFlowStep(
|
||||
key,
|
||||
@@ -343,7 +483,7 @@ export function useTravelReimbursementFlow({
|
||||
flowSimulationTimers.push(completionTimer)
|
||||
return
|
||||
}
|
||||
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
|
||||
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patchObject)
|
||||
}
|
||||
|
||||
function failCurrentFlowStep(error) {
|
||||
@@ -527,7 +667,51 @@ export function useTravelReimbursementFlow({
|
||||
})
|
||||
}
|
||||
|
||||
function isApplicationSessionActive() {
|
||||
return String(activeSessionType?.value || '').trim() === 'application'
|
||||
}
|
||||
|
||||
function isSubmittedApplicationPayload(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
||||
? payload.draft_payload
|
||||
: null
|
||||
return Boolean(
|
||||
draftPayload
|
||||
&& String(draftPayload.draft_type || '').trim() === 'expense_application'
|
||||
&& String(draftPayload.status || '').trim() === 'submitted'
|
||||
)
|
||||
}
|
||||
|
||||
function buildApplicationSubmitSuccessDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: {}
|
||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||
const approvalStage = String(draftPayload.approval_stage || '').trim() || '直属领导审批'
|
||||
return claimNo
|
||||
? `申请单 ${claimNo} 已提交成功,当前节点:${approvalStage}`
|
||||
: `申请单提交成功,当前节点:${approvalStage}`
|
||||
}
|
||||
|
||||
function shouldHideToolCall(toolCall) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
return (
|
||||
toolName.includes('semantic_ontology')
|
||||
|| toolName.includes('ontology.')
|
||||
|| toolType.includes('semantic_ontology')
|
||||
|| toolType.includes('ontology')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveToolCallFlowMeta(toolCall, index) {
|
||||
if (shouldHideToolCall(toolCall)) {
|
||||
return null
|
||||
}
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
@@ -535,17 +719,31 @@ export function useTravelReimbursementFlow({
|
||||
: {}
|
||||
const responseMessage = String(response.message || '').trim()
|
||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||
if (
|
||||
isApplicationSessionActive()
|
||||
&& (
|
||||
String(response.status || '').trim() === 'submitted'
|
||||
|| String(response?.draft_payload?.status || '').trim() === 'submitted'
|
||||
)
|
||||
) {
|
||||
return { key: 'application-submit-success', title: '申请单提交成功', tool: 'ApplicationSubmit' }
|
||||
}
|
||||
if (toolType.includes('rule')) {
|
||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||
}
|
||||
if (toolType.includes('mcp')) {
|
||||
return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' }
|
||||
return toolName.includes('standard')
|
||||
? { key, title: '差旅补助标准查询', tool: 'TravelStandard' }
|
||||
: null
|
||||
}
|
||||
if (toolName.includes('knowledge')) {
|
||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||
}
|
||||
if (toolName.includes('application_review_preview')) {
|
||||
return { key: 'application-review-preview', title: '申请信息核对', tool: 'ApplicationReview' }
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return { key: 'expense-review-preview', title: '报销信息核对', tool: toolCall?.tool_name || 'user_agent.expense_review_preview' }
|
||||
return { key: 'expense-review-preview', title: '报销信息核对', tool: 'ExpenseReview' }
|
||||
}
|
||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||
if (
|
||||
@@ -564,39 +762,45 @@ export function useTravelReimbursementFlow({
|
||||
}
|
||||
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (toolType.includes('database')) {
|
||||
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
||||
}
|
||||
if (toolType.includes('llm') || toolName.includes('user_agent')) {
|
||||
return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' }
|
||||
}
|
||||
return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' }
|
||||
return null
|
||||
}
|
||||
|
||||
function summarizeFlowToolCall(toolCall) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
if (toolName.includes('application_review_preview')) {
|
||||
return '已整理申请核对信息'
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return '已整理报销核对信息'
|
||||
}
|
||||
if (String(response.status || '').trim() === 'submitted') {
|
||||
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
return isApplicationSessionActive()
|
||||
? '申请单提交成功'
|
||||
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批'
|
||||
return summarizeVisibleToolText(response.message) || 'AI预审发现待补充项,暂未提交审批'
|
||||
}
|
||||
return (
|
||||
String(response.message || response.summary || response.result_summary || '').trim()
|
||||
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
|
||||
|| String(toolCall?.tool_name || '').trim()
|
||||
|| '工具调用完成'
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFlowRunDetail(run) {
|
||||
const runStartedAt = resolveStartedTimestamp(run)
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
if (runStartedAt) {
|
||||
flowStartedAt.value = runStartedAt
|
||||
}
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
||||
clearFlowSimulationTimers()
|
||||
const semanticDurations = resolveSemanticPhaseDurations(run)
|
||||
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse, {
|
||||
@@ -605,17 +809,26 @@ export function useTravelReimbursementFlow({
|
||||
expenseTypeLabels: EXPENSE_TYPE_LABELS,
|
||||
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}),
|
||||
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||
semanticDurations.intentMs,
|
||||
{ refreshCompleted: true }
|
||||
)
|
||||
completePendingFlowStep(
|
||||
'extraction',
|
||||
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
||||
extractionStep?.startedAt ? null : semanticDurations.extractionMs
|
||||
semanticDurations.extractionMs,
|
||||
{ refreshCompleted: true }
|
||||
)
|
||||
}
|
||||
|
||||
const hasApplicationSubmitSuccess = flowSteps.value.some((step) => step.key === 'application-submit-success')
|
||||
toolCalls.forEach((toolCall, index) => {
|
||||
const meta = resolveToolCallFlowMeta(toolCall, index)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
if (hasApplicationSubmitSuccess && isApplicationSessionActive() && meta.key !== 'application-submit-success') {
|
||||
return
|
||||
}
|
||||
const failed = String(toolCall?.status || '').toLowerCase() === 'failed'
|
||||
if (failed) {
|
||||
failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta)
|
||||
@@ -625,7 +838,7 @@ export function useTravelReimbursementFlow({
|
||||
meta.key,
|
||||
summarizeFlowToolCall(toolCall),
|
||||
toolDurationMs,
|
||||
meta
|
||||
{ ...meta, refreshCompleted: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -634,6 +847,13 @@ export function useTravelReimbursementFlow({
|
||||
failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' })
|
||||
return
|
||||
}
|
||||
if (
|
||||
runFinishedAt
|
||||
&& flowSteps.value.length
|
||||
&& flowSteps.value.every((step) => [FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||
) {
|
||||
flowFinishedAt.value = runFinishedAt
|
||||
}
|
||||
}
|
||||
|
||||
function completeFlowResult(payload, run = null) {
|
||||
@@ -651,11 +871,20 @@ export function useTravelReimbursementFlow({
|
||||
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
|
||||
completeFlowStep(step.key, detail)
|
||||
})
|
||||
if (isSubmittedApplicationPayload(payload)) {
|
||||
completePendingFlowStep(
|
||||
'application-submit-success',
|
||||
buildApplicationSubmitSuccessDetail(payload),
|
||||
null,
|
||||
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
|
||||
)
|
||||
}
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
flowFinishedAt.value = flowSteps.value.some(
|
||||
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
|
||||
)
|
||||
? 0
|
||||
: Date.now()
|
||||
: runFinishedAt || Date.now()
|
||||
}
|
||||
|
||||
async function refreshFlowRunDetail() {
|
||||
|
||||
@@ -65,6 +65,10 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
function resolveDefaultSessionTypeFromEntry() {
|
||||
const initialSessionType = String(props.initialSessionType || '').trim()
|
||||
if (initialSessionType) {
|
||||
return initialSessionType
|
||||
}
|
||||
if (props.entrySource === 'budget') {
|
||||
return SESSION_TYPE_BUDGET
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
currentInsight,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
emitOperationCompleted,
|
||||
emitRequestUpdated,
|
||||
extractReviewAttachmentNames,
|
||||
failCurrentFlowStep,
|
||||
fetchExpenseClaims,
|
||||
@@ -101,6 +103,36 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
const pendingAttachmentAssociations = new Map()
|
||||
|
||||
function isSubmittedApplicationDraftPayload(draftPayload) {
|
||||
return (
|
||||
String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
&& String(draftPayload?.status || '').trim() === 'submitted'
|
||||
)
|
||||
}
|
||||
|
||||
function buildOperationFeedbackState(context) {
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
context,
|
||||
submitting: false,
|
||||
submitted: false,
|
||||
dismissed: false,
|
||||
rating: 0,
|
||||
reason: '',
|
||||
error: ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssistantResultText(payload, fallbackAnswer) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
if (isSubmittedApplicationDraftPayload(result.draft_payload)) {
|
||||
return ''
|
||||
}
|
||||
return result.answer || result.message || fallbackAnswer
|
||||
}
|
||||
|
||||
function createPendingAttachmentAssociationId() {
|
||||
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
@@ -411,6 +443,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const feedbackOperationType = String(options.feedbackOperationType || '').trim()
|
||||
const attachmentAssociationConfirmed = Boolean(
|
||||
options.associationConfirmed ||
|
||||
extraContext.attachment_association_confirmed ||
|
||||
@@ -966,7 +999,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||||
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
||||
: '智能体已完成处理。'
|
||||
const assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
|
||||
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
|
||||
? emitOperationCompleted?.(payload, {
|
||||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||||
})
|
||||
: null
|
||||
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
@@ -981,7 +1019,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
|
||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext)
|
||||
})
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
const nextInsight = buildAgentInsight(
|
||||
@@ -996,12 +1035,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
.then(() => {
|
||||
.then((syncResult) => {
|
||||
persistSessionState()
|
||||
if (detailScopedUpload && Number(syncResult?.uploadedCount || 0) > 0) {
|
||||
emitRequestUpdated?.({
|
||||
claimId: resolvedDraftClaimId,
|
||||
source: 'detail-smart-entry-attachment-sync'
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
|
||||
Reference in New Issue
Block a user