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:
183
web/src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js
Normal file
183
web/src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { buildSelectedFileCards } from './workbenchAiComposerModel.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 resolveDocumentPreviewUrl(document = null) {
|
||||
return normalizePreviewText(document?.preview_data_url || document?.previewDataUrl)
|
||||
}
|
||||
|
||||
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: '' })
|
||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).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 openAiModeFilePreview(fileKey) {
|
||||
const target = findSelectedFile(fileKey)
|
||||
if (!target?.rawFile) {
|
||||
return
|
||||
}
|
||||
clearFilePreviewObjectUrl()
|
||||
filePreviewState.value = {
|
||||
open: true,
|
||||
key: fileKey,
|
||||
objectUrl: createObjectUrl(target.rawFile)
|
||||
}
|
||||
}
|
||||
|
||||
function closeAiModeFilePreview() {
|
||||
clearFilePreviewObjectUrl()
|
||||
filePreviewState.value = { open: false, key: '', objectUrl: '' }
|
||||
}
|
||||
|
||||
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 documentPreviewUrl = resolveDocumentPreviewUrl(document)
|
||||
const sourceUrl = documentPreviewUrl || filePreviewState.value.objectUrl
|
||||
const sourceKind = documentPreviewUrl ? 'image' : 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user