feat(web): 设置中心缓存管理与文件预览资产工具

- 新增 documentPreviewAssets 工具,统一从 URL/Blob/File 推断预览类型(image/pdf/file/unsupported)
- SettingsView/SettingsView.js/settingsModelHelper 新增系统缓存管理区块,调用 /settings/cache/clear 并展示清理结果;useSettings/services 适配
- WorkbenchAiFilePreviewDialog/useWorkbenchAiFilePreview 接入预览资产工具,workbenchAiComposerModel 调整文件处理
- ReceiptFolder/LogDetailView/DigitalEmployeeWorkRecords/travelReimbursementAttachmentModel 配套适配
- 新增 settings-cache-management-section 测试,更新 settings-llm/rendering/receipt-folder-view/composer-components/attachment-association 测试
This commit is contained in:
caoxiaozhu
2026-06-24 12:35:59 +08:00
parent 9a5ed0e94a
commit 8417a9f542
20 changed files with 815 additions and 102 deletions

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useSystemState } from './useSystemState.js'
import { useThemeSkin } from './useThemeSkin.js'
import { fetchSettings, saveSettings } from '../services/settings.js'
import { clearSystemCaches, fetchSettings, saveSettings } from '../services/settings.js'
import { useToast } from './useToast.js'
import {
isHermesEmployeeSettingsReady
@@ -56,6 +56,10 @@ export function useSettings() {
const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null)
const logoInputRef = ref(null)
const cacheClearing = ref(false)
const cacheClearItems = ref([])
const cacheClearMessage = ref('')
const cacheClearFailed = ref(false)
const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS
@@ -433,6 +437,46 @@ export function useSettings() {
})
}
function normalizeCacheClearErrorMessage(error) {
const message = String(error?.message || '').trim()
if (!message || /^not found$/i.test(message)) {
return '缓存清理接口暂不可用,请确认后端服务已加载最新路由后重试。'
}
return message
}
async function clearAllCaches() {
if (cacheClearing.value) {
return
}
cacheClearing.value = true
cacheClearMessage.value = ''
cacheClearItems.value = []
cacheClearFailed.value = false
try {
const payload = await clearSystemCaches()
const items = Array.isArray(payload?.items) ? payload.items : []
const totalCleared = Number(payload?.totalCleared || 0)
cacheClearItems.value = items
cacheClearMessage.value = totalCleared > 0
? `已清理 ${totalCleared} 条缓存。`
: '当前没有可清理的缓存。'
cacheClearFailed.value = false
toast(cacheClearMessage.value)
} catch (error) {
const message = normalizeCacheClearErrorMessage(error)
cacheClearFailed.value = true
cacheClearMessage.value = message
toast(message)
} finally {
cacheClearing.value = false
}
}
async function saveMailSection() {
const mailForm = pageState.value.mailForm
@@ -494,6 +538,10 @@ export function useSettings() {
return
}
if (activeSection.value === 'cacheManagement') {
return
}
if (activeSection.value === 'rendering') {
await saveRenderingSection()
return
@@ -557,6 +605,11 @@ export function useSettings() {
activeThemeSkinId,
archiveCycleOptions,
activateSection,
cacheClearFailed,
cacheClearItems,
cacheClearMessage,
cacheClearing,
clearAllCaches,
clearRenderSecretMask,
completedSectionCount,
logLevels,

View File

@@ -1,5 +1,13 @@
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()
@@ -40,10 +48,6 @@ function normalizePreviewField(field = {}) {
}
}
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()
@@ -72,8 +76,18 @@ export function useWorkbenchAiFilePreview({
scrollInlineConversationToBottom,
selectedFiles
}) {
const filePreviewState = ref({ open: false, key: '', objectUrl: '' })
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
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])
})))
@@ -97,22 +111,94 @@ export function useWorkbenchAiFilePreview({
}
}
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: createObjectUrl(target.rawFile)
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: '' }
filePreviewState.value = {
open: false,
key: '',
objectUrl: '',
objectKind: '',
objectSource: '',
loading: false
}
}
const activeAiModeFilePreview = computed(() => {
@@ -128,9 +214,16 @@ export function useWorkbenchAiFilePreview({
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 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 ||

View File

@@ -1,4 +1,5 @@
import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import { resolveDocumentPreviewAsset } from '../../utils/documentPreviewAssets.js'
export const AI_COMPOSER_FILE_TYPE_META = {
pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' },
@@ -44,7 +45,14 @@ export function resolveAiComposerFileName(file) {
return String(file?.name || '未命名附件').trim() || '未命名附件'
}
export function resolveAiComposerFileType(file) {
export function resolveAiComposerFileType(file, previewAsset = null) {
if (previewAsset?.kind === 'image') {
return AI_COMPOSER_FILE_TYPE_META.image
}
if (previewAsset?.kind === 'pdf') {
return AI_COMPOSER_FILE_TYPE_META.pdf
}
const fileName = resolveAiComposerFileName(file).toLowerCase()
const mimeType = String(file?.type || '').toLowerCase()
const extension = fileName.includes('.') ? fileName.split('.').pop() : ''
@@ -66,12 +74,19 @@ export function resolveAiComposerFileType(file) {
return AI_COMPOSER_FILE_TYPE_META.file
}
export function buildSelectedFileCards(files = []) {
return files.map((file) => ({
key: buildFileIdentity(file),
name: resolveAiComposerFileName(file),
...resolveAiComposerFileType(file)
}))
export function buildSelectedFileCards(files = [], resolveRecognitionState = null) {
return files.map((file, index) => {
const recognitionState = typeof resolveRecognitionState === 'function'
? resolveRecognitionState(file, index)
: null
const previewAsset = resolveDocumentPreviewAsset(recognitionState?.document || null)
return {
key: buildFileIdentity(file),
name: resolveAiComposerFileName(file),
...resolveAiComposerFileType(file, previewAsset),
previewAsset: previewAsset || null
}
})
}
export function isLikelyAiModeOcrFile(file = {}) {