Files
X-Financial/web/src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js

277 lines
8.5 KiB
JavaScript
Raw Normal View History

import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { buildSelectedFileCards } from './workbenchAiComposerModel.js'
import { fetchReceiptFolderAsset } from '../../services/receiptFolder.js'
import {
inferPreviewKindFromBlob,
inferPreviewKindFromFile,
isInlinePreviewUrl,
isTemporaryPreviewUrl,
resolveDocumentPreviewAsset
} from '../../utils/documentPreviewAssets.js'
function normalizePreviewText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim()
}
function formatFileSize(size) {
const bytes = Number(size || 0)
if (!Number.isFinite(bytes) || bytes <= 0) {
return '-'
}
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} KB`
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatFileTime(timestamp) {
const value = Number(timestamp || 0)
if (!Number.isFinite(value) || value <= 0) {
return '-'
}
return new Date(value).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function normalizePreviewField(field = {}) {
const value = normalizePreviewText(field.value ?? field.text)
if (!value) {
return null
}
return {
label: normalizePreviewText(field.label || field.key || field.name) || '识别字段',
value
}
}
function resolveSourceKind(sourceUrl, rawFile = {}) {
const type = normalizePreviewText(rawFile?.type).toLowerCase()
const name = normalizePreviewText(rawFile?.name).toLowerCase()
if (!sourceUrl) {
return 'unsupported'
}
if (sourceUrl.startsWith('data:image/') || type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/.test(name)) {
return 'image'
}
if (type === 'application/pdf' || /\.pdf$/.test(name)) {
return 'pdf'
}
return 'unsupported'
}
function createObjectUrl(rawFile) {
if (!rawFile || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
return ''
}
return URL.createObjectURL(rawFile)
}
export function useWorkbenchAiFilePreview({
attachmentFlow,
conversationStarted,
scrollInlineConversationToBottom,
selectedFiles
}) {
const filePreviewState = ref({
open: false,
key: '',
objectUrl: '',
objectKind: '',
objectSource: '',
loading: false
})
const selectedFileCards = computed(() => buildSelectedFileCards(
selectedFiles.value,
(file) => attachmentFlow.resolveAiModeReceiptRecognitionState(file)
).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
function clearFilePreviewObjectUrl() {
const objectUrl = filePreviewState.value.objectUrl
if (objectUrl && objectUrl.startsWith('blob:') && typeof URL !== 'undefined') {
URL.revokeObjectURL(objectUrl)
}
}
function findSelectedFile(fileKey) {
const index = selectedFileCards.value.findIndex((file) => file.key === fileKey)
if (index < 0) {
return null
}
return {
card: selectedFileCards.value[index],
index,
rawFile: selectedFiles.value[index]
}
}
function resolveTargetDocument(target) {
if (!target?.rawFile) {
return null
}
const recognitionState = attachmentFlow.resolveAiModeReceiptRecognitionState(target.rawFile) || target.card.ocrState || null
return recognitionState?.document || null
}
function resolveRemotePreviewAsset(target) {
const asset = resolveDocumentPreviewAsset(resolveTargetDocument(target))
if (!asset?.url || isInlinePreviewUrl(asset.url) || isTemporaryPreviewUrl(asset.url)) {
return null
}
return asset
}
async function loadRemotePreviewAsset(fileKey) {
const target = findSelectedFile(fileKey)
const asset = resolveRemotePreviewAsset(target)
if (!target?.rawFile || !asset?.url) {
return
}
try {
const blob = await fetchReceiptFolderAsset(asset.url)
const objectUrl = createObjectUrl(blob)
const objectKind = inferPreviewKindFromBlob(blob) || asset.kind
if (!objectUrl || !['image', 'pdf'].includes(objectKind)) {
return
}
if (!filePreviewState.value.open || filePreviewState.value.key !== fileKey) {
URL.revokeObjectURL(objectUrl)
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
...filePreviewState.value,
objectUrl,
objectKind,
objectSource: asset.source,
loading: false
}
} catch (error) {
console.warn('AI mode remote attachment preview unavailable:', error)
if (!filePreviewState.value.open || filePreviewState.value.key !== fileKey) {
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
...filePreviewState.value,
objectUrl: createObjectUrl(target.rawFile),
objectKind: inferPreviewKindFromFile(target.rawFile),
objectSource: 'file',
loading: false
}
}
}
function openAiModeFilePreview(fileKey) {
const target = findSelectedFile(fileKey)
if (!target?.rawFile) {
return
}
clearFilePreviewObjectUrl()
const remoteAsset = resolveRemotePreviewAsset(target)
filePreviewState.value = {
open: true,
key: fileKey,
objectUrl: remoteAsset ? '' : createObjectUrl(target.rawFile),
objectKind: remoteAsset ? '' : inferPreviewKindFromFile(target.rawFile),
objectSource: remoteAsset ? remoteAsset.source : 'file',
loading: Boolean(remoteAsset)
}
if (remoteAsset) {
void loadRemotePreviewAsset(fileKey)
}
}
function closeAiModeFilePreview() {
clearFilePreviewObjectUrl()
filePreviewState.value = {
open: false,
key: '',
objectUrl: '',
objectKind: '',
objectSource: '',
loading: false
}
}
const activeAiModeFilePreview = computed(() => {
if (!filePreviewState.value.open || !filePreviewState.value.key) {
return null
}
const target = findSelectedFile(filePreviewState.value.key)
if (!target) {
return null
}
const rawFile = target.rawFile
const recognitionState = attachmentFlow.resolveAiModeReceiptRecognitionState(rawFile) || target.card.ocrState || null
const document = recognitionState?.document || null
const documentFields = Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || []
const ocrFields = documentFields.map((field) => normalizePreviewField(field)).filter(Boolean)
const documentPreviewAsset = resolveDocumentPreviewAsset(document)
const inlinePreviewAvailable = documentPreviewAsset?.url && isInlinePreviewUrl(documentPreviewAsset.url)
const sourceUrl = inlinePreviewAvailable
? documentPreviewAsset.url
: filePreviewState.value.objectUrl
const sourceKind = inlinePreviewAvailable
? documentPreviewAsset.kind
: filePreviewState.value.loading
? 'loading'
: filePreviewState.value.objectKind || resolveSourceKind(sourceUrl, rawFile)
const documentTypeLabel = normalizePreviewText(
document?.document_type_label ||
document?.scene_label ||
document?.document_type ||
target.card.typeLabel
)
return {
open: true,
key: target.card.key,
name: target.card.name,
sourceKind,
sourceUrl,
documentTypeLabel: documentTypeLabel || '待识别',
recognitionStatus: recognitionState?.status || 'idle',
recognitionStatusLabel: recognitionState?.label || '等待智能录入识别',
recognitionStatusTitle: recognitionState?.title || '',
fileInfoRows: [
{ label: '文件类型', value: target.card.typeLabel },
{ label: '文件大小', value: formatFileSize(rawFile?.size) },
{ label: '上传时间', value: formatFileTime(rawFile?.lastModified) }
],
ocrFields,
ocrSummary: normalizePreviewText(document?.summary),
rawText: normalizePreviewText(document?.text).slice(0, 600)
}
})
watch(selectedFiles, (files, previousFiles = []) => {
attachmentFlow.primeAiModeReceiptContext(files)
const fileCountChanged = files.length !== previousFiles.length
if (conversationStarted.value && fileCountChanged) {
scrollInlineConversationToBottom({ force: true })
}
if (filePreviewState.value.open && !findSelectedFile(filePreviewState.value.key)) {
closeAiModeFilePreview()
}
}, { flush: 'sync' })
onBeforeUnmount(() => {
closeAiModeFilePreview()
})
return {
activeAiModeFilePreview,
closeAiModeFilePreview,
openAiModeFilePreview,
selectedFileCards
}
}