refactor: enforce 800 line source limits

This commit is contained in:
caoxiaozhu
2026-06-22 11:58:53 +08:00
parent 08a4fa3577
commit 6d33ba5742
150 changed files with 27413 additions and 23791 deletions

View File

@@ -0,0 +1,357 @@
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
createExpenseClaimItem,
extractExpenseClaimItems,
fetchExpenseClaimDetail,
fetchExpenseClaims,
uploadExpenseClaimItemAttachment
} from '../../services/reimbursements.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import {
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
const completed = status === 'completed'
const failed = status === 'failed'
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
return [
{
eventId: 'attachment-ocr',
title: '识别上传票据',
content: '提取票据里的日期、地点和行程信息。',
status: eventStatus
},
{
eventId: 'claim-lookup',
title: '查询可关联报销单',
content: '查找草稿、待补充和退回状态的可归集单据。',
status: eventStatus
},
{
eventId: 'claim-match',
title: '匹配票据与报销单',
content: '根据票据时间、城市和报销事由判断最可能的关联单据。',
status: eventStatus
}
]
}
function resolveAiAttachmentAssociationClaimNo(payload = {}) {
return String(payload?.claim_no || payload?.claimNo || '').trim()
}
function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
const completed = status === 'completed'
const failed = status === 'failed'
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
return [
{
eventId: 'attachment-confirm',
title: '确认自动归集',
content: '正在读取匹配单据并准备写入附件。',
status: eventStatus
},
{
eventId: 'attachment-upload',
title: '归集票据附件',
content: '把本次上传的票据写入报销单明细。',
status: eventStatus
}
]
}
export function useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
toast
}) {
async function collectAiModeReceiptContext(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const attachmentNames = safeFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const ocrSourceFileNames = ocrFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const baseContext = {
attachmentNames,
attachmentCount: attachmentNames.length,
ocrSourceFileNames,
ocrSummary: '',
ocrDocuments: []
}
if (!ocrFiles.length) {
return baseContext
}
try {
const collected = await collectReceiptFiles({
files: ocrFiles,
recognizeOcrFiles
})
return {
...baseContext,
ocrSummary: String(collected.ocrSummary || '').trim(),
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
}
} catch (error) {
console.warn('AI mode OCR request failed:', error)
return {
...baseContext,
ocrError: error?.message || 'OCR识别失败已继续使用附件名称。'
}
}
}
function findAiAttachmentAssociationRuntime(options = {}) {
const normalizedAssociationId = String(options.associationId || options.association_id || '').trim()
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
if (normalizedAssociationId) {
const runtime = aiAttachmentAssociationRuntime.get(normalizedAssociationId)
if (runtime) {
return { associationId: normalizedAssociationId, runtime }
}
}
if (normalizedClaimNo) {
for (const [runtimeId, runtime] of aiAttachmentAssociationRuntime.entries()) {
if (String(runtime?.claimNo || '').trim() === normalizedClaimNo && runtime?.files?.length) {
return { associationId: runtimeId, runtime }
}
}
}
return { associationId: '', runtime: null }
}
function updateAiAttachmentAssociationActionState(message = {}, associationId = '', state = {}, options = {}) {
const normalizedAssociationId = String(associationId || '').trim()
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
if (!message || !Array.isArray(message.suggestedActions) || (!normalizedAssociationId && !normalizedClaimNo)) {
return
}
message.suggestedActions = message.suggestedActions.map((action) => {
const actionAssociationId = String(action?.payload?.association_id || action?.payload?.associationId || '').trim()
const actionClaimNo = resolveAiAttachmentAssociationClaimNo(action?.payload || {})
const isSameAssociation = normalizedAssociationId && actionAssociationId === normalizedAssociationId
const isSameClaim = normalizedClaimNo && actionClaimNo === normalizedClaimNo
if (String(action?.action_type || '').trim() !== AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION || (!isSameAssociation && !isSameClaim)) {
return action
}
return {
...action,
...state
}
})
}
function buildAiAttachmentAssociationDetailActions(runtime = {}) {
const claimNo = String(runtime.claimNo || '').trim()
const claimId = String(runtime.claimId || '').trim()
if (!claimNo && !claimId) {
return []
}
return [{
label: '查看单据',
description: '打开已归集票据的报销单。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_id: claimId,
claim_no: claimNo,
document_type: 'expense'
}
}]
}
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
const runtimeResult = findAiAttachmentAssociationRuntime({
associationId: requestedAssociationId,
claimNo: payloadClaimNo
})
const associationId = runtimeResult.associationId
const runtime = runtimeResult.runtime
const actionClaimNo = payloadClaimNo || String(runtime?.claimNo || '').trim()
if (!associationId || !runtime?.files?.length) {
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
return
}
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '正在归集...',
disabled: true
}, { claimNo: actionClaimNo })
persistCurrentConversation()
sending.value = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
const syncResult = await syncExpenseClaimFilesToDraft({
claimId: runtime.claimId,
files: runtime.files,
fetchExpenseClaimDetail,
createExpenseClaimItem,
uploadExpenseClaimItemAttachment
})
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: runtime.claimNo,
fileNames: runtime.fileNames,
uploadedCount: syncResult.uploadedCount,
skippedCount: syncResult.skippedCount
})
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('completed')
},
suggestedActions: buildAiAttachmentAssociationDetailActions(runtime)
})
)
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '已自动关联',
disabled: true
}, { claimNo: actionClaimNo })
aiAttachmentAssociationRuntime.delete(associationId)
persistCurrentConversation()
} catch (error) {
const finalMessageText = error?.message || '自动归集失败,请稍后重试。'
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('failed')
}
})
)
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '重新自动关联',
disabled: false
}, { claimNo: actionClaimNo })
toast(finalMessageText)
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
}
async function requestAiAttachmentAssociationReply(prompt, entry = {}, files = []) {
let shouldAutoScrollOnFinish = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('running')
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
const claims = extractExpenseClaimItems(claimsPayload)
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
const associationRecord = match.best?.record || match.recommended?.record || null
const associationId = associationRecord?.claimId
? createAiAttachmentAssociationId()
: ''
if (associationId) {
aiAttachmentAssociationRuntime.set(associationId, {
files,
fileNames: files.map((file) => file?.name || '').filter(Boolean),
claimId: String(associationRecord.claimId || '').trim(),
claimNo: String(associationRecord.claimNo || '').trim(),
ocrPayload: collected.ocrPayload,
ocrSummary: collected.ocrSummary,
ocrDocuments: collected.ocrDocuments,
ocrFilePreviews: collected.ocrFilePreviews
})
}
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
match,
fileNames: files.map((file) => file?.name || ''),
ocrDocuments: collected.ocrDocuments
})
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
},
attachmentOcrDetails,
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
includeOcrDetails: Boolean(attachmentOcrDetails)
})
})
)
persistCurrentConversation()
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('failed')
}
})
)
toast(finalMessageText)
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
}
}
return {
collectAiModeReceiptContext,
confirmAiAttachmentAssociation,
requestAiAttachmentAssociationReply,
resolveAiAttachmentAssociationClaimNo
}
}