feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -152,6 +152,23 @@ export default {
|
||||
const canToggleRiskRuleEnabled = computed(
|
||||
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
|
||||
)
|
||||
const canEditRiskRuleDraft = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
(canEditSelected.value || canManageSelected.value) &&
|
||||
!detailBusy.value &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
|
||||
)
|
||||
const canCreateRiskRuleRevision = computed(
|
||||
() =>
|
||||
selectedSkillUsesJsonRisk.value &&
|
||||
(canEditSelected.value || canManageSelected.value) &&
|
||||
!detailBusy.value &&
|
||||
!riskRuleGenerationBusy.value &&
|
||||
!riskRuleGenerationFailed.value &&
|
||||
Boolean(normalizeText(selectedSkill.value?.publishedVersion).replace('-', ''))
|
||||
)
|
||||
const canEditMarkdown = computed(() => selectedSkillIsRule.value && canEditSelected.value)
|
||||
const isDisplayingWorkingVersion = computed(
|
||||
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||
@@ -330,10 +347,16 @@ export default {
|
||||
riskRuleReturnOpen,
|
||||
riskRulePublishOpen,
|
||||
riskRuleReturnNote,
|
||||
riskRuleEditOpen,
|
||||
riskRuleEditMode,
|
||||
riskRuleEditForm,
|
||||
resetRiskRuleActionDialogs,
|
||||
openRiskRuleTestDialog,
|
||||
closeRiskRuleTestDialog,
|
||||
handleRiskRuleReportSaved,
|
||||
openRiskRuleEditDialog,
|
||||
closeRiskRuleEditDialog,
|
||||
submitRiskRuleEdit,
|
||||
openDeleteRiskRuleDialog,
|
||||
closeDeleteRiskRuleDialog,
|
||||
deleteSelectedRiskRule,
|
||||
@@ -353,6 +376,8 @@ export default {
|
||||
canReturnRiskRule,
|
||||
canPublishRiskRule,
|
||||
canToggleRiskRuleEnabled,
|
||||
canEditRiskRuleDraft,
|
||||
canCreateRiskRuleRevision,
|
||||
riskRuleTestPassed,
|
||||
refreshCurrentAssets,
|
||||
loadSelectedAssetDetail,
|
||||
@@ -719,6 +744,9 @@ export default {
|
||||
riskRuleReturnOpen,
|
||||
riskRulePublishOpen,
|
||||
riskRuleReturnNote,
|
||||
riskRuleEditOpen,
|
||||
riskRuleEditMode,
|
||||
riskRuleEditForm,
|
||||
riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS,
|
||||
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
|
||||
showReviewNote,
|
||||
@@ -762,6 +790,9 @@ export default {
|
||||
openRiskRuleTestDialog,
|
||||
closeRiskRuleTestDialog,
|
||||
handleRiskRuleReportSaved,
|
||||
openRiskRuleEditDialog,
|
||||
closeRiskRuleEditDialog,
|
||||
submitRiskRuleEdit,
|
||||
openDeleteRiskRuleDialog,
|
||||
closeDeleteRiskRuleDialog,
|
||||
deleteSelectedRiskRule,
|
||||
|
||||
@@ -16,8 +16,13 @@ import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSu
|
||||
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
|
||||
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
|
||||
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
|
||||
import {
|
||||
buildOperationFeedbackPayload,
|
||||
normalizeOperationFeedbackContext
|
||||
} from '../../composables/useOperationFeedback.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { createOperationFeedback } from '../../services/operationFeedback.js'
|
||||
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
import { renderMarkdown } from '../../utils/markdown.js'
|
||||
import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js'
|
||||
@@ -46,6 +51,7 @@ import {
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
calculateTravelReimbursement,
|
||||
createExpenseClaimItem,
|
||||
fetchExpenseClaims,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
fetchExpenseClaimDetail,
|
||||
@@ -526,6 +532,10 @@ export default {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
initialSessionType: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
entrySource: {
|
||||
type: String,
|
||||
default: 'requests'
|
||||
@@ -543,7 +553,7 @@ export default {
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
emits: ['close', 'draft-saved'],
|
||||
emits: ['close', 'draft-saved', 'request-updated'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const { currentUser } = useSystemState()
|
||||
@@ -605,14 +615,42 @@ export default {
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
commitApplicationPreviewDateEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
setApplicationPreviewDateMode,
|
||||
canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
} = useApplicationPreviewEditor({
|
||||
persistSessionState,
|
||||
toast
|
||||
})
|
||||
|
||||
function applyLinkedApplicationPreviewDateSelection(selection) {
|
||||
const editor = applicationPreviewEditor.value
|
||||
if (editor.fieldKey !== 'time' || !editor.messageId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const targetMessage = messages.value.find((item) =>
|
||||
String(item.id || '') === String(editor.messageId || '')
|
||||
)
|
||||
if (!targetMessage?.applicationPreview) {
|
||||
return false
|
||||
}
|
||||
|
||||
applicationPreviewEditor.value = {
|
||||
...editor,
|
||||
dateMode: selection.mode === 'range' ? 'range' : 'single',
|
||||
singleDate: selection.startDate,
|
||||
rangeStartDate: selection.startDate,
|
||||
rangeEndDate: selection.endDate || selection.startDate
|
||||
}
|
||||
return commitApplicationPreviewDateEditor(targetMessage)
|
||||
}
|
||||
|
||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
|
||||
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
|
||||
@@ -884,8 +922,34 @@ export default {
|
||||
buildReviewSlotMap,
|
||||
isValidIsoDateString,
|
||||
buildLocallySyncedReviewPayload,
|
||||
formatDateInputValue
|
||||
formatDateInputValue,
|
||||
onComposerDateSelection: applyLinkedApplicationPreviewDateSelection
|
||||
})
|
||||
|
||||
function syncComposerDateFromApplicationEditor() {
|
||||
const editor = applicationPreviewEditor.value
|
||||
const today = formatDateInputValue()
|
||||
composerDateMode.value = editor.dateMode === 'range' ? 'range' : 'single'
|
||||
composerSingleDate.value = editor.singleDate || today
|
||||
composerRangeStartDate.value = editor.rangeStartDate || composerSingleDate.value || today
|
||||
composerRangeEndDate.value = editor.rangeEndDate || composerRangeStartDate.value || today
|
||||
composerDatePickerOpen.value = true
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
|
||||
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
|
||||
openApplicationPreviewEditor(message, fieldKey, value)
|
||||
if (fieldKey === 'time' && isApplicationPreviewEditing(message, 'time')) {
|
||||
syncComposerDateFromApplicationEditor()
|
||||
}
|
||||
}
|
||||
|
||||
watch(composerDatePickerOpen, (open, previousOpen) => {
|
||||
if (!open && previousOpen && applicationPreviewEditor.value.fieldKey === 'time') {
|
||||
cancelApplicationPreviewEditor()
|
||||
}
|
||||
})
|
||||
|
||||
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
|
||||
const {
|
||||
fileInputMode,
|
||||
@@ -918,6 +982,7 @@ export default {
|
||||
reviewActionBusy,
|
||||
toast,
|
||||
fileInputRef,
|
||||
createExpenseClaimItem,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
@@ -939,6 +1004,32 @@ export default {
|
||||
composerFilesExpanded,
|
||||
guidedFlowState
|
||||
}
|
||||
const promptedOperationFeedbackRunIds = new Set()
|
||||
|
||||
function emitOperationCompleted(payload = {}, extras = {}) {
|
||||
const runId = String(payload?.run_id || payload?.runId || '').trim()
|
||||
const operationStatus = String(payload?.status || '').trim()
|
||||
if (!runId || promptedOperationFeedbackRunIds.has(runId) || operationStatus !== 'succeeded') {
|
||||
return null
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
promptedOperationFeedbackRunIds.add(runId)
|
||||
return normalizeOperationFeedbackContext({
|
||||
run_id: runId,
|
||||
conversation_id: String(payload?.conversation_id || payload?.conversationId || conversationId.value || '').trim(),
|
||||
user_id: resolveCurrentUserId(),
|
||||
selected_agent: String(payload?.selected_agent || payload?.selectedAgent || '').trim(),
|
||||
source: 'user_message',
|
||||
session_type: activeSessionType.value,
|
||||
operation_type: String(extras.operationType || 'assistant_round').trim(),
|
||||
operation_status: operationStatus,
|
||||
status: operationStatus,
|
||||
route_reason: String(payload?.route_reason || payload?.routeReason || '').trim(),
|
||||
entry_source: props.entrySource,
|
||||
trace_summary: payload?.trace_summary || payload?.traceSummary || null,
|
||||
result_summary: String(result.answer || result.message || '').trim()
|
||||
}, currentUser.value || {})
|
||||
}
|
||||
const {
|
||||
confirmPendingAttachmentAssociationInternal,
|
||||
submitComposerInternal
|
||||
@@ -1016,6 +1107,8 @@ export default {
|
||||
startSemanticFlowPreview,
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
emitOperationCompleted,
|
||||
emitRequestUpdated: (payload) => emit('request-updated', payload),
|
||||
toast
|
||||
})
|
||||
const canSubmit = computed(
|
||||
@@ -1757,6 +1850,121 @@ export default {
|
||||
return buildApplicationPreviewFooterMessage(message.applicationPreview)
|
||||
}
|
||||
|
||||
function isApplicationDraftPayload(draftPayload) {
|
||||
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
}
|
||||
|
||||
function resolveDraftPayloadBodyField(draftPayload, label) {
|
||||
const body = String(draftPayload?.body || '')
|
||||
const pattern = new RegExp(`^${label}:(.+)$`, 'm')
|
||||
return String(body.match(pattern)?.[1] || '').trim()
|
||||
}
|
||||
|
||||
function resolveApplicationDraftStatusLabel(draftPayload) {
|
||||
const status = String(draftPayload?.status || '').trim()
|
||||
if (status === 'submitted') return '审批中'
|
||||
return status || '已生成'
|
||||
}
|
||||
|
||||
function buildApplicationDraftSummaryItems(draftPayload) {
|
||||
if (!isApplicationDraftPayload(draftPayload)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{ label: '单号', value: String(draftPayload?.claim_no || '').trim() || '待生成' },
|
||||
{ label: '类型', value: String(draftPayload?.title || '').trim() || '费用申请' },
|
||||
{ label: '节点', value: String(draftPayload?.approval_stage || '').trim() || '直属领导审批' },
|
||||
{ label: '时间', value: resolveDraftPayloadBodyField(draftPayload, '发生时间') },
|
||||
{ label: '费用', value: resolveDraftPayloadBodyField(draftPayload, '用户预估费用') }
|
||||
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
|
||||
}
|
||||
|
||||
function updateMessageOperationFeedback(message, patch = {}) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
}
|
||||
messages.value = messages.value.map((item) => (
|
||||
item.id === message.id
|
||||
? {
|
||||
...item,
|
||||
operationFeedback: {
|
||||
...(item.operationFeedback || {}),
|
||||
...patch
|
||||
}
|
||||
}
|
||||
: item
|
||||
))
|
||||
}
|
||||
|
||||
function isOperationFeedbackVisible(message) {
|
||||
const feedback = message?.operationFeedback || null
|
||||
return Boolean(
|
||||
feedback?.context
|
||||
&& !feedback.dismissed
|
||||
)
|
||||
}
|
||||
|
||||
function dismissOperationFeedbackForMessage(message) {
|
||||
updateMessageOperationFeedback(message, {
|
||||
dismissed: true,
|
||||
error: ''
|
||||
})
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
async function submitOperationFeedbackForMessage(message, feedback = {}) {
|
||||
const rating = Number(feedback.rating || 0)
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||
updateMessageOperationFeedback(message, { error: '请选择 1 到 5 星评分。' })
|
||||
return
|
||||
}
|
||||
|
||||
const context = message?.operationFeedback?.context || null
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: true,
|
||||
rating,
|
||||
reason: String(feedback.reason || '').trim(),
|
||||
error: ''
|
||||
})
|
||||
try {
|
||||
await createOperationFeedback(
|
||||
buildOperationFeedbackPayload(context, feedback, currentUser.value || {})
|
||||
)
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: false,
|
||||
submitted: true,
|
||||
dismissed: false,
|
||||
rating,
|
||||
reason: String(feedback.reason || '').trim(),
|
||||
error: ''
|
||||
})
|
||||
persistSessionState()
|
||||
} catch (error) {
|
||||
updateMessageOperationFeedback(message, {
|
||||
submitting: false,
|
||||
error: error?.message || '评价提交失败,请稍后重试。'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function openApplicationDraftDetail(message) {
|
||||
const draftPayload = message?.draftPayload || {}
|
||||
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
if (!claimId) {
|
||||
toast('暂未获取到申请单据 ID,稍后可在单据中心查看。')
|
||||
return
|
||||
}
|
||||
await router.push({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewMissingFields(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return []
|
||||
@@ -1818,6 +2026,7 @@ export default {
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true,
|
||||
skipScopeGuard: true,
|
||||
feedbackOperationType: 'submit_application',
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
user_input_text: applicationSubmitText
|
||||
@@ -2181,10 +2390,21 @@ export default {
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveApplicationPreviewMissingFields,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor: openApplicationPreviewEditorFromUi,
|
||||
commitApplicationPreviewEditor,
|
||||
commitApplicationPreviewDateEditor,
|
||||
setApplicationPreviewDateMode,
|
||||
canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown,
|
||||
buildApplicationPreviewFooterText,
|
||||
isApplicationDraftPayload,
|
||||
resolveApplicationDraftStatusLabel,
|
||||
buildApplicationDraftSummaryItems,
|
||||
openApplicationDraftDetail,
|
||||
isOperationFeedbackVisible,
|
||||
dismissOperationFeedbackForMessage,
|
||||
submitOperationFeedbackForMessage,
|
||||
runWelcomeQuickAction: runShortcut,
|
||||
handleSuggestedAction,
|
||||
isSuggestedActionSelected,
|
||||
@@ -2297,7 +2517,7 @@ export default {
|
||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import TravelRequestApprovalDialog from '../../components/travel/TravelRequestAp
|
||||
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
||||
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
||||
import EmployeeProfileRiskCard from '../../components/travel/EmployeeProfileRiskCard.vue'
|
||||
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
|
||||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||||
import {
|
||||
approveExpenseClaim,
|
||||
@@ -39,7 +40,10 @@ import {
|
||||
buildLeaderApprovalInfo,
|
||||
resolveGeneratedDraftClaimNo
|
||||
} from '../../utils/applicationApproval.js'
|
||||
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
|
||||
import {
|
||||
buildApplicationDetailFactItems,
|
||||
buildRelatedApplicationFactItems
|
||||
} from '../../utils/expenseApplicationDetail.js'
|
||||
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
@@ -374,6 +378,7 @@ export default {
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect,
|
||||
EmployeeProfileRiskCard,
|
||||
RiskObservationEvidenceCard,
|
||||
TravelRequestApprovalDialog,
|
||||
TravelRequestBudgetAnalysis,
|
||||
TravelRequestDeleteDialog,
|
||||
@@ -793,6 +798,7 @@ export default {
|
||||
return formatCurrency(total)
|
||||
})
|
||||
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
||||
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const expenseTableColumnCount = computed(
|
||||
@@ -1920,7 +1926,7 @@ export default {
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalOpinionHint,
|
||||
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
||||
applicationDetailFactItems,
|
||||
applicationDetailFactItems, relatedApplicationFactItems,
|
||||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
|
||||
@@ -3,7 +3,8 @@ export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
daily_risk_scan: '每日风险巡检',
|
||||
global_risk_scan: '全局风险巡检',
|
||||
global_risk_scan: '财务风险图谱巡检',
|
||||
employee_behavior_profile_scan: '员工行为画像巡检',
|
||||
weekly_ar_summary: '周度应收账龄汇总',
|
||||
weekly_expense_report: '周度费用洞察',
|
||||
rule_review_digest: '规则待审摘要',
|
||||
@@ -16,6 +17,7 @@ const TASK_TYPE_LABELS = {
|
||||
const TASK_TYPE_SKILL_CATEGORIES = {
|
||||
daily_risk_scan: '评估',
|
||||
global_risk_scan: '评估',
|
||||
employee_behavior_profile_scan: '评估',
|
||||
weekly_ar_summary: '整理',
|
||||
weekly_expense_report: '整理',
|
||||
rule_review_digest: '升级',
|
||||
|
||||
@@ -247,28 +247,39 @@ export function resolveRiskRuleConditionSummary(payload) {
|
||||
|
||||
export function resolveRiskRuleFlow(payload, fields) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const flowModel = resolveFlowModel(payload)
|
||||
const flow = metadata && typeof metadata.flow === 'object' ? metadata.flow : {}
|
||||
const fieldSummary = buildRiskRuleFieldSummary(fields)
|
||||
const conditionSummary = resolveRiskRuleConditionSummary(payload)
|
||||
const severityLabel = resolveRiskRuleSeverityLabel(payload)
|
||||
const isCityRouteRule = isCityRouteConsistencyPayload(payload)
|
||||
const modelNodes = Array.isArray(flowModel?.nodes) ? flowModel.nodes : []
|
||||
const startNode = modelNodes.find((node) => node?.type === 'start')
|
||||
const evidenceNode = modelNodes.find((node) => node?.type === 'evidence')
|
||||
const riskNode = modelNodes.find((node) => node?.type === 'risk')
|
||||
const passNode = modelNodes.find((node) => node?.type === 'pass')
|
||||
|
||||
return {
|
||||
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
|
||||
evidence: isCityRouteRule
|
||||
start: normalizeRiskRuleText(startNode?.description) || normalizeRiskRuleText(flow.start) || '业务单据提交',
|
||||
evidence: normalizeRiskRuleText(evidenceNode?.description) || (isCityRouteRule
|
||||
? CITY_ROUTE_FLOW_EVIDENCE
|
||||
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
|
||||
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`),
|
||||
decision: isCityRouteRule
|
||||
? CITY_ROUTE_FLOW_DECISION
|
||||
: normalizeRiskRuleText(flow.decision) || conditionSummary,
|
||||
basis: conditionSummary,
|
||||
...resolveRiskRuleFlowDetails(payload, fields),
|
||||
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
|
||||
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
|
||||
...resolveRiskRuleFlowDetails(payload, fields, flowModel),
|
||||
flowModel,
|
||||
pass: normalizeRiskRuleText(passNode?.description) || normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
|
||||
fail: normalizeRiskRuleText(riskNode?.description) || normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskRuleFlowDetails(payload, fields) {
|
||||
function resolveRiskRuleFlowDetails(payload, fields, flowModel = null) {
|
||||
const modelDetails = resolveFlowModelDetails(flowModel, fields)
|
||||
if (modelDetails) {
|
||||
return modelDetails
|
||||
}
|
||||
const params = payload && typeof payload === 'object' && payload.params && typeof payload.params === 'object'
|
||||
? payload.params
|
||||
: {}
|
||||
@@ -283,6 +294,44 @@ function resolveRiskRuleFlowDetails(payload, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFlowModel(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
|
||||
const flowModel = payload.flow_model && typeof payload.flow_model === 'object'
|
||||
? payload.flow_model
|
||||
: metadata.flow_model
|
||||
return flowModel && typeof flowModel === 'object' ? flowModel : null
|
||||
}
|
||||
|
||||
function resolveFlowModelDetails(flowModel, fields) {
|
||||
const nodes = Array.isArray(flowModel?.nodes) ? flowModel.nodes : []
|
||||
if (!nodes.length) {
|
||||
return null
|
||||
}
|
||||
const labelByKey = buildLabelByKey(fields)
|
||||
const evidenceNodes = nodes.filter((node) => node?.type === 'evidence')
|
||||
const decisionNodes = nodes.filter((node) => node?.type === 'decision')
|
||||
const facts = evidenceNodes.flatMap((node) => {
|
||||
const keys = readStringList(node?.fields)
|
||||
const fieldText = keys.slice(0, 4).map((key) => `${labelByKey[key] || key}[${key}]`)
|
||||
return fieldText.length
|
||||
? fieldText
|
||||
: [normalizeRiskRuleText(node?.description)]
|
||||
}).filter(Boolean)
|
||||
const conditions = decisionNodes.map((node, index) => {
|
||||
const title = normalizeRiskRuleText(node?.title || `判断 ${index + 1}`)
|
||||
const description = normalizeRiskRuleText(node?.description)
|
||||
return description ? `${title}:${description}` : title
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
facts: facts.length ? facts : buildFieldFactLines(fields),
|
||||
conditions,
|
||||
hitLogic: conditions.join(' AND ')
|
||||
}
|
||||
}
|
||||
|
||||
function buildFactLines(facts, fields) {
|
||||
const labelByKey = buildLabelByKey(fields)
|
||||
const rows = facts
|
||||
|
||||
@@ -18,6 +18,47 @@ const KNOWLEDGE_JOB_TYPES = new Set([
|
||||
'finance_policy_knowledge_organize'
|
||||
])
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
global_risk_scan: '财务风险图谱巡检',
|
||||
employee_behavior_profile_scan: '员工行为画像巡检',
|
||||
finance_policy_knowledge_organize: '知识制度整理',
|
||||
knowledge_index_sync: '知识制度整理',
|
||||
llm_wiki_sync: '知识制度整理',
|
||||
llm_wiki_rule_formation: '知识制度整理'
|
||||
}
|
||||
|
||||
const TASK_CODE_TO_TYPE = {
|
||||
'task.hermes.global_risk_scan': 'global_risk_scan',
|
||||
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
|
||||
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
|
||||
}
|
||||
|
||||
function toObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function normalizeTaskType(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
return TASK_CODE_TO_TYPE[normalized] || normalized
|
||||
}
|
||||
|
||||
function resolveTaskTypeFromToolName(value) {
|
||||
const name = String(value || '').trim()
|
||||
if (name.includes('financial_risk_graph')) {
|
||||
return 'global_risk_scan'
|
||||
}
|
||||
if (name.includes('employee_behavior_profile')) {
|
||||
return 'employee_behavior_profile_scan'
|
||||
}
|
||||
if (name.includes('finance_policy_knowledge')) {
|
||||
return 'finance_policy_knowledge_organize'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatWorkRecordDateTime(value) {
|
||||
if (!value) {
|
||||
return '未结束'
|
||||
@@ -46,8 +87,89 @@ export function resolveWorkRecordSourceLabel(source) {
|
||||
return SOURCE_LABELS[source] || source || '未标记'
|
||||
}
|
||||
|
||||
export function resolveWorkRecordTaskType(run) {
|
||||
const routeJson = toObject(run?.route_json)
|
||||
const routeCandidates = [
|
||||
routeJson.job_type,
|
||||
routeJson.task_type,
|
||||
routeJson.report_type,
|
||||
routeJson.task_code
|
||||
].map(normalizeTaskType)
|
||||
|
||||
for (const candidate of routeCandidates) {
|
||||
if (candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
for (const toolCall of run?.tool_calls || []) {
|
||||
const requestJson = toObject(toolCall?.request_json)
|
||||
const responseJson = toObject(toolCall?.response_json)
|
||||
const candidates = [
|
||||
requestJson.task_type,
|
||||
requestJson.job_type,
|
||||
responseJson.report_type,
|
||||
responseJson.task_type,
|
||||
responseJson.job_type,
|
||||
resolveTaskTypeFromToolName(toolCall?.tool_name)
|
||||
].map(normalizeTaskType)
|
||||
|
||||
const matched = candidates.find(Boolean)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveWorkRecordTaskLabel(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
return TASK_TYPE_LABELS[taskType] || ''
|
||||
}
|
||||
|
||||
export function resolveWorkRecordProductKind(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
if (taskType === 'global_risk_scan') {
|
||||
return 'risk_graph'
|
||||
}
|
||||
if (taskType === 'employee_behavior_profile_scan') {
|
||||
return 'employee_profile'
|
||||
}
|
||||
if (KNOWLEDGE_JOB_TYPES.has(taskType)) {
|
||||
return 'knowledge'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function extractWorkRecordToolSummary(run) {
|
||||
const taskType = resolveWorkRecordTaskType(run)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const matchedCall = toolCalls.find((toolCall) => {
|
||||
const requestJson = toObject(toolCall?.request_json)
|
||||
const responseJson = toObject(toolCall?.response_json)
|
||||
const candidates = [
|
||||
requestJson.task_type,
|
||||
requestJson.job_type,
|
||||
responseJson.report_type,
|
||||
responseJson.task_type,
|
||||
responseJson.job_type,
|
||||
resolveTaskTypeFromToolName(toolCall?.tool_name)
|
||||
].map(normalizeTaskType)
|
||||
return candidates.includes(taskType)
|
||||
}) || toolCalls[0]
|
||||
|
||||
const responseJson = toObject(matchedCall?.response_json)
|
||||
const nestedSummary = toObject(responseJson.summary)
|
||||
return Object.keys(nestedSummary).length ? nestedSummary : responseJson
|
||||
}
|
||||
|
||||
export function resolveWorkRecordModuleLabel(run) {
|
||||
const routeJson = run?.route_json || {}
|
||||
const taskLabel = resolveWorkRecordTaskLabel(run)
|
||||
if (taskLabel) {
|
||||
return taskLabel
|
||||
}
|
||||
if (KNOWLEDGE_JOB_TYPES.has(routeJson.job_type)) {
|
||||
return '知识制度整理'
|
||||
}
|
||||
@@ -62,6 +184,11 @@ export function resolveWorkRecordModuleLabel(run) {
|
||||
|
||||
export function resolveWorkRecordTitle(run) {
|
||||
const routeJson = run?.route_json || {}
|
||||
const taskLabel = resolveWorkRecordTaskLabel(run)
|
||||
if (taskLabel) {
|
||||
const suffix = String(routeJson.task_name || routeJson.folder || '本次运行').trim()
|
||||
return suffix && suffix !== taskLabel ? `${taskLabel} · ${suffix}` : taskLabel
|
||||
}
|
||||
if (KNOWLEDGE_JOB_TYPES.has(routeJson.job_type)) {
|
||||
return `知识制度整理 · ${routeJson.folder || '未指定目录'}`
|
||||
}
|
||||
|
||||
224
web/src/views/scripts/receiptFolderDetailFields.js
Normal file
224
web/src/views/scripts/receiptFolderDetailFields.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const TRAIN_KEY_FIELD_DEFINITIONS = [
|
||||
{
|
||||
id: 'invoice_number',
|
||||
label: '发票号码',
|
||||
placeholder: '待识别',
|
||||
keys: ['invoice_number', 'ticket_number'],
|
||||
labels: ['发票号码', '票据号码', '票号']
|
||||
},
|
||||
{
|
||||
id: 'invoice_date',
|
||||
label: '开票日期',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
keys: ['invoice_date', 'issue_date'],
|
||||
labels: ['开票日期', '发票日期']
|
||||
},
|
||||
{
|
||||
id: 'fare',
|
||||
label: '票价',
|
||||
placeholder: '待识别',
|
||||
keys: ['fare', 'amount'],
|
||||
labels: ['票价', '金额']
|
||||
},
|
||||
{
|
||||
id: 'passenger_name',
|
||||
label: '姓名',
|
||||
placeholder: '待识别',
|
||||
keys: ['passenger_name'],
|
||||
labels: ['乘车人', '旅客姓名', '姓名']
|
||||
}
|
||||
]
|
||||
|
||||
const DEFAULT_KEY_FIELD_DEFINITIONS = [
|
||||
{
|
||||
id: 'invoice_number',
|
||||
label: '发票号码',
|
||||
placeholder: '待识别',
|
||||
keys: ['invoice_number', 'ticket_number'],
|
||||
labels: ['发票号码', '票据号码', '票号']
|
||||
},
|
||||
{
|
||||
id: 'invoice_date',
|
||||
label: '开票日期',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
keys: ['invoice_date', 'issue_date'],
|
||||
labels: ['开票日期', '发票日期']
|
||||
},
|
||||
{
|
||||
id: 'amount',
|
||||
label: '金额',
|
||||
placeholder: '待识别',
|
||||
keys: ['amount', 'fare'],
|
||||
labels: ['金额', '价税合计', '合计金额', '票价']
|
||||
},
|
||||
{
|
||||
id: 'merchant_name',
|
||||
label: '商户',
|
||||
placeholder: '待识别',
|
||||
keys: ['merchant_name'],
|
||||
labels: ['商户', '销售方', '开票方']
|
||||
}
|
||||
]
|
||||
|
||||
const RECEIPT_META_FIELD_DEFINITIONS = [
|
||||
{
|
||||
id: 'document_type_label',
|
||||
label: '票据类型',
|
||||
placeholder: '待识别',
|
||||
keys: ['document_type_label'],
|
||||
labels: ['票据类型', '识别类型']
|
||||
},
|
||||
{
|
||||
id: 'scene_label',
|
||||
label: '费用场景',
|
||||
placeholder: '待识别',
|
||||
keys: ['scene_label'],
|
||||
labels: ['费用场景', '场景']
|
||||
},
|
||||
{
|
||||
id: 'merchant_name',
|
||||
label: '商户',
|
||||
placeholder: '待识别',
|
||||
keys: ['merchant_name'],
|
||||
labels: ['商户', '销售方', '开票方']
|
||||
}
|
||||
]
|
||||
|
||||
export function createReceiptDetailFieldModel({ detailForm, isTrainTicket }) {
|
||||
const activeKeyFieldDefinitions = computed(() => (
|
||||
isTrainTicket.value ? TRAIN_KEY_FIELD_DEFINITIONS : DEFAULT_KEY_FIELD_DEFINITIONS
|
||||
))
|
||||
const keyReceiptFields = computed(() => (
|
||||
activeKeyFieldDefinitions.value.map((definition) => ({
|
||||
...definition,
|
||||
value: getReceiptFieldValue(definition)
|
||||
}))
|
||||
))
|
||||
const keyReceiptFieldTokens = computed(() => {
|
||||
const tokens = new Set()
|
||||
activeKeyFieldDefinitions.value.forEach((definition) => {
|
||||
for (const token of [definition.id, ...(definition.keys || []), ...(definition.labels || [])]) {
|
||||
const normalized = normalizeReceiptFieldToken(token)
|
||||
if (normalized) tokens.add(normalized)
|
||||
}
|
||||
})
|
||||
return tokens
|
||||
})
|
||||
const editableOtherFields = computed(() => (
|
||||
detailForm.fields.filter((field) => {
|
||||
const key = normalizeReceiptFieldToken(field?.key)
|
||||
const label = normalizeReceiptFieldToken(field?.label)
|
||||
return !keyReceiptFieldTokens.value.has(key) && !keyReceiptFieldTokens.value.has(label)
|
||||
})
|
||||
))
|
||||
|
||||
function findReceiptFieldForDefinition(definition) {
|
||||
const keys = (definition.keys || []).map(normalizeReceiptFieldToken).filter(Boolean)
|
||||
const labels = (definition.labels || []).map(normalizeReceiptFieldToken).filter(Boolean)
|
||||
|
||||
return detailForm.fields.find((field) => keys.includes(normalizeReceiptFieldToken(field?.key)))
|
||||
|| detailForm.fields.find((field) => labels.includes(normalizeReceiptFieldToken(field?.label)))
|
||||
|| null
|
||||
}
|
||||
|
||||
function getReceiptFieldFallback(definition) {
|
||||
if (definition.id === 'invoice_date') return detailForm.document_date
|
||||
if (definition.id === 'fare' || definition.id === 'amount') return detailForm.amount
|
||||
if (definition.id === 'merchant_name') return detailForm.merchant_name
|
||||
if (definition.id === 'document_type_label') return detailForm.document_type_label
|
||||
if (definition.id === 'scene_label') return detailForm.scene_label
|
||||
return ''
|
||||
}
|
||||
|
||||
function getReceiptFieldValue(definition) {
|
||||
const field = findReceiptFieldForDefinition(definition)
|
||||
return String(field?.value || getReceiptFieldFallback(definition) || '')
|
||||
}
|
||||
|
||||
function ensureReceiptField(definition) {
|
||||
const field = findReceiptFieldForDefinition(definition)
|
||||
if (field) {
|
||||
field.key = field.key || definition.keys?.[0] || definition.id
|
||||
field.label = field.label || definition.label
|
||||
return field
|
||||
}
|
||||
|
||||
const created = {
|
||||
key: definition.keys?.[0] || definition.id,
|
||||
label: definition.label,
|
||||
value: getReceiptFieldFallback(definition)
|
||||
}
|
||||
detailForm.fields.push(created)
|
||||
return created
|
||||
}
|
||||
|
||||
function ensureEditableReceiptFields() {
|
||||
for (const definition of [...activeKeyFieldDefinitions.value, ...RECEIPT_META_FIELD_DEFINITIONS]) {
|
||||
const field = ensureReceiptField(definition)
|
||||
const fallback = getReceiptFieldFallback(definition)
|
||||
if (!String(field.value || '').trim() && fallback) {
|
||||
field.value = fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateReceiptField(definition, value) {
|
||||
const field = ensureReceiptField(definition)
|
||||
field.value = value
|
||||
syncEditableFieldsToTopLevel()
|
||||
}
|
||||
|
||||
function readFieldValue(definition) {
|
||||
return String(findReceiptFieldForDefinition(definition)?.value || '').trim()
|
||||
}
|
||||
|
||||
function syncEditableFieldsToTopLevel() {
|
||||
const invoiceDate = readFieldValue(TRAIN_KEY_FIELD_DEFINITIONS[1]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[1])
|
||||
const amount = readFieldValue(TRAIN_KEY_FIELD_DEFINITIONS[2]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[2])
|
||||
const merchant = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[2]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[3])
|
||||
const documentTypeLabel = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[0])
|
||||
const sceneLabel = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[1])
|
||||
|
||||
if (invoiceDate) detailForm.document_date = invoiceDate
|
||||
if (amount) detailForm.amount = amount
|
||||
if (merchant) detailForm.merchant_name = merchant
|
||||
if (documentTypeLabel) detailForm.document_type_label = documentTypeLabel
|
||||
if (sceneLabel) detailForm.scene_label = sceneLabel
|
||||
}
|
||||
|
||||
function buildDetailPayload() {
|
||||
syncEditableFieldsToTopLevel()
|
||||
return {
|
||||
document_type: detailForm.document_type,
|
||||
document_type_label: detailForm.document_type_label,
|
||||
scene_code: detailForm.scene_code,
|
||||
scene_label: detailForm.scene_label,
|
||||
summary: detailForm.summary,
|
||||
amount: detailForm.amount,
|
||||
document_date: detailForm.document_date,
|
||||
merchant_name: detailForm.merchant_name,
|
||||
fields: detailForm.fields
|
||||
.map((field) => ({
|
||||
key: String(field?.key || '').trim(),
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.key || field.label || field.value)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildDetailPayload,
|
||||
editableOtherFields,
|
||||
ensureEditableReceiptFields,
|
||||
keyReceiptFields,
|
||||
syncEditableFieldsToTopLevel,
|
||||
updateReceiptField
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReceiptFieldToken(value) {
|
||||
return String(value || '').trim().toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
@@ -108,6 +109,8 @@ export const SOURCE_LABELS = {
|
||||
requests: '来自报销列表'
|
||||
}
|
||||
|
||||
export { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
|
||||
|
||||
export const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
@@ -157,6 +160,12 @@ export const FLOW_STEP_FALLBACKS = {
|
||||
runningText: '正在把已确认信息保存为草稿...',
|
||||
completedText: '草稿已保存'
|
||||
},
|
||||
'application-submit-success': {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
runningText: '正在提交费用申请...',
|
||||
completedText: '申请单提交成功'
|
||||
},
|
||||
'attachment-association': {
|
||||
title: '票据关联草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
@@ -286,7 +295,7 @@ export function nowTime() {
|
||||
|
||||
export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
messageSeed += 1
|
||||
return {
|
||||
const message = {
|
||||
id: `msg-${messageSeed}`,
|
||||
role,
|
||||
text,
|
||||
@@ -308,8 +317,11 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
pendingAttachmentAssociation: null,
|
||||
applicationPreview: null,
|
||||
budgetReport: null,
|
||||
operationFeedback: null,
|
||||
...extras
|
||||
}
|
||||
message.meta = filterVisibleMessageMeta(message.meta)
|
||||
return message
|
||||
}
|
||||
|
||||
export function buildExpenseIntentConfirmationMessage(rawText) {
|
||||
@@ -471,18 +483,6 @@ export function resolveStatusTone(status) {
|
||||
export function buildMessageMeta(payload, fileNames = []) {
|
||||
const items = []
|
||||
|
||||
if (payload?.selected_agent) {
|
||||
items.push(`Agent: ${payload.selected_agent}`)
|
||||
}
|
||||
|
||||
if (payload?.permission_level) {
|
||||
items.push(`权限: ${payload.permission_level}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.tool_count) {
|
||||
items.push(`工具: ${payload.trace_summary.tool_count}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.degraded) {
|
||||
items.push('已降级')
|
||||
}
|
||||
@@ -491,15 +491,11 @@ export function buildMessageMeta(payload, fileNames = []) {
|
||||
items.push('待确认')
|
||||
}
|
||||
|
||||
if (payload?.run_id) {
|
||||
items.push(`Run: ${payload.run_id}`)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
items.push(`附件: ${fileNames.length}`)
|
||||
}
|
||||
|
||||
return items
|
||||
return filterVisibleMessageMeta(items)
|
||||
}
|
||||
|
||||
export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
|
||||
@@ -515,7 +511,7 @@ export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
|
||||
if (attachmentNames.length) {
|
||||
items.push(`附件: ${attachmentNames.length}`)
|
||||
}
|
||||
return items
|
||||
return filterVisibleMessageMeta(items)
|
||||
}
|
||||
|
||||
export function buildWelcomeUserContext(user = {}) {
|
||||
@@ -870,7 +866,7 @@ export function serializeSessionMessages(messages) {
|
||||
text: message.text,
|
||||
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
|
||||
time: message.time,
|
||||
meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [],
|
||||
meta: filterVisibleMessageMeta(message.meta),
|
||||
metaTone: message.metaTone || '',
|
||||
citations: Array.isArray(message.citations) ? message.citations : [],
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
@@ -883,10 +879,11 @@ export function serializeSessionMessages(messages) {
|
||||
draftPayload: message.draftPayload || null,
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
budgetReport: message.budgetReport || null,
|
||||
assistantName: message.assistantName || '',
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
budgetReport: message.budgetReport || null,
|
||||
operationFeedback: message.operationFeedback || null,
|
||||
assistantName: message.assistantName || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
}))
|
||||
@@ -908,6 +905,7 @@ export function hasMeaningfulSessionMessages(messages) {
|
||||
|| message.draftPayload
|
||||
|| message.applicationPreview
|
||||
|| message.budgetReport
|
||||
|| message.operationFeedback
|
||||
|| message.pendingAttachmentAssociation
|
||||
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
|
||||
)
|
||||
|
||||
@@ -164,6 +164,7 @@ export function buildFallbackProgressSteps(requestModel = {}) {
|
||||
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
|
||||
const paid = /已付款/.test(node)
|
||||
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
|
||||
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
|
||||
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
|
||||
@@ -197,13 +198,14 @@ export function buildFallbackProgressSteps(requestModel = {}) {
|
||||
}
|
||||
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication },
|
||||
{ index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
|
||||
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
|
||||
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
|
||||
{ index: 8, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,48 @@ import {
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
getTodayDateValue
|
||||
} from '../../utils/workbenchComposerDate.js'
|
||||
|
||||
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
|
||||
const applicationPreviewEditor = ref({
|
||||
function parseEditorDateValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
const matches = [...text.matchAll(/20\d{2}-\d{1,2}-\d{1,2}/g)].map((item) => item[0])
|
||||
const startDate = matches[0] || getTodayDateValue()
|
||||
const endDate = matches[1] || startDate
|
||||
return {
|
||||
dateMode: matches.length > 1 && startDate !== endDate ? 'range' : 'single',
|
||||
singleDate: startDate,
|
||||
rangeStartDate: startDate,
|
||||
rangeEndDate: endDate
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmptyEditor() {
|
||||
return {
|
||||
messageId: '',
|
||||
fieldKey: '',
|
||||
draftValue: ''
|
||||
})
|
||||
draftValue: '',
|
||||
dateMode: 'single',
|
||||
singleDate: getTodayDateValue(),
|
||||
rangeStartDate: getTodayDateValue(),
|
||||
rangeEndDate: getTodayDateValue()
|
||||
}
|
||||
}
|
||||
|
||||
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
|
||||
const applicationPreviewEditor = ref(buildEmptyEditor())
|
||||
|
||||
function resolveApplicationPreviewRows(message) {
|
||||
return buildApplicationPreviewRows(message?.applicationPreview || {})
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorControl(fieldKey) {
|
||||
return fieldKey === 'transportMode' ? 'select' : 'text'
|
||||
if (fieldKey === 'transportMode') return 'select'
|
||||
if (fieldKey === 'time') return 'date'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorOptions(fieldKey) {
|
||||
@@ -39,21 +67,47 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
.find((row) => row.key === fieldKey)
|
||||
if (targetRow && targetRow.editable === false) return
|
||||
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
|
||||
const dateState = fieldKey === 'time' ? parseEditorDateValue(normalizedValue) : {}
|
||||
applicationPreviewEditor.value = {
|
||||
messageId: String(message.id || ''),
|
||||
fieldKey,
|
||||
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||
? ''
|
||||
: normalizedValue
|
||||
: normalizedValue,
|
||||
...dateState
|
||||
}
|
||||
}
|
||||
|
||||
function cancelApplicationPreviewEditor() {
|
||||
applicationPreviewEditor.value = {
|
||||
messageId: '',
|
||||
fieldKey: '',
|
||||
draftValue: ''
|
||||
}
|
||||
applicationPreviewEditor.value = buildEmptyEditor()
|
||||
}
|
||||
|
||||
function isApplicationPreviewDateEditorOpen(message) {
|
||||
return isApplicationPreviewEditing(message, 'time')
|
||||
}
|
||||
|
||||
function setApplicationPreviewDateMode(mode) {
|
||||
applicationPreviewEditor.value.dateMode = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function canApplyApplicationPreviewDateSelection() {
|
||||
const editor = applicationPreviewEditor.value
|
||||
return canApplyWorkbenchDateSelection({
|
||||
mode: editor.dateMode,
|
||||
singleDate: editor.singleDate,
|
||||
rangeStartDate: editor.rangeStartDate,
|
||||
rangeEndDate: editor.rangeEndDate
|
||||
})
|
||||
}
|
||||
|
||||
function buildApplicationPreviewDateDraftValue() {
|
||||
const editor = applicationPreviewEditor.value
|
||||
return buildWorkbenchDateLabel({
|
||||
mode: editor.dateMode,
|
||||
singleDate: editor.singleDate,
|
||||
rangeStartDate: editor.rangeStartDate,
|
||||
rangeEndDate: editor.rangeEndDate
|
||||
})
|
||||
}
|
||||
|
||||
function commitApplicationPreviewEditor(message) {
|
||||
@@ -63,7 +117,13 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
return false
|
||||
}
|
||||
|
||||
const nextValue = String(editor.draftValue || '').trim()
|
||||
const nextValue = editor.fieldKey === 'time'
|
||||
? buildApplicationPreviewDateDraftValue()
|
||||
: String(editor.draftValue || '').trim()
|
||||
if (editor.fieldKey === 'time' && !nextValue) {
|
||||
toast?.('请先选择有效日期。')
|
||||
return false
|
||||
}
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
...message.applicationPreview,
|
||||
fields: {
|
||||
@@ -79,6 +139,14 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
return true
|
||||
}
|
||||
|
||||
function commitApplicationPreviewDateEditor(message) {
|
||||
if (!canApplyApplicationPreviewDateSelection()) {
|
||||
toast?.('请确认结束日期不早于开始日期。')
|
||||
return false
|
||||
}
|
||||
return commitApplicationPreviewEditor(message)
|
||||
}
|
||||
|
||||
function handleApplicationPreviewEditorKeydown(event, message) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
@@ -97,9 +165,13 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
commitApplicationPreviewDateEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
setApplicationPreviewDateMode,
|
||||
canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
createRiskRuleRevision,
|
||||
deleteAgentAsset,
|
||||
fetchAgentAssetDetail,
|
||||
publishRiskRuleAsset,
|
||||
returnRiskRuleAsset,
|
||||
setRiskRuleAssetEnabled
|
||||
setRiskRuleAssetEnabled,
|
||||
updateRiskRuleDraft
|
||||
} from '../../services/agentAssets.js'
|
||||
import { normalizeText } from './auditViewModel.js'
|
||||
|
||||
const DEFAULT_EXPENSE_CATEGORY = 'travel'
|
||||
|
||||
export function useAuditRiskRuleActions({
|
||||
selectedSkill,
|
||||
detailBusy,
|
||||
@@ -18,6 +22,8 @@ export function useAuditRiskRuleActions({
|
||||
canReturnRiskRule,
|
||||
canPublishRiskRule,
|
||||
canToggleRiskRuleEnabled,
|
||||
canEditRiskRuleDraft,
|
||||
canCreateRiskRuleRevision,
|
||||
riskRuleTestPassed,
|
||||
refreshCurrentAssets,
|
||||
loadSelectedAssetDetail,
|
||||
@@ -31,6 +37,9 @@ export function useAuditRiskRuleActions({
|
||||
const riskRuleReturnOpen = ref(false)
|
||||
const riskRulePublishOpen = ref(false)
|
||||
const riskRuleReturnNote = ref('')
|
||||
const riskRuleEditOpen = ref(false)
|
||||
const riskRuleEditMode = ref('draft')
|
||||
const riskRuleEditForm = ref(createRiskRuleEditForm())
|
||||
|
||||
function resetRiskRuleActionDialogs() {
|
||||
riskRuleTestOpen.value = false
|
||||
@@ -38,6 +47,9 @@ export function useAuditRiskRuleActions({
|
||||
riskRuleReturnOpen.value = false
|
||||
riskRulePublishOpen.value = false
|
||||
riskRuleReturnNote.value = ''
|
||||
riskRuleEditOpen.value = false
|
||||
riskRuleEditMode.value = 'draft'
|
||||
riskRuleEditForm.value = createRiskRuleEditForm()
|
||||
}
|
||||
|
||||
function openRiskRuleTestDialog() {
|
||||
@@ -68,6 +80,68 @@ export function useAuditRiskRuleActions({
|
||||
}
|
||||
}
|
||||
|
||||
function openRiskRuleEditDialog(mode = 'draft') {
|
||||
const normalizedMode = mode === 'revision' ? 'revision' : 'draft'
|
||||
if (normalizedMode === 'revision' && !canCreateRiskRuleRevision.value) {
|
||||
return
|
||||
}
|
||||
if (normalizedMode === 'draft' && !canEditRiskRuleDraft.value) {
|
||||
return
|
||||
}
|
||||
riskRuleEditMode.value = normalizedMode
|
||||
riskRuleEditForm.value = createRiskRuleEditForm(selectedSkill.value, normalizedMode)
|
||||
riskRuleEditOpen.value = true
|
||||
}
|
||||
|
||||
function closeRiskRuleEditDialog() {
|
||||
if (actionState.value === 'save-risk-rule-edit') {
|
||||
return
|
||||
}
|
||||
riskRuleEditOpen.value = false
|
||||
}
|
||||
|
||||
async function submitRiskRuleEdit() {
|
||||
const isRevision = riskRuleEditMode.value === 'revision'
|
||||
if (!selectedSkill.value || detailBusy.value) {
|
||||
return
|
||||
}
|
||||
if (isRevision && !canCreateRiskRuleRevision.value) {
|
||||
return
|
||||
}
|
||||
if (!isRevision && !canEditRiskRuleDraft.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = normalizeRiskRuleEditPayload(riskRuleEditForm.value, isRevision)
|
||||
if (payload.rule_title.length < 2) {
|
||||
toast('请输入至少 2 个字的规则标题。')
|
||||
return
|
||||
}
|
||||
if (payload.natural_language.length < 8) {
|
||||
toast('请至少输入 8 个字的风险规则描述。')
|
||||
return
|
||||
}
|
||||
if (isRevision && !payload.change_reason) {
|
||||
toast('请填写修订原因。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'save-risk-rule-edit'
|
||||
try {
|
||||
const detail = isRevision
|
||||
? await createRiskRuleRevision(selectedSkill.value.id, payload, { actor: resolveActor() })
|
||||
: await updateRiskRuleDraft(selectedSkill.value.id, payload, { actor: resolveActor() })
|
||||
riskRuleEditOpen.value = false
|
||||
mergeSelectedRuleLifecycle(detail)
|
||||
await refreshCurrentAssets()
|
||||
toast(isRevision ? '已创建风险规则修订草稿。' : '风险规则草稿已更新。')
|
||||
} catch (error) {
|
||||
toast(error?.message || (isRevision ? '创建修订版本失败,请稍后重试。' : '编辑规则草稿失败,请稍后重试。'))
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteRiskRuleDialog() {
|
||||
if (!canDeleteRiskRule.value) {
|
||||
return
|
||||
@@ -208,10 +282,16 @@ export function useAuditRiskRuleActions({
|
||||
riskRuleReturnOpen,
|
||||
riskRulePublishOpen,
|
||||
riskRuleReturnNote,
|
||||
riskRuleEditOpen,
|
||||
riskRuleEditMode,
|
||||
riskRuleEditForm,
|
||||
resetRiskRuleActionDialogs,
|
||||
openRiskRuleTestDialog,
|
||||
closeRiskRuleTestDialog,
|
||||
handleRiskRuleReportSaved,
|
||||
openRiskRuleEditDialog,
|
||||
closeRiskRuleEditDialog,
|
||||
submitRiskRuleEdit,
|
||||
openDeleteRiskRuleDialog,
|
||||
closeDeleteRiskRuleDialog,
|
||||
deleteSelectedRiskRule,
|
||||
@@ -224,3 +304,27 @@ export function useAuditRiskRuleActions({
|
||||
toggleSelectedRiskRuleEnabled
|
||||
}
|
||||
}
|
||||
|
||||
function createRiskRuleEditForm(rule = null, mode = 'draft') {
|
||||
const config = rule?.configJson || {}
|
||||
return {
|
||||
rule_title: normalizeText(rule?.name),
|
||||
expense_category: normalizeText(config.expense_category) || DEFAULT_EXPENSE_CATEGORY,
|
||||
requires_attachment: Boolean(rule?.riskRuleRequiresAttachment || config.requires_attachment),
|
||||
natural_language: normalizeText(rule?.summary || rule?.riskRuleSubtitle),
|
||||
change_reason: mode === 'revision' ? '' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRiskRuleEditPayload(form, includeReason) {
|
||||
const payload = {
|
||||
rule_title: normalizeText(form?.rule_title),
|
||||
expense_category: normalizeText(form?.expense_category) || DEFAULT_EXPENSE_CATEGORY,
|
||||
requires_attachment: Boolean(form?.requires_attachment),
|
||||
natural_language: normalizeText(form?.natural_language)
|
||||
}
|
||||
if (includeReason) {
|
||||
payload.change_reason = normalizeText(form?.change_reason)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useTravelReimbursementAttachments({
|
||||
reviewActionBusy,
|
||||
toast,
|
||||
fileInputRef,
|
||||
createExpenseClaimItem,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
@@ -149,7 +150,7 @@ export function useTravelReimbursementAttachments({
|
||||
async function syncComposerFilesToDraft(claimId, files) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) {
|
||||
return
|
||||
return { uploadedCount: 0, skippedCount: Array.isArray(files) ? files.length : 0 }
|
||||
}
|
||||
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
@@ -157,16 +158,30 @@ export function useTravelReimbursementAttachments({
|
||||
const exactMatchBuckets = new Map()
|
||||
const normalizedMatchBuckets = new Map()
|
||||
const placeholderQueue = []
|
||||
const emptyAttachmentQueue = []
|
||||
const usedItemIds = new Set()
|
||||
let uploadedCount = 0
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
if (!itemId) continue
|
||||
if (invoiceId && !invoiceId.includes('/')) {
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
const isSystemGenerated = Boolean(
|
||||
item?.isSystemGenerated ||
|
||||
item?.is_system_generated ||
|
||||
itemType === 'travel_allowance'
|
||||
)
|
||||
if (!invoiceId && !isSystemGenerated) {
|
||||
emptyAttachmentQueue.push(item)
|
||||
continue
|
||||
}
|
||||
if (!invoiceId || invoiceId.includes('/')) {
|
||||
continue
|
||||
}
|
||||
if (invoiceId) {
|
||||
placeholderQueue.push(item)
|
||||
}
|
||||
if (!invoiceId) continue
|
||||
const bucket = exactMatchBuckets.get(invoiceId) || []
|
||||
bucket.push(item)
|
||||
exactMatchBuckets.set(invoiceId, bucket)
|
||||
@@ -185,17 +200,41 @@ export function useTravelReimbursementAttachments({
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
|
||||
const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch
|
||||
const targetItemId = String(targetItem?.id || '').trim()
|
||||
const emptyFallbackMatch = emptyAttachmentQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
let targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch || emptyFallbackMatch
|
||||
let targetItemId = String(targetItem?.id || '').trim()
|
||||
if (!targetItemId && typeof createExpenseClaimItem === 'function') {
|
||||
const updatedClaim = await createExpenseClaimItem(normalizedClaimId, {})
|
||||
const createdItems = Array.isArray(updatedClaim?.items) ? updatedClaim.items : []
|
||||
targetItem = createdItems.find((item) => {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
return (
|
||||
itemId &&
|
||||
!usedItemIds.has(itemId) &&
|
||||
!invoiceId &&
|
||||
itemType !== 'travel_allowance' &&
|
||||
!item?.isSystemGenerated &&
|
||||
!item?.is_system_generated
|
||||
)
|
||||
}) || null
|
||||
targetItemId = String(targetItem?.id || '').trim()
|
||||
}
|
||||
if (!targetItemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
usedItemIds.add(targetItemId)
|
||||
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
|
||||
uploadedCount += 1
|
||||
}
|
||||
|
||||
await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true })
|
||||
return {
|
||||
uploadedCount,
|
||||
skippedCount: Math.max(0, files.length - uploadedCount)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileUpload(mode = 'composer') {
|
||||
|
||||
@@ -44,6 +44,9 @@ const CHINESE_DAY_NUMBERS = {
|
||||
十: 10
|
||||
}
|
||||
|
||||
const COMPOSER_DATE_RANGE_PREFIX_RE = /^20\d{2}-\d{1,2}-\d{1,2}(?:\s*至\s*20\d{2}-\d{1,2}-\d{1,2})?[,,。\s]*/u
|
||||
const COMPOSER_LABELED_TIME_PREFIX_RE = /^(?:业务)?发生时间[::]\s*[^,,。\n]+(?:至\s*[^,,。\n]+)?[,,。\s]*/u
|
||||
|
||||
function normalizeComposerText(value) {
|
||||
return String(value || '').trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
@@ -85,7 +88,8 @@ function calculateBusinessDays(businessTimeContext) {
|
||||
|
||||
function stripBusinessTimePrefix(text) {
|
||||
return normalizeComposerText(text)
|
||||
.replace(/^(?:业务)?发生时间[::]\s*[^,,。\n]+(?:至\s*[^,,。\n]+)?[,,。\s]*/u, '')
|
||||
.replace(COMPOSER_LABELED_TIME_PREFIX_RE, '')
|
||||
.replace(COMPOSER_DATE_RANGE_PREFIX_RE, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -183,7 +187,8 @@ export function useTravelReimbursementComposerTools({
|
||||
buildReviewSlotMap,
|
||||
isValidIsoDateString,
|
||||
buildLocallySyncedReviewPayload,
|
||||
formatDateInputValue
|
||||
formatDateInputValue,
|
||||
onComposerDateSelection
|
||||
}) {
|
||||
const composerDatePickerOpen = ref(false)
|
||||
const composerDateMode = ref('single')
|
||||
@@ -217,23 +222,19 @@ export function useTravelReimbursementComposerTools({
|
||||
)
|
||||
function buildComposerBusinessTimeLabel() {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return `发生时间:${composerSingleDate.value}`
|
||||
return composerSingleDate.value
|
||||
}
|
||||
if (composerRangeStartDate.value === composerRangeEndDate.value) {
|
||||
return `发生时间:${composerRangeStartDate.value}`
|
||||
return composerRangeStartDate.value
|
||||
}
|
||||
return `发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
return `${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
function hasComposerBusinessTimeSelection() {
|
||||
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContextFromSelection() {
|
||||
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
|
||||
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
|
||||
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
|
||||
@@ -255,6 +256,28 @@ export function useTravelReimbursementComposerTools({
|
||||
}
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildComposerBusinessTimeContextFromSelection()
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeSelection() {
|
||||
const context = buildComposerBusinessTimeContextFromSelection()
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label: buildComposerBusinessTimeLabel(),
|
||||
context,
|
||||
mode: context.mode,
|
||||
startDate: context.start_date,
|
||||
endDate: context.end_date
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
|
||||
if (!businessTimeContext) {
|
||||
return extraContext
|
||||
@@ -345,9 +368,60 @@ export function useTravelReimbursementComposerTools({
|
||||
composerDateMode.value = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange() {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
async function commitComposerDateSelection({ closePicker = true, focusComposer = true } = {}) {
|
||||
if (!composerCanApplyDateSelection.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selection = buildComposerBusinessTimeSelection()
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
|
||||
const handled = onComposerDateSelection?.(selection) === true
|
||||
if (handled) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
composerBusinessTimeTags.value = []
|
||||
} else {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: selection.label
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(selection.context)
|
||||
}
|
||||
|
||||
if (closePicker) {
|
||||
composerDatePickerOpen.value = false
|
||||
}
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
if (focusComposer) {
|
||||
composerTextareaRef.value?.focus()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange(part = 'single') {
|
||||
if (composerDateMode.value !== 'range' || part === 'single') {
|
||||
void commitComposerDateSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (part === 'range-start') {
|
||||
if (!composerRangeEndDate.value || composerRangeEndDate.value < composerRangeStartDate.value) {
|
||||
composerRangeEndDate.value = composerRangeStartDate.value
|
||||
}
|
||||
if (!onComposerDateSelection) {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContextFromSelection())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
void commitComposerDateSelection()
|
||||
}
|
||||
|
||||
function removeComposerBusinessTimeTag(tagId) {
|
||||
@@ -376,22 +450,7 @@ export function useTravelReimbursementComposerTools({
|
||||
}
|
||||
|
||||
async function applyComposerDateSelection() {
|
||||
if (!composerCanApplyDateSelection.value) {
|
||||
return
|
||||
}
|
||||
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
await commitComposerDateSelection()
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialDays() {
|
||||
@@ -547,6 +606,7 @@ export function useTravelReimbursementComposerTools({
|
||||
travelCalculatorCanSubmit,
|
||||
buildComposerBusinessTimeLabel,
|
||||
hasComposerBusinessTimeSelection,
|
||||
buildComposerBusinessTimeSelection,
|
||||
buildComposerBusinessTimeContext,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
syncComposerBusinessTimeToReviewCard,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
function formatFlowDuration(ms) {
|
||||
if (ms === null || ms === undefined || ms === '') {
|
||||
return '--'
|
||||
}
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return '--'
|
||||
}
|
||||
if (numericValue < 1000) {
|
||||
@@ -15,18 +18,122 @@ function formatFlowDuration(ms) {
|
||||
}
|
||||
|
||||
function parseFlowTimestamp(value) {
|
||||
const timestamp = new Date(value || '').getTime()
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 0 && value < 10000000000 ? Math.round(value * 1000) : Math.round(value)
|
||||
}
|
||||
const timestamp = new Date(value).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
const FLOW_DURATION_MS_FIELDS = [
|
||||
'duration_ms',
|
||||
'elapsed_ms',
|
||||
'latency_ms',
|
||||
'total_duration_ms',
|
||||
'execution_time_ms'
|
||||
]
|
||||
const FLOW_DURATION_SECOND_FIELDS = [
|
||||
'duration_seconds',
|
||||
'elapsed_seconds',
|
||||
'latency_seconds',
|
||||
'execution_time_seconds'
|
||||
]
|
||||
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
|
||||
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
|
||||
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
|
||||
|
||||
function normalizeDurationValue(value, unit = 'ms') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
let numericValue = Number(value)
|
||||
let normalizedUnit = unit
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim()
|
||||
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|毫秒|s|秒)?$/i)
|
||||
if (match) {
|
||||
numericValue = Number(match[1])
|
||||
if (match[2]) {
|
||||
normalizedUnit = ['s', '秒'].includes(match[2].toLowerCase()) ? 'seconds' : 'ms'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return null
|
||||
}
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return Math.round(numericValue * 1000)
|
||||
}
|
||||
if (normalizedUnit === 'auto') {
|
||||
return Math.round(numericValue <= 300 ? numericValue * 1000 : numericValue)
|
||||
}
|
||||
return Math.round(numericValue)
|
||||
}
|
||||
|
||||
function readFirstDurationField(source, fields, unit) {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return null
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, field)) {
|
||||
continue
|
||||
}
|
||||
const durationMs = normalizeDurationValue(source[field], unit)
|
||||
if (durationMs) {
|
||||
return durationMs
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDurationFromFields(source) {
|
||||
return (
|
||||
readFirstDurationField(source, FLOW_DURATION_MS_FIELDS, 'ms')
|
||||
|| readFirstDurationField(source, FLOW_DURATION_SECOND_FIELDS, 'seconds')
|
||||
|| readFirstDurationField(source, FLOW_DURATION_AUTO_FIELDS, 'auto')
|
||||
)
|
||||
}
|
||||
|
||||
function readFirstTimestampField(source, fields) {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return 0
|
||||
}
|
||||
for (const field of fields) {
|
||||
const timestamp = parseFlowTimestamp(source[field])
|
||||
if (timestamp) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function resolveStartedTimestamp(source) {
|
||||
return readFirstTimestampField(source, FLOW_STARTED_AT_FIELDS)
|
||||
}
|
||||
|
||||
function resolveFinishedTimestamp(source) {
|
||||
return readFirstTimestampField(source, FLOW_FINISHED_AT_FIELDS)
|
||||
}
|
||||
|
||||
function resolveTimeRangeDurationMs(source) {
|
||||
const startedAt = resolveStartedTimestamp(source)
|
||||
const finishedAt = resolveFinishedTimestamp(source)
|
||||
return finishedAt > startedAt ? finishedAt - startedAt : null
|
||||
}
|
||||
|
||||
function resolveSemanticPhaseDurations(run) {
|
||||
const runStart = parseFlowTimestamp(run?.started_at)
|
||||
const runStart = resolveStartedTimestamp(run)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const firstToolStartedAt = toolCalls
|
||||
.map((item) => parseFlowTimestamp(item?.created_at))
|
||||
.map((item) => resolveStartedTimestamp(item))
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => left - right)[0] || 0
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
|
||||
|
||||
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
|
||||
@@ -43,18 +150,24 @@ function resolveSemanticPhaseDurations(run) {
|
||||
}
|
||||
|
||||
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
const explicitDuration = Number(toolCall?.duration_ms)
|
||||
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const explicitDuration = resolveDurationFromFields(toolCall)
|
||||
|| resolveTimeRangeDurationMs(toolCall)
|
||||
|| resolveDurationFromFields(response)
|
||||
|| resolveTimeRangeDurationMs(response)
|
||||
if (explicitDuration) {
|
||||
return explicitDuration
|
||||
}
|
||||
|
||||
const startedAt = parseFlowTimestamp(toolCall?.created_at)
|
||||
const startedAt = resolveStartedTimestamp(toolCall)
|
||||
if (!startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at)
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const nextStartedAt = resolveStartedTimestamp(toolCalls[index + 1])
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
|
||||
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
@@ -64,6 +177,19 @@ function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
return finishedAt - startedAt
|
||||
}
|
||||
|
||||
function summarizeVisibleToolText(value) {
|
||||
const text = String(value || '')
|
||||
.replace(/\|[^\n]*\|/g, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) || ''
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
return text.length > 80 ? `${text.slice(0, 80)}...` : text
|
||||
}
|
||||
|
||||
export function useTravelReimbursementFlow({
|
||||
activeSessionType,
|
||||
reviewDrawerMode,
|
||||
@@ -238,7 +364,8 @@ export function useTravelReimbursementFlow({
|
||||
startedAt: normalizedPatch.startedAt || 0,
|
||||
finishedAt: normalizedPatch.finishedAt || 0,
|
||||
error: normalizedPatch.error || '',
|
||||
deferredCompletion: Boolean(normalizedPatch.deferredCompletion)
|
||||
deferredCompletion: Boolean(normalizedPatch.deferredCompletion),
|
||||
syntheticTiming: Boolean(normalizedPatch.syntheticTiming)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +403,8 @@ export function useTravelReimbursementFlow({
|
||||
startedAt,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
error: '',
|
||||
syntheticTiming: Boolean(normalizedPatch.syntheticTiming)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,16 +414,22 @@ export function useTravelReimbursementFlow({
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const explicitDuration = Number(durationMs)
|
||||
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
|
||||
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
|
||||
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : 0)
|
||||
const measuredDuration = hasExplicitDuration
|
||||
? explicitDuration
|
||||
: startedAt && !currentStep?.syntheticTiming
|
||||
? Math.max(0, now - startedAt)
|
||||
: null
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || definition?.completedText || '',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
|
||||
durationMs: measuredDuration,
|
||||
error: '',
|
||||
deferredCompletion: false
|
||||
deferredCompletion: false,
|
||||
syntheticTiming: false
|
||||
})
|
||||
if (
|
||||
flowSteps.value.length
|
||||
@@ -323,15 +457,21 @@ export function useTravelReimbursementFlow({
|
||||
}
|
||||
|
||||
function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const patchObject = patch && typeof patch === 'object' ? { ...patch } : {}
|
||||
const refreshCompleted = Boolean(patchObject.refreshCompleted)
|
||||
delete patchObject.refreshCompleted
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return
|
||||
}
|
||||
const normalizedDuration = Number(durationMs)
|
||||
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
if (refreshCompleted && hasMeasuredDuration) {
|
||||
completeFlowStep(key, detail, normalizedDuration, patchObject)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
|
||||
const revealOrder = flowSteps.value.length
|
||||
startFlowStep(key, { ...patch, deferredCompletion: true })
|
||||
startFlowStep(key, { ...patchObject, deferredCompletion: true, syntheticTiming: !hasMeasuredDuration })
|
||||
const completionTimer = window.setTimeout(() => {
|
||||
completeFlowStep(
|
||||
key,
|
||||
@@ -343,7 +483,7 @@ export function useTravelReimbursementFlow({
|
||||
flowSimulationTimers.push(completionTimer)
|
||||
return
|
||||
}
|
||||
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
|
||||
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patchObject)
|
||||
}
|
||||
|
||||
function failCurrentFlowStep(error) {
|
||||
@@ -527,7 +667,51 @@ export function useTravelReimbursementFlow({
|
||||
})
|
||||
}
|
||||
|
||||
function isApplicationSessionActive() {
|
||||
return String(activeSessionType?.value || '').trim() === 'application'
|
||||
}
|
||||
|
||||
function isSubmittedApplicationPayload(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
||||
? payload.draft_payload
|
||||
: null
|
||||
return Boolean(
|
||||
draftPayload
|
||||
&& String(draftPayload.draft_type || '').trim() === 'expense_application'
|
||||
&& String(draftPayload.status || '').trim() === 'submitted'
|
||||
)
|
||||
}
|
||||
|
||||
function buildApplicationSubmitSuccessDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: {}
|
||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||
const approvalStage = String(draftPayload.approval_stage || '').trim() || '直属领导审批'
|
||||
return claimNo
|
||||
? `申请单 ${claimNo} 已提交成功,当前节点:${approvalStage}`
|
||||
: `申请单提交成功,当前节点:${approvalStage}`
|
||||
}
|
||||
|
||||
function shouldHideToolCall(toolCall) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
return (
|
||||
toolName.includes('semantic_ontology')
|
||||
|| toolName.includes('ontology.')
|
||||
|| toolType.includes('semantic_ontology')
|
||||
|| toolType.includes('ontology')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveToolCallFlowMeta(toolCall, index) {
|
||||
if (shouldHideToolCall(toolCall)) {
|
||||
return null
|
||||
}
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
@@ -535,17 +719,31 @@ export function useTravelReimbursementFlow({
|
||||
: {}
|
||||
const responseMessage = String(response.message || '').trim()
|
||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||
if (
|
||||
isApplicationSessionActive()
|
||||
&& (
|
||||
String(response.status || '').trim() === 'submitted'
|
||||
|| String(response?.draft_payload?.status || '').trim() === 'submitted'
|
||||
)
|
||||
) {
|
||||
return { key: 'application-submit-success', title: '申请单提交成功', tool: 'ApplicationSubmit' }
|
||||
}
|
||||
if (toolType.includes('rule')) {
|
||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||
}
|
||||
if (toolType.includes('mcp')) {
|
||||
return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' }
|
||||
return toolName.includes('standard')
|
||||
? { key, title: '差旅补助标准查询', tool: 'TravelStandard' }
|
||||
: null
|
||||
}
|
||||
if (toolName.includes('knowledge')) {
|
||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||
}
|
||||
if (toolName.includes('application_review_preview')) {
|
||||
return { key: 'application-review-preview', title: '申请信息核对', tool: 'ApplicationReview' }
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return { key: 'expense-review-preview', title: '报销信息核对', tool: toolCall?.tool_name || 'user_agent.expense_review_preview' }
|
||||
return { key: 'expense-review-preview', title: '报销信息核对', tool: 'ExpenseReview' }
|
||||
}
|
||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||
if (
|
||||
@@ -564,39 +762,45 @@ export function useTravelReimbursementFlow({
|
||||
}
|
||||
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (toolType.includes('database')) {
|
||||
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
||||
}
|
||||
if (toolType.includes('llm') || toolName.includes('user_agent')) {
|
||||
return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' }
|
||||
}
|
||||
return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' }
|
||||
return null
|
||||
}
|
||||
|
||||
function summarizeFlowToolCall(toolCall) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
if (toolName.includes('application_review_preview')) {
|
||||
return '已整理申请核对信息'
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return '已整理报销核对信息'
|
||||
}
|
||||
if (String(response.status || '').trim() === 'submitted') {
|
||||
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
return isApplicationSessionActive()
|
||||
? '申请单提交成功'
|
||||
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批'
|
||||
return summarizeVisibleToolText(response.message) || 'AI预审发现待补充项,暂未提交审批'
|
||||
}
|
||||
return (
|
||||
String(response.message || response.summary || response.result_summary || '').trim()
|
||||
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
|
||||
|| String(toolCall?.tool_name || '').trim()
|
||||
|| '工具调用完成'
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFlowRunDetail(run) {
|
||||
const runStartedAt = resolveStartedTimestamp(run)
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
if (runStartedAt) {
|
||||
flowStartedAt.value = runStartedAt
|
||||
}
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
||||
clearFlowSimulationTimers()
|
||||
const semanticDurations = resolveSemanticPhaseDurations(run)
|
||||
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse, {
|
||||
@@ -605,17 +809,26 @@ export function useTravelReimbursementFlow({
|
||||
expenseTypeLabels: EXPENSE_TYPE_LABELS,
|
||||
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}),
|
||||
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||
semanticDurations.intentMs,
|
||||
{ refreshCompleted: true }
|
||||
)
|
||||
completePendingFlowStep(
|
||||
'extraction',
|
||||
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
||||
extractionStep?.startedAt ? null : semanticDurations.extractionMs
|
||||
semanticDurations.extractionMs,
|
||||
{ refreshCompleted: true }
|
||||
)
|
||||
}
|
||||
|
||||
const hasApplicationSubmitSuccess = flowSteps.value.some((step) => step.key === 'application-submit-success')
|
||||
toolCalls.forEach((toolCall, index) => {
|
||||
const meta = resolveToolCallFlowMeta(toolCall, index)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
if (hasApplicationSubmitSuccess && isApplicationSessionActive() && meta.key !== 'application-submit-success') {
|
||||
return
|
||||
}
|
||||
const failed = String(toolCall?.status || '').toLowerCase() === 'failed'
|
||||
if (failed) {
|
||||
failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta)
|
||||
@@ -625,7 +838,7 @@ export function useTravelReimbursementFlow({
|
||||
meta.key,
|
||||
summarizeFlowToolCall(toolCall),
|
||||
toolDurationMs,
|
||||
meta
|
||||
{ ...meta, refreshCompleted: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -634,6 +847,13 @@ export function useTravelReimbursementFlow({
|
||||
failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' })
|
||||
return
|
||||
}
|
||||
if (
|
||||
runFinishedAt
|
||||
&& flowSteps.value.length
|
||||
&& flowSteps.value.every((step) => [FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||
) {
|
||||
flowFinishedAt.value = runFinishedAt
|
||||
}
|
||||
}
|
||||
|
||||
function completeFlowResult(payload, run = null) {
|
||||
@@ -651,11 +871,20 @@ export function useTravelReimbursementFlow({
|
||||
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
|
||||
completeFlowStep(step.key, detail)
|
||||
})
|
||||
if (isSubmittedApplicationPayload(payload)) {
|
||||
completePendingFlowStep(
|
||||
'application-submit-success',
|
||||
buildApplicationSubmitSuccessDetail(payload),
|
||||
null,
|
||||
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
|
||||
)
|
||||
}
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
flowFinishedAt.value = flowSteps.value.some(
|
||||
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
|
||||
)
|
||||
? 0
|
||||
: Date.now()
|
||||
: runFinishedAt || Date.now()
|
||||
}
|
||||
|
||||
async function refreshFlowRunDetail() {
|
||||
|
||||
@@ -65,6 +65,10 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
function resolveDefaultSessionTypeFromEntry() {
|
||||
const initialSessionType = String(props.initialSessionType || '').trim()
|
||||
if (initialSessionType) {
|
||||
return initialSessionType
|
||||
}
|
||||
if (props.entrySource === 'budget') {
|
||||
return SESSION_TYPE_BUDGET
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
currentInsight,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
emitOperationCompleted,
|
||||
emitRequestUpdated,
|
||||
extractReviewAttachmentNames,
|
||||
failCurrentFlowStep,
|
||||
fetchExpenseClaims,
|
||||
@@ -101,6 +103,36 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
const pendingAttachmentAssociations = new Map()
|
||||
|
||||
function isSubmittedApplicationDraftPayload(draftPayload) {
|
||||
return (
|
||||
String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||||
&& String(draftPayload?.status || '').trim() === 'submitted'
|
||||
)
|
||||
}
|
||||
|
||||
function buildOperationFeedbackState(context) {
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
context,
|
||||
submitting: false,
|
||||
submitted: false,
|
||||
dismissed: false,
|
||||
rating: 0,
|
||||
reason: '',
|
||||
error: ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssistantResultText(payload, fallbackAnswer) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
if (isSubmittedApplicationDraftPayload(result.draft_payload)) {
|
||||
return ''
|
||||
}
|
||||
return result.answer || result.message || fallbackAnswer
|
||||
}
|
||||
|
||||
function createPendingAttachmentAssociationId() {
|
||||
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
@@ -411,6 +443,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const feedbackOperationType = String(options.feedbackOperationType || '').trim()
|
||||
const attachmentAssociationConfirmed = Boolean(
|
||||
options.associationConfirmed ||
|
||||
extraContext.attachment_association_confirmed ||
|
||||
@@ -966,7 +999,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||||
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
||||
: '智能体已完成处理。'
|
||||
const assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
|
||||
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
|
||||
? emitOperationCompleted?.(payload, {
|
||||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||||
})
|
||||
: null
|
||||
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
@@ -981,7 +1019,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
fileCount: files.length,
|
||||
rawText
|
||||
}),
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
|
||||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext)
|
||||
})
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
const nextInsight = buildAgentInsight(
|
||||
@@ -996,12 +1035,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
.then(() => {
|
||||
.then((syncResult) => {
|
||||
persistSessionState()
|
||||
if (detailScopedUpload && Number(syncResult?.uploadedCount || 0) > 0) {
|
||||
emitRequestUpdated?.({
|
||||
claimId: resolvedDraftClaimId,
|
||||
source: 'detail-smart-entry-attachment-sync'
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
|
||||
Reference in New Issue
Block a user