feat(web): AI 工作台文件预览/附件关联任务与草稿分支

- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览
- 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示
- 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿
- PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善
- DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配
- 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
This commit is contained in:
caoxiaozhu
2026-06-24 10:42:50 +08:00
parent 0264a4b5b4
commit ee730aa31c
73 changed files with 2528 additions and 379 deletions

View File

@@ -1,5 +1,4 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../useSystemState.js'
import { useToast } from '../useToast.js'
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
@@ -19,7 +18,6 @@ import {
} from '../../utils/aiDocumentDetailReference.js'
import {
AI_MODE_ACTION_ITEMS,
buildSelectedFileCards,
shouldRunAiAttachmentAutoAssociation
} from './workbenchAiComposerModel.js'
import {
@@ -32,6 +30,7 @@ import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicatio
import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
import { useWorkbenchAiFilePreview } from './useWorkbenchAiFilePreview.js'
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
@@ -75,7 +74,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
normalizeRuntimeMessage,
serializeRuntimeMessage
} = messageRuntime
const {
applicationPreviewEditor,
resolveApplicationPreviewEditorControl,
@@ -139,6 +137,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
fileInputRef,
focusAiModeInput,
isInputLocked: () => isAiModeInputLocked.value,
resolveInputLockedMessage: () => resolveAiModeInputLockMessage(),
selectedFiles,
toast
})
@@ -154,6 +153,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationId,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
@@ -167,14 +167,12 @@ export function usePersonalWorkbenchAiMode(props, emit) {
toast
})
watch(selectedFiles, (files) => {
attachmentFlow.primeAiModeReceiptContext(files)
const filePreview = useWorkbenchAiFilePreview({
attachmentFlow,
conversationStarted,
scrollInlineConversationToBottom,
selectedFiles
})
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
const {
hasInlineAttachmentOcrDetails,
hasInlineThinking,
@@ -319,9 +317,13 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const applicationPreviewEstimatePending = computed(() => (
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
const isAiModeReceiptRecognitionPending = computed(() => attachmentFlow.hasPendingAiModeReceiptRecognition(selectedFiles.value))
const hasAiModeReceiptRecognitionFailure = computed(() => attachmentFlow.hasFailedAiModeReceiptRecognition(selectedFiles.value))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value || isAiModeReceiptRecognitionPending.value)
const aiModeInputLockMessage = computed(() => resolveAiModeInputLockMessage())
const canSubmitAiModePrompt = computed(() => (
!isAiModeInputLocked.value && (
!isAiModeInputLocked.value &&
!hasAiModeReceiptRecognitionFailure.value && (
Boolean(assistantDraft.value.trim()) ||
selectedFiles.value.length > 0 ||
Boolean(workbenchDateTagLabel.value)
@@ -517,18 +519,47 @@ export function usePersonalWorkbenchAiMode(props, emit) {
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
}
function renderInlineConversationHtml(content) {
return renderAiConversationHtml(content)
}
function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) }
function buildInlinePromptText(rawPrompt, files = []) {
const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) {
return prompt
}
if (prompt) return prompt
return files.length ? '请帮我处理已上传的附件。' : ''
}
function resolveAiModeInputLockMessage() {
if (isAiModeReceiptRecognitionPending.value) {
return '附件识别中,请稍等...'
}
if (applicationPreviewEstimatePending.value) {
return '费用测算中,请稍等...'
}
return ''
}
function resolveAiModeSubmitBlockedMessage() {
if (applicationPreviewEstimatePending.value) {
return '请等待费用测算完成后再继续操作。'
}
if (isAiModeReceiptRecognitionPending.value) {
return '附件 OCR 识别中,请稍等,识别完成后再继续对话。'
}
if (hasAiModeReceiptRecognitionFailure.value) {
return '请先移除识别失败的附件或重新上传。'
}
return ''
}
function ensureAiModeCanStartConversation() {
const blockedMessage = resolveAiModeSubmitBlockedMessage()
if (!blockedMessage) {
return true
}
toast(blockedMessage)
focusAiModeInput()
return false
}
function handleAiAnswerMarkdownClick(event) {
const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
@@ -546,8 +577,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function startInlineConversation(prompt, entry = {}, files = []) {
if (isAiModeInputLocked.value) {
toast('请等待费用测算完成后再继续操作。')
if (!ensureAiModeCanStartConversation()) {
return
}
const cleanPrompt = buildInlinePromptText(prompt, files)
@@ -595,6 +625,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function submitAiModePrompt() {
if (!ensureAiModeCanStartConversation()) {
return
}
if (!canSubmitAiModePrompt.value) {
toast('请输入需求后再发送。')
focusAiModeInput()
@@ -604,6 +637,9 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function runAiModeAction(item) {
if (!ensureAiModeCanStartConversation()) {
return
}
if (String(item?.label || '').trim() === '发起报销') {
void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label)
return
@@ -624,9 +660,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
}
function pushInlineUserMessage(text) {
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
}
function pushInlineUserMessage(text) { conversationMessages.value.push(createInlineMessage('user', String(text || '').trim())) }
function pushInlineApplicationActionUserMessage(text) {
pushInlineUserMessage(text)
@@ -637,13 +671,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
function resolveLatestInlineUserPrompt() {
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
return String(latestUserMessage?.content || '').trim()
return String([...conversationMessages.value].reverse().find((message) => message.role === 'user')?.content || '').trim()
}
function handleVoiceInput() {
if (isAiModeInputLocked.value) {
toast('请等待费用测算完成后再继续操作。')
if (!ensureAiModeCanStartConversation()) {
return
}
toast('语音输入正在准备中,您可以先输入文字需求。')
@@ -664,6 +696,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
if (command.type === 'open-recent') {
sessionCommands.openInlineRecentConversation(command.payload || {})
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
}
}
)
@@ -671,6 +705,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
onMounted(() => {
loadSystemSettings()
refreshConversationHistory()
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
})
@@ -681,6 +717,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return {
activeConversationTitle,
aiModeActionItems,
aiModeInputLockMessage,
applicationPreviewEditor,
applicationSubmitConfirmOpen,
assistantInputRef,
@@ -734,7 +771,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
resolveInlineThinkingEvents,
runAiModeAction,
scrollInlineConversationToTop,
selectedFileCards,
...filePreview,
sending,
setAssistantInputRef,
setWorkbenchDateMode,