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

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