feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>

View File

@@ -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"

View File

@@ -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,

View File

@@ -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
}
}
}

View File

@@ -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,

View File

@@ -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: '升级',

View File

@@ -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

View File

@@ -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 || '未指定目录'}`
}

View 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, '')
}

View File

@@ -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)
)

View File

@@ -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 }
]
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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') {

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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
}

View File

@@ -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)