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:
@@ -687,7 +687,243 @@
|
||||
}
|
||||
|
||||
.rendering-settings-card .switch-group {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cache-management-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.cache-management-hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(92px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cache-management-hero-metrics span {
|
||||
min-height: 64px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.cache-management-hero-metrics strong {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cache-management-hero-metrics small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cache-safety-strip {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cache-safety-strip i {
|
||||
flex: 0 0 auto;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.cache-scope-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cache-scope-item {
|
||||
min-width: 0;
|
||||
min-height: 148px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.cache-scope-item i {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.cache-scope-item strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cache-scope-item span {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.cache-management-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.cache-management-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cache-management-copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cache-management-copy span {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cache-clear-button {
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cache-clear-button:hover:not(:disabled) {
|
||||
border-color: var(--theme-primary);
|
||||
background: var(--theme-primary-soft);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.cache-clear-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.cache-clear-result,
|
||||
.cache-clear-empty {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.cache-clear-result.is-error {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.cache-clear-result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cache-clear-result-head i {
|
||||
color: #16a34a;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.cache-clear-result.is-error .cache-clear-result-head i {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.cache-clear-result-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cache-clear-result ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cache-clear-result li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
color: #475569;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cache-clear-result li strong {
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cache-clear-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-self: start;
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cache-clear-empty i {
|
||||
color: var(--theme-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.log-policy-card .card-head {
|
||||
@@ -751,6 +987,15 @@
|
||||
.save-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cache-management-panel {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.cache-scope-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@@ -801,6 +1046,21 @@
|
||||
justify-items: start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.cache-management-hero-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cache-management-panel {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.cache-clear-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -850,4 +1110,16 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cache-scope-item,
|
||||
.cache-management-panel,
|
||||
.cache-clear-result,
|
||||
.cache-clear-empty {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.cache-scope-grid,
|
||||
.cache-management-hero-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,10 +250,6 @@
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
|
||||
<i class="mdi mdi-timeline-text-outline"></i>
|
||||
<span>查看 Trace</span>
|
||||
</button>
|
||||
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
|
||||
@@ -266,7 +262,6 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AuditPickerFilter from './AuditPickerFilter.vue'
|
||||
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
|
||||
@@ -303,7 +298,6 @@ const props = defineProps({
|
||||
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
|
||||
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const runs = ref([])
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
@@ -586,14 +580,6 @@ function closeWorkRecordDetail() {
|
||||
detailError.value = ''
|
||||
}
|
||||
|
||||
function openTraceCenter() {
|
||||
const runId = String(selectedRunDetail.value?.run_id || selectedRunId.value || '').trim()
|
||||
if (!runId) {
|
||||
return
|
||||
}
|
||||
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.focusRunId,
|
||||
(runId) => {
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
class="workbench-ai-file-preview-frame"
|
||||
title="附件 PDF 预览"
|
||||
></iframe>
|
||||
<div v-else-if="preview.sourceKind === 'loading'" class="workbench-ai-file-preview-state">
|
||||
<i class="mdi mdi-loading"></i>
|
||||
<span>正在加载统一预览。</span>
|
||||
</div>
|
||||
<div v-else class="workbench-ai-file-preview-state">
|
||||
<i class="mdi mdi-file-eye-outline"></i>
|
||||
<span>当前附件暂不支持直接预览。</span>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -17,3 +17,9 @@ export function testModelConnectivity(payload) {
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function clearSystemCaches() {
|
||||
return apiRequest('/settings/cache/clear', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
118
web/src/utils/documentPreviewAssets.js
Normal file
118
web/src/utils/documentPreviewAssets.js
Normal file
@@ -0,0 +1,118 @@
|
||||
function normalizePreviewText(value) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function normalizePreviewKind(value) {
|
||||
const normalized = normalizePreviewText(value).toLowerCase()
|
||||
return ['image', 'pdf', 'file', 'unsupported'].includes(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
export function inferPreviewKindFromUrl(url) {
|
||||
const normalized = normalizePreviewText(url).toLowerCase()
|
||||
if (!normalized) return ''
|
||||
if (normalized.startsWith('data:image/') || /\.(png|jpe?g|webp|gif|bmp|svg)(?:[?#].*)?$/i.test(normalized)) {
|
||||
return 'image'
|
||||
}
|
||||
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
|
||||
return 'pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function inferPreviewKindFromBlob(blob) {
|
||||
const mediaType = normalizePreviewText(blob?.type).toLowerCase()
|
||||
if (mediaType.startsWith('image/')) return 'image'
|
||||
if (mediaType === 'application/pdf') return 'pdf'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function inferPreviewKindFromFile(file) {
|
||||
const mediaType = normalizePreviewText(file?.type).toLowerCase()
|
||||
const filename = normalizePreviewText(file?.name).toLowerCase()
|
||||
if (mediaType.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg|heic)$/i.test(filename)) {
|
||||
return 'image'
|
||||
}
|
||||
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
|
||||
return 'pdf'
|
||||
}
|
||||
return 'file'
|
||||
}
|
||||
|
||||
export function isInlinePreviewUrl(url) {
|
||||
return normalizePreviewText(url).toLowerCase().startsWith('data:')
|
||||
}
|
||||
|
||||
export function isTemporaryPreviewUrl(url) {
|
||||
return normalizePreviewText(url).toLowerCase().startsWith('blob:')
|
||||
}
|
||||
|
||||
export function resolveDocumentPreviewKind(item = {}, fallback = '') {
|
||||
const explicit = normalizePreviewKind(item?.preview_kind || item?.previewKind)
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
return inferPreviewKindFromUrl(resolveDocumentPreviewUrl(item)) || normalizePreviewKind(fallback)
|
||||
}
|
||||
|
||||
export function resolveDocumentPreviewUrl(item = {}) {
|
||||
return normalizePreviewText(
|
||||
item?.preview_data_url ||
|
||||
item?.previewDataUrl ||
|
||||
item?.preview_url ||
|
||||
item?.previewUrl ||
|
||||
item?.receipt_preview_url ||
|
||||
item?.receiptPreviewUrl
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveDocumentPreviewAsset(item = {}, options = {}) {
|
||||
const sourceCandidates = [
|
||||
{
|
||||
source: 'inline',
|
||||
url: normalizePreviewText(item?.preview_data_url || item?.previewDataUrl)
|
||||
},
|
||||
{
|
||||
source: 'remote',
|
||||
url: normalizePreviewText(item?.preview_url || item?.previewUrl)
|
||||
},
|
||||
{
|
||||
source: 'receipt',
|
||||
url: normalizePreviewText(item?.receipt_preview_url || item?.receiptPreviewUrl)
|
||||
}
|
||||
]
|
||||
const explicitKind = normalizePreviewKind(item?.preview_kind || item?.previewKind)
|
||||
|
||||
for (const candidate of sourceCandidates) {
|
||||
if (!candidate.url) continue
|
||||
const kind = explicitKind || inferPreviewKindFromUrl(candidate.url)
|
||||
if (!['image', 'pdf'].includes(kind)) continue
|
||||
return {
|
||||
kind,
|
||||
url: candidate.url,
|
||||
source: candidate.source
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackUrl = normalizePreviewText(options.fallbackUrl)
|
||||
if (fallbackUrl) {
|
||||
const kind = inferPreviewKindFromUrl(fallbackUrl) || inferPreviewKindFromFile(options.fallbackFile)
|
||||
if (['image', 'pdf'].includes(kind)) {
|
||||
return {
|
||||
kind,
|
||||
url: fallbackUrl,
|
||||
source: 'file'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackKind = inferPreviewKindFromFile(options.fallbackFile)
|
||||
if (fallbackKind) {
|
||||
return {
|
||||
kind: fallbackKind,
|
||||
url: '',
|
||||
source: 'file'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -84,11 +84,11 @@ export const SECTION_DEFINITIONS = [
|
||||
actionLabel: ''
|
||||
},
|
||||
{
|
||||
id: 'agentTraces',
|
||||
label: 'Agent Trace',
|
||||
title: 'Agent 链路追踪',
|
||||
desc: '对话链路、工具调用与事件重放',
|
||||
longDesc: '按 Run ID 还原 Orchestrator 到下游 Agent 的语义识别、路由、工具调用、会话写回和最终回复,便于线上排障和审计复盘。',
|
||||
id: 'cacheManagement',
|
||||
label: '缓存管理',
|
||||
title: '系统缓存管理',
|
||||
desc: 'OCR、模型与索引缓存',
|
||||
longDesc: '手动清理 OCR 识别结果、模型失败冷却、知识库本地索引和运行时配置等进程内缓存。',
|
||||
actionLabel: ''
|
||||
},
|
||||
{
|
||||
@@ -482,7 +482,7 @@ export function computeSectionStatus(state) {
|
||||
normalizeValue(state.logForm.logPath)
|
||||
),
|
||||
systemLogs: true,
|
||||
agentTraces: true,
|
||||
cacheManagement: true,
|
||||
mail: Boolean(
|
||||
normalizeValue(state.mailForm.smtpHost) &&
|
||||
Number(state.mailForm.port) > 0 &&
|
||||
|
||||
@@ -34,10 +34,6 @@
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>刷新详情</span>
|
||||
</button>
|
||||
<button class="refresh-btn" type="button" @click="openAgentTraceCenter">
|
||||
<i class="mdi mdi-timeline-text-outline"></i>
|
||||
<span>查看 Trace</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -445,14 +441,6 @@ function backToLogs() {
|
||||
router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
|
||||
}
|
||||
|
||||
function openAgentTraceCenter() {
|
||||
const runId = String(hermesRun.value?.run_id || '').trim()
|
||||
if (!runId) {
|
||||
return
|
||||
}
|
||||
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.logKind, route.params.logId],
|
||||
() => {
|
||||
|
||||
@@ -381,6 +381,7 @@ import {
|
||||
fetchReceiptFolderItems,
|
||||
updateReceiptFolderItem
|
||||
} from '../services/receiptFolder.js'
|
||||
import { inferPreviewKindFromBlob } from '../utils/documentPreviewAssets.js'
|
||||
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
|
||||
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
|
||||
import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js'
|
||||
@@ -638,6 +639,13 @@ async function loadPreview(detail) {
|
||||
if (!detail?.preview_url) return
|
||||
try {
|
||||
const blob = await fetchReceiptFolderAsset(detail.preview_url)
|
||||
const resolvedPreviewKind = inferPreviewKindFromBlob(blob)
|
||||
if (resolvedPreviewKind && selectedReceipt.value?.id === detail.id) {
|
||||
selectedReceipt.value = {
|
||||
...selectedReceipt.value,
|
||||
preview_kind: resolvedPreviewKind
|
||||
}
|
||||
}
|
||||
previewObjectUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
previewObjectUrl.value = ''
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<div
|
||||
class="settings-content"
|
||||
:class="{ 'settings-content-fill': ['systemLogs', 'agentTraces'].includes(activeSection) }"
|
||||
:class="{ 'settings-content-fill': ['systemLogs'].includes(activeSection) }"
|
||||
>
|
||||
<template v-if="activeSection === 'profile'">
|
||||
<section class="settings-card">
|
||||
@@ -442,8 +442,95 @@
|
||||
<LogsView v-else class="settings-logs-view" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'agentTraces'">
|
||||
<AgentTraceCenterView class="settings-trace-center-view" />
|
||||
<template v-else-if="activeSection === 'cacheManagement'">
|
||||
<section class="settings-card cache-management-card">
|
||||
<div class="card-head">
|
||||
<div class="card-title-with-icon">
|
||||
<div class="model-icon-box slate">
|
||||
<i class="mdi mdi-cached"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4>应用缓存清理</h4>
|
||||
<p>清理运行时产生的临时结果,释放进程内缓存,并让下一次识别、检索或模型调用重新构建最新状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-management-hero-metrics" aria-label="缓存清理范围概览">
|
||||
<span>
|
||||
<strong>4 类</strong>
|
||||
<small>清理范围</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>0 项</strong>
|
||||
<small>数据删除</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="cache-safety-strip">
|
||||
<i class="mdi mdi-shield-check-outline" aria-hidden="true"></i>
|
||||
<span>不会删除源文件、业务单据和系统日志,只清理可重新生成的进程内缓存。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-scope-grid" aria-label="缓存清理范围">
|
||||
<article class="cache-scope-item">
|
||||
<i class="mdi mdi-text-recognition" aria-hidden="true"></i>
|
||||
<strong>OCR 识别结果</strong>
|
||||
<span>清空票据识别的内存结果,下次上传或查看时重新识别。</span>
|
||||
</article>
|
||||
<article class="cache-scope-item">
|
||||
<i class="mdi mdi-robot-outline" aria-hidden="true"></i>
|
||||
<strong>模型失败冷却</strong>
|
||||
<span>解除模型通道失败后的短期冷却,便于恢复后立即重试。</span>
|
||||
</article>
|
||||
<article class="cache-scope-item">
|
||||
<i class="mdi mdi-book-search-outline" aria-hidden="true"></i>
|
||||
<strong>知识库本地索引</strong>
|
||||
<span>清理本地检索索引缓存,下一次检索会重新读取知识库。</span>
|
||||
</article>
|
||||
<article class="cache-scope-item">
|
||||
<i class="mdi mdi-tune-variant" aria-hidden="true"></i>
|
||||
<strong>运行时配置缓存</strong>
|
||||
<span>刷新进程内配置读取结果,避免继续使用旧配置快照。</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="cache-management-panel">
|
||||
<div class="cache-management-copy">
|
||||
<strong>立即执行维护清理</strong>
|
||||
<span>建议在 OCR 配置、模型接入或知识库文件调整后使用。</span>
|
||||
</div>
|
||||
<button class="cache-clear-button" type="button" :disabled="cacheClearing" @click="clearAllCaches">
|
||||
<i class="mdi" :class="cacheClearing ? 'mdi-loading mdi-spin' : 'mdi-delete-sweep-outline'"></i>
|
||||
<span>{{ cacheClearing ? '清理中...' : '一键清理缓存' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cacheClearMessage" class="cache-clear-result" :class="{ 'is-error': cacheClearFailed }">
|
||||
<div class="cache-clear-result-head">
|
||||
<i
|
||||
class="mdi"
|
||||
:class="cacheClearFailed ? 'mdi-alert-circle-outline' : 'mdi-check-circle-outline'"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<strong>{{ cacheClearMessage }}</strong>
|
||||
</div>
|
||||
<ul v-if="cacheClearItems.length">
|
||||
<li v-for="item in cacheClearItems" :key="item.cacheKey">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.clearedCount }} 条</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="cache-clear-empty">
|
||||
<i class="mdi mdi-database-clock-outline"></i>
|
||||
<span>等待执行缓存清理。</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'mail'">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
|
||||
import AgentTraceCenterView from '../AgentTraceCenterView.vue'
|
||||
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
|
||||
import LogDetailView from '../LogDetailView.vue'
|
||||
import LogsView from '../LogsView.vue'
|
||||
@@ -10,7 +9,6 @@ import { useSettings } from '../../composables/useSettings.js'
|
||||
export default {
|
||||
name: 'SettingsView',
|
||||
components: {
|
||||
AgentTraceCenterView,
|
||||
HermesEmployeeSettingsPanel,
|
||||
EnterpriseSelect,
|
||||
LlmSettingsPanel,
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
resolveExpenseTypeCode
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import {
|
||||
inferPreviewKindFromFile,
|
||||
isTemporaryPreviewUrl as isTemporaryDocumentPreviewUrl,
|
||||
resolveDocumentPreviewAsset,
|
||||
resolveDocumentPreviewKind as resolveUnifiedDocumentPreviewKind
|
||||
} from '../../utils/documentPreviewAssets.js'
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
@@ -404,15 +410,7 @@ export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
|
||||
}
|
||||
|
||||
export function inferPreviewKind(file) {
|
||||
const mediaType = String(file?.type || '').toLowerCase()
|
||||
const filename = String(file?.name || '').toLowerCase()
|
||||
if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) {
|
||||
return 'image'
|
||||
}
|
||||
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
|
||||
return 'pdf'
|
||||
}
|
||||
return 'file'
|
||||
return inferPreviewKindFromFile(file)
|
||||
}
|
||||
|
||||
export function buildFilePreviews(files, previewRegistry) {
|
||||
@@ -449,7 +447,7 @@ export function resolveDocumentPreview(filePreviews, filename) {
|
||||
}
|
||||
|
||||
export function isTemporaryPreviewUrl(url) {
|
||||
return String(url || '').trim().toLowerCase().startsWith('blob:')
|
||||
return isTemporaryDocumentPreviewUrl(url)
|
||||
}
|
||||
|
||||
export function buildFileIdentity(file) {
|
||||
@@ -526,45 +524,35 @@ export function filterPersistableFilePreviews(filePreviews) {
|
||||
})
|
||||
}
|
||||
|
||||
function inferPreviewKindFromUrl(url) {
|
||||
const normalized = String(url || '').trim().toLowerCase()
|
||||
if (!normalized) return ''
|
||||
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
|
||||
return 'image'
|
||||
}
|
||||
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
|
||||
return 'pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveDocumentPreviewKind(item) {
|
||||
const explicit = String(item?.preview_kind || '').trim()
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
|
||||
return resolveUnifiedDocumentPreviewKind(item)
|
||||
}
|
||||
|
||||
export function buildOcrFilePreviews(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: resolveDocumentPreviewKind(item),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.map((item) => {
|
||||
const asset = resolveDocumentPreviewAsset(item)
|
||||
return {
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: asset?.kind || '',
|
||||
url: asset?.url || ''
|
||||
}
|
||||
})
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: resolveDocumentPreviewKind(item),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.map((item) => {
|
||||
const asset = resolveDocumentPreviewAsset(item)
|
||||
return {
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: asset?.kind || '',
|
||||
url: asset?.url || ''
|
||||
}
|
||||
})
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ test('attachment upload association uses conversation selection instead of legac
|
||||
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
|
||||
assert.match(
|
||||
submitDraftPreflightSource,
|
||||
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
|
||||
/const claims = await fetchExpenseClaims\([^)]*\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
|
||||
)
|
||||
assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/)
|
||||
assert.match(
|
||||
@@ -186,6 +186,26 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
|
||||
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
||||
})
|
||||
|
||||
test('OCR preview builders reuse receipt folder image preview endpoints', () => {
|
||||
const ocrPreviews = buildOcrFilePreviews({
|
||||
documents: [
|
||||
{
|
||||
filename: '2月23 上海-武汉.pdf',
|
||||
preview_kind: 'image',
|
||||
receipt_preview_url: '/receipt-folder/receipt-train-1/preview'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(ocrPreviews, [
|
||||
{
|
||||
filename: '2月23 上海-武汉.pdf',
|
||||
kind: 'image',
|
||||
url: '/receipt-folder/receipt-train-1/preview'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('OCR receipt folder ids are kept for final draft attachment association', () => {
|
||||
const files = [
|
||||
{ name: 'invoice.png' },
|
||||
|
||||
@@ -62,6 +62,15 @@ function testReceiptFolderViewSurface() {
|
||||
assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/)
|
||||
}
|
||||
|
||||
function testReceiptPreviewKindFollowsReturnedBlobType() {
|
||||
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
|
||||
assert.match(view, /import \{ inferPreviewKindFromBlob \} from '\.\.\/utils\/documentPreviewAssets\.js'/)
|
||||
assert.doesNotMatch(view, /function inferPreviewKindFromBlob\(blob\)/)
|
||||
assert.match(view, /const resolvedPreviewKind = inferPreviewKindFromBlob\(blob\)/)
|
||||
assert.match(view, /preview_kind: resolvedPreviewKind/)
|
||||
}
|
||||
|
||||
function testReceiptFolderServiceContract() {
|
||||
const service = readProjectFile('web/src/services/receiptFolder.js')
|
||||
const ocrService = readProjectFile('web/src/services/ocr.js')
|
||||
@@ -180,6 +189,7 @@ function testAssistantUnlinkedReceiptPrompt() {
|
||||
|
||||
function run() {
|
||||
testReceiptFolderViewSurface()
|
||||
testReceiptPreviewKindFollowsReturnedBlobType()
|
||||
testReceiptFolderServiceContract()
|
||||
testAppShellWiresReceiptFolder()
|
||||
testSharedDocumentListStyleReuse()
|
||||
|
||||
39
web/tests/settings-cache-management-section.test.mjs
Normal file
39
web/tests/settings-cache-management-section.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
|
||||
const settingsService = readFileSync(new URL('../src/services/settings.js', import.meta.url), 'utf8')
|
||||
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
|
||||
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
|
||||
const useSettings = readFileSync(new URL('../src/composables/useSettings.js', import.meta.url), 'utf8')
|
||||
|
||||
test('system settings replace Agent Trace with cache management', () => {
|
||||
assert.match(settingsModel, /id:\s*'cacheManagement'[\s\S]*label:\s*'缓存管理'/)
|
||||
assert.doesNotMatch(settingsModel, /id:\s*'agentTraces'/)
|
||||
assert.doesNotMatch(settingsScript, /AgentTraceCenterView/)
|
||||
assert.match(settingsView, /activeSection === 'cacheManagement'/)
|
||||
assert.match(settingsView, /一键清理缓存/)
|
||||
})
|
||||
|
||||
test('cache management action calls the settings clear-cache endpoint', () => {
|
||||
assert.match(settingsService, /export function clearSystemCaches/)
|
||||
assert.match(settingsService, /\/settings\/cache\/clear/)
|
||||
assert.match(useSettings, /clearSystemCaches/)
|
||||
assert.match(settingsView, /@click="clearAllCaches"/)
|
||||
})
|
||||
|
||||
test('cache management section uses an enterprise maintenance layout', () => {
|
||||
assert.match(settingsView, /class="settings-card cache-management-card"/)
|
||||
assert.match(settingsView, /class="card-title-with-icon"/)
|
||||
assert.match(settingsView, /class="cache-management-hero-metrics"/)
|
||||
assert.match(settingsView, /class="cache-scope-grid"/)
|
||||
assert.match(settingsView, /OCR 识别结果/)
|
||||
assert.match(settingsView, /模型失败冷却/)
|
||||
assert.match(settingsView, /知识库本地索引/)
|
||||
assert.match(settingsView, /不会删除源文件、业务单据和系统日志/)
|
||||
assert.match(settingsView, /:class="\{ 'is-error': cacheClearFailed \}"/)
|
||||
assert.match(useSettings, /const cacheClearFailed = ref\(false\)/)
|
||||
assert.match(useSettings, /normalizeCacheClearErrorMessage/)
|
||||
assert.doesNotMatch(settingsView, />\\s*Not Found\\s*</)
|
||||
})
|
||||
@@ -1,17 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
|
||||
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
|
||||
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
|
||||
const llmSettingsPanel = readFileSync(new URL('../src/views/LlmSettingsPanel.vue', import.meta.url), 'utf8')
|
||||
|
||||
function testLlmSectionReplacesVlmWithReranker() {
|
||||
assert.doesNotMatch(settingsView, /VLM 模型/)
|
||||
assert.match(settingsView, /Reranker 模型配置/)
|
||||
assert.match(settingsScript, /rerankerProvider/)
|
||||
assert.match(llmSettingsPanel, /Reranker 模型配置/)
|
||||
assert.match(settingsModel, /rerankerProvider/)
|
||||
}
|
||||
|
||||
function testRerankerCardRendersAfterEmbeddingCard() {
|
||||
assert.match(settingsView, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
|
||||
assert.match(llmSettingsPanel, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
|
||||
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
|
||||
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
|
||||
const settingsStyles = readFileSync(new URL('../src/assets/styles/views/settings-view.css', import.meta.url), 'utf8')
|
||||
|
||||
function testRenderingSectionUsesConciseToolbarTitle() {
|
||||
assert.match(settingsScript, /title:\s*'文件渲染'/)
|
||||
assert.doesNotMatch(settingsScript, /title:\s*'ONLYOFFICE 文件渲染配置'/)
|
||||
assert.match(settingsModel, /title:\s*'文件渲染'/)
|
||||
assert.doesNotMatch(settingsModel, /title:\s*'ONLYOFFICE 文件渲染配置'/)
|
||||
}
|
||||
|
||||
function testRenderingCardRemovesDuplicatedDescription() {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { buildSelectedFileCards } from '../src/composables/workbenchAiMode/workbenchAiComposerModel.js'
|
||||
|
||||
function readSource(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
@@ -49,6 +51,31 @@ test('shared workbench file strip preserves OCR status badges', () => {
|
||||
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
|
||||
})
|
||||
|
||||
test('shared workbench file strip uses recognized image preview metadata over raw PDF type', () => {
|
||||
const [card] = buildSelectedFileCards([
|
||||
{
|
||||
name: '2月23 上海-武汉.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 24940,
|
||||
lastModified: 1760000000000
|
||||
}
|
||||
], () => ({
|
||||
status: 'recognized',
|
||||
document: {
|
||||
preview_kind: 'image',
|
||||
receipt_preview_url: '/receipt-folder/receipt-train-1/preview'
|
||||
}
|
||||
}))
|
||||
|
||||
assert.equal(card.tone, 'image')
|
||||
assert.equal(card.icon, 'mdi mdi-file-image-outline')
|
||||
assert.deepEqual(card.previewAsset, {
|
||||
kind: 'image',
|
||||
url: '/receipt-folder/receipt-train-1/preview',
|
||||
source: 'receipt'
|
||||
})
|
||||
})
|
||||
|
||||
test('AI mode primes attachment OCR synchronously after file selection', () => {
|
||||
assert.match(
|
||||
filePreviewRuntime,
|
||||
|
||||
Reference in New Issue
Block a user