feat(attachment): 改进附件解析功能,增加解析方法选择和状态显示

This commit is contained in:
Wenjie Zhang 2026-05-28 09:14:08 +08:00
parent 6438f8b7c2
commit 54bf9b61a8
2 changed files with 652 additions and 162 deletions

View File

@ -22,41 +22,124 @@
<div v-if="fileItems.length" class="attachment-list"> <div v-if="fileItems.length" class="attachment-list">
<div v-for="item in fileItems" :key="item.localId" class="attachment-item"> <div v-for="item in fileItems" :key="item.localId" class="attachment-item">
<div class="attachment-item-main"> <div class="attachment-file-icon" :style="{ color: getFileIconColor(item.fileName) }">
<div class="attachment-name-row"> <component :is="getFileIcon(item.fileName)" />
<span class="attachment-name">{{ item.fileName }}</span>
<a-tag :color="getStatusColor(item.status)">{{ getStatusLabel(item.status) }}</a-tag>
</div>
<div class="attachment-meta">
<span>{{ formatFileSize(item.fileSize) }}</span>
<span v-if="item.error" class="attachment-error">{{ item.error }}</span>
<span v-else-if="item.parseError" class="attachment-error">{{ item.parseError }}</span>
<span v-else-if="item.parsedObjectName" class="attachment-parsed">已生成解析附件</span>
</div>
</div> </div>
<div class="attachment-actions"> <div class="attachment-item-content">
<template v-if="item.parseSupported && item.status !== 'uploading' && item.status !== 'error'"> <div class="attachment-name-row">
<a-select <span class="attachment-name" :title="item.fileName">{{ item.fileName }}</span>
v-model:value="item.selectedParseMethod"
:options="getParseMethodOptions(item)"
size="small"
class="parse-select"
:disabled="item.status === 'parsing' || confirming"
@change="(value) => handleParseMethodChange(item.localId, value)"
/>
<a-button <a-button
size="small" size="small"
:loading="item.status === 'parsing'" type="text"
:disabled="!item.selectedParseMethod || confirming" class="remove-btn"
@click="handleParse(item)" :disabled="confirming"
@click="removeItem(item.localId)"
> >
解析 <X :size="16" />
</a-button> </a-button>
</template> </div>
<a-button size="small" type="text" :disabled="confirming" @click="removeItem(item.localId)">
移除 <div class="attachment-status-row">
</a-button> <div class="attachment-status-meta">
<a-tag :color="getStatusColor(item.status)">{{ getStatusLabel(item.status) }}</a-tag>
<span>{{ formatFileSize(item.fileSize) }}</span>
<span v-if="item.error" class="attachment-error">{{ item.error }}</span>
<span v-else-if="item.parseError" class="attachment-error">{{
item.parseError
}}</span>
</div>
<a-popover
v-if="item.parseSupported && item.status !== 'uploading' && item.status !== 'error'"
v-model:open="item.parsePanelOpen"
placement="bottomRight"
trigger="click"
overlayClassName="attachment-parse-popover"
@openChange="(open) => handleParsePanelOpenChange(item.localId, open)"
>
<template #content>
<div class="parse-panel">
<button
v-for="method in getAvailableParseMethods(item)"
:key="method"
type="button"
class="parse-method-option"
:class="{ selected: item.selectedParseMethod === method }"
:disabled="item.status === 'parsing' || confirming"
@click="handleParseMethodChange(item.localId, method)"
>
<span class="parse-method-option-header">
<span class="parse-method-name">{{ methodLabels[method] || method }}</span>
<span
class="parse-method-status"
:class="`status-${getMethodStatus(method)}`"
>
{{ getMethodStatusLabel(method) }}
</span>
</span>
<span class="parse-method-desc">{{ getMethodDescription(method) }}</span>
</button>
<div v-if="getUnavailableParseMethods(item).length" class="unavailable-methods">
<button
type="button"
class="unavailable-toggle"
@click="toggleUnavailableParseMethods(item.localId)"
>
<span>不可用选项{{ getUnavailableParseMethods(item).length }}</span>
<ChevronUp v-if="item.unavailableMethodsExpanded" :size="14" />
<ChevronDown v-else :size="14" />
</button>
<div v-if="item.unavailableMethodsExpanded" class="unavailable-method-list">
<button
v-for="method in getUnavailableParseMethods(item)"
:key="method"
type="button"
class="parse-method-option disabled"
disabled
>
<span class="parse-method-option-header">
<span class="parse-method-name">{{
methodLabels[method] || method
}}</span>
<span
class="parse-method-status"
:class="`status-${getMethodStatus(method)}`"
>
{{ getMethodStatusLabel(method) }}
</span>
</span>
<span class="parse-method-desc">{{ getMethodDescription(method) }}</span>
</button>
</div>
</div>
<a-button
type="primary"
size="small"
block
class="parse-start-btn"
:loading="item.status === 'parsing'"
:disabled="isParseDisabled(item)"
@click="handleStartParse(item.localId)"
>
开始解析
</a-button>
</div>
</template>
<a-button
size="small"
class="parse-trigger-btn"
:loading="item.status === 'parsing'"
:disabled="confirming"
>
解析
</a-button>
</a-popover>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -66,7 +149,10 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { ChevronDown, ChevronUp, X } from 'lucide-vue-next'
import { threadApi } from '@/apis' import { threadApi } from '@/apis'
import { ocrApi } from '@/apis/system_api'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
@ -89,7 +175,34 @@ const methodLabels = {
deepseek_ocr: 'DeepSeek OCR' deepseek_ocr: 'DeepSeek OCR'
} }
const busy = computed(() => fileItems.value.some((item) => ['uploading', 'parsing'].includes(item.status))) const ocrMethodKeys = [
'rapid_ocr',
'mineru_ocr',
'mineru_official',
'pp_structure_v3_ocr',
'deepseek_ocr'
]
const defaultOcrHealthStatus = () =>
Object.fromEntries(ocrMethodKeys.map((method) => [method, { status: 'unknown', message: '' }]))
const ocrHealthStatus = ref(defaultOcrHealthStatus())
const ocrHealthChecking = ref(false)
const methodStatusLabels = {
local: '无需 OCR',
healthy: '可用',
unavailable: '不可用',
unhealthy: '异常',
timeout: '超时',
error: '异常',
checking: '检查中',
unknown: '状态未知'
}
const busy = computed(() =>
fileItems.value.some((item) => ['uploading', 'parsing'].includes(item.status))
)
const confirmableItems = computed(() => const confirmableItems = computed(() =>
fileItems.value.filter((item) => ['uploaded', 'parsed'].includes(item.status)) fileItems.value.filter((item) => ['uploaded', 'parsed'].includes(item.status))
) )
@ -109,10 +222,7 @@ const getErrorMessage = (error, fallback = '操作失败') => {
return error?.response?.data?.detail || error?.message || fallback return error?.response?.data?.detail || error?.message || fallback
} }
const getDefaultParseMethod = (methods) => { const getDefaultParseMethod = () => null
if (!Array.isArray(methods) || methods.length === 0) return null
return methods.includes('disable') ? 'disable' : methods[0]
}
const normalizeTmpUpload = (response) => ({ const normalizeTmpUpload = (response) => ({
tmpFileId: response.tmp_file_id, tmpFileId: response.tmp_file_id,
@ -133,6 +243,23 @@ const updateItem = (localId, patch) => {
) )
} }
const checkOcrHealth = async () => {
if (ocrHealthChecking.value) return
ocrHealthChecking.value = true
try {
const healthData = await ocrApi.getHealth()
ocrHealthStatus.value = {
...defaultOcrHealthStatus(),
...(healthData?.services || {})
}
} catch (error) {
console.error('OCR健康检查失败:', error)
} finally {
ocrHealthChecking.value = false
}
}
const uploadFile = async (file) => { const uploadFile = async (file) => {
const localId = `${Date.now()}-${localIdSeed++}` const localId = `${Date.now()}-${localIdSeed++}`
const item = { const item = {
@ -149,7 +276,11 @@ const uploadFile = async (file) => {
try { try {
const response = await threadApi.uploadTmpAttachment(file) const response = await threadApi.uploadTmpAttachment(file)
updateItem(localId, { ...normalizeTmpUpload(response), status: 'uploaded' }) const normalized = normalizeTmpUpload(response)
updateItem(localId, { ...normalized, status: 'uploaded' })
if (normalized.parseSupported) {
void checkOcrHealth()
}
} catch (error) { } catch (error) {
updateItem(localId, { updateItem(localId, {
status: 'error', status: 'error',
@ -163,13 +294,49 @@ const handleBeforeUpload = (file) => {
return false return false
} }
const getParseMethodOptions = (item) => { const getMethodStatus = (method) => {
return (item.parseMethods || []).map((method) => ({ if (method === 'disable') return 'local'
label: methodLabels[method] || method, const current = ocrHealthStatus.value?.[method]
value: method if (ocrHealthChecking.value && (!current || current.status === 'unknown')) return 'checking'
})) return current?.status || 'unknown'
} }
const getMethodStatusLabel = (method) => methodStatusLabels[getMethodStatus(method)] || '状态未知'
const getMethodDescription = (method) => {
if (method === 'disable') return '使用文件内置文本层,不调用 OCR 服务'
const messageText = ocrHealthStatus.value?.[method]?.message
if (messageText) return messageText
const status = getMethodStatus(method)
const fallbackMap = {
healthy: '服务正常',
unavailable: '服务不可用',
unhealthy: '服务异常',
timeout: '服务检查超时',
error: '服务异常',
checking: '正在检查服务状态',
unknown: '服务状态未知'
}
return fallbackMap[status] || '服务状态未知'
}
const isUnavailableParseMethod = (method) =>
['unavailable', 'error'].includes(getMethodStatus(method))
const getAvailableParseMethods = (item) =>
(item.parseMethods || []).filter((method) => !isUnavailableParseMethod(method))
const getUnavailableParseMethods = (item) =>
(item.parseMethods || []).filter((method) => isUnavailableParseMethod(method))
const isParseDisabled = (item) =>
item.status === 'parsing' ||
!item.selectedParseMethod ||
confirming.value ||
isUnavailableParseMethod(item.selectedParseMethod)
const clearParsedState = { const clearParsedState = {
parsedObjectName: null, parsedObjectName: null,
parsedMinioUrl: null, parsedMinioUrl: null,
@ -187,6 +354,13 @@ const handleParseMethodChange = (localId, selectedParseMethod) => {
}) })
} }
const handleStartParse = (localId) => {
const item = fileItems.value.find((entry) => entry.localId === localId)
if (!item || isParseDisabled(item)) return
updateItem(localId, { parsePanelOpen: false })
void handleParse(item)
}
const handleParse = async (item) => { const handleParse = async (item) => {
if (!item.objectName || !item.selectedParseMethod) return if (!item.objectName || !item.selectedParseMethod) return
@ -223,6 +397,18 @@ const removeItem = (localId) => {
fileItems.value = fileItems.value.filter((item) => item.localId !== localId) fileItems.value = fileItems.value.filter((item) => item.localId !== localId)
} }
const toggleUnavailableParseMethods = (localId) => {
const item = fileItems.value.find((entry) => entry.localId === localId)
updateItem(localId, { unavailableMethodsExpanded: !item?.unavailableMethodsExpanded })
}
const handleParsePanelOpenChange = (localId, open) => {
updateItem(localId, { parsePanelOpen: open })
if (open) {
void checkOcrHealth()
}
}
const handleConfirm = async () => { const handleConfirm = async () => {
if (confirmDisabled.value) return if (confirmDisabled.value) return
@ -290,7 +476,7 @@ const formatFileSize = (size) => {
<style lang="less" scoped> <style lang="less" scoped>
.attachment-dropzone { .attachment-dropzone {
margin-bottom: 12px; margin-bottom: 0;
} }
.dropzone-title { .dropzone-title {
@ -309,23 +495,35 @@ const formatFileSize = (size) => {
.attachment-list { .attachment-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 10px;
max-height: 360px; max-height: 360px;
margin-top: 16px;
overflow: auto; overflow: auto;
} }
.attachment-item { .attachment-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between;
gap: 12px; gap: 12px;
padding: 10px; padding: 12px;
border: 1px solid var(--gray-100); border: 1px solid var(--gray-100);
border-radius: 8px; border-radius: 10px;
background: var(--gray-50); background: var(--gray-50);
} }
.attachment-item-main { .attachment-file-icon {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--gray-0);
font-size: 18px;
}
.attachment-item-content {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
@ -338,6 +536,7 @@ const formatFileSize = (size) => {
} }
.attachment-name { .attachment-name {
flex: 1;
overflow: hidden; overflow: hidden;
color: var(--gray-900); color: var(--gray-900);
font-size: 13px; font-size: 13px;
@ -346,32 +545,154 @@ const formatFileSize = (size) => {
white-space: nowrap; white-space: nowrap;
} }
.attachment-meta { .remove-btn {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: var(--gray-500);
}
.remove-btn:hover {
color: var(--color-error-500);
background: var(--color-error-50);
}
.attachment-status-row {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
justify-content: space-between;
gap: 8px; gap: 8px;
margin-top: 4px; margin-top: 6px;
color: var(--gray-500); color: var(--gray-500);
font-size: 12px; font-size: 12px;
} }
.attachment-status-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.attachment-error { .attachment-error {
color: var(--color-error-700); color: var(--color-error-700);
} }
.attachment-parsed { .parse-trigger-btn {
flex: none;
margin-left: auto;
}
.parse-panel {
display: flex;
flex-direction: column;
gap: 8px;
width: 260px;
}
.parse-method-option {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--gray-100);
border-radius: 8px;
background: var(--gray-0);
color: inherit;
cursor: pointer;
text-align: left;
}
.parse-method-option:hover:not(:disabled) {
border-color: var(--main-color);
background: color-mix(in srgb, var(--main-color) 6%, var(--gray-0));
}
.parse-method-option.selected {
border-color: var(--main-color);
background: color-mix(in srgb, var(--main-color) 8%, var(--gray-0));
}
.parse-method-option.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.unavailable-methods,
.unavailable-method-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.unavailable-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 2px;
border: none;
background: transparent;
color: var(--gray-500);
cursor: pointer;
font-size: 12px;
}
.unavailable-toggle:hover {
color: var(--gray-800);
}
.parse-method-option-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.parse-method-name {
color: var(--gray-900);
font-size: 13px;
font-weight: 500;
}
.parse-method-status {
flex: none;
font-size: 12px;
}
.parse-method-status.status-local,
.parse-method-status.status-healthy {
color: var(--color-success-700); color: var(--color-success-700);
} }
.attachment-actions { .parse-method-status.status-unavailable,
display: flex; .parse-method-status.status-error {
align-items: center; color: var(--color-error-700);
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
} }
.parse-select { .parse-method-status.status-unhealthy,
width: 150px; .parse-method-status.status-timeout,
.parse-method-status.status-unknown,
.parse-method-status.status-checking {
color: var(--color-warning-700);
}
.parse-method-desc {
color: var(--gray-500);
font-size: 12px;
line-height: 1.4;
}
.parse-start-btn {
margin-top: 2px;
}
:global(.attachment-parse-popover .ant-popover-inner-content) {
padding: 10px;
} }
</style> </style>

View File

@ -76,25 +76,76 @@
</a-tooltip> </a-tooltip>
</div> </div>
<div class="setting-content"> <div class="setting-content">
<a-select <a-popover
v-model:value="processingParams.ocr_engine" v-model:open="ocrPanelOpen"
:options="enableOcrOptions" placement="bottomLeft"
style="width: 100%" trigger="click"
class="ocr-select" overlayClassName="ocr-engine-popover"
@dropdownVisibleChange="handleOcrDropdownVisibleChange" @openChange="handleOcrPanelOpenChange"
/> >
<p class="param-description"> <template #content>
<template v-if="!isOcrEnabled"> 不启用 OCR仅处理文本文件 </template> <div class="ocr-engine-panel">
<template v-else-if="selectedOcrStatus === 'healthy'"> <button
{{ selectedOcrMessage || '服务正常' }} v-for="option in availableOcrOptions"
:key="option.value"
type="button"
class="ocr-engine-option"
:class="{ selected: processingParams.ocr_engine === option.value }"
:disabled="chunkLoading"
@click="selectOcrEngine(option.value)"
>
<span class="ocr-engine-option-header">
<span class="ocr-engine-name">{{ option.label }}</span>
<span
class="ocr-engine-status"
:class="`status-${getOcrStatus(option.value)}`"
>
{{ getOcrStatusLabel(option.value) }}
</span>
</span>
<span class="ocr-engine-desc">{{ getOcrDescription(option.value) }}</span>
</button>
<div v-if="unavailableOcrOptions.length" class="unavailable-ocr-options">
<button
type="button"
class="unavailable-toggle"
@click="toggleUnavailableOcrOptions"
>
<span>不可用选项{{ unavailableOcrOptions.length }}</span>
<ChevronUp v-if="unavailableOcrExpanded" :size="14" />
<ChevronDown v-else :size="14" />
</button>
<div v-if="unavailableOcrExpanded" class="unavailable-ocr-list">
<button
v-for="option in unavailableOcrOptions"
:key="option.value"
type="button"
class="ocr-engine-option disabled"
disabled
>
<span class="ocr-engine-option-header">
<span class="ocr-engine-name">{{ option.label }}</span>
<span
class="ocr-engine-status"
:class="`status-${getOcrStatus(option.value)}`"
>
{{ getOcrStatusLabel(option.value) }}
</span>
</span>
<span class="ocr-engine-desc">{{ getOcrDescription(option.value) }}</span>
</button>
</div>
</div>
</div>
</template> </template>
<template v-else-if="selectedOcrStatus === 'unknown'">
点击刷新图标检查服务状态 <a-button class="ocr-engine-trigger" block :loading="ocrHealthChecking">
</template> <span>{{ selectedOcrEngineLabel }}</span>
<template v-else> <ChevronDown :size="14" />
{{ selectedOcrMessage || '服务异常' }} </a-button>
</template> </a-popover>
</p>
</div> </div>
</div> </div>
</div> </div>
@ -206,7 +257,13 @@
<span class="workspace-current-path" :title="workspaceCurrentPath"> <span class="workspace-current-path" :title="workspaceCurrentPath">
{{ workspaceCurrentPath }} {{ workspaceCurrentPath }}
</span> </span>
<span>已选择 {{ selectedWorkspacePaths.length }} 个文件注意上传会扁平化上传不保留文件层级结构</span> <span
>已选择
{{
selectedWorkspacePaths.length
}}
个文件注意上传会扁平化上传不保留文件层级结构</span
>
</div> </div>
<div class="workspace-actions"> <div class="workspace-actions">
<a-button <a-button
@ -836,6 +893,8 @@ const ocrHealthStatus = ref({
// OCR // OCR
const ocrHealthChecking = ref(false) const ocrHealthChecking = ref(false)
const ocrPanelOpen = ref(false)
const unavailableOcrExpanded = ref(false)
// //
const processingParams = ref({ const processingParams = ref({
@ -911,105 +970,104 @@ const hasZipFiles = computed(() => {
}) })
}) })
// OCR const ocrEngineOptions = [
const enableOcrOptions = computed(() => [
{ {
value: 'disable', value: 'disable',
label: '不启用', label: '不启用',
title: '不启用' description: '不启用 OCR仅处理文本文件'
}, },
{ {
value: 'rapid_ocr', value: 'rapid_ocr',
label: getOcrLabel('rapid_ocr', 'RapidOCR (ONNX)'), label: 'RapidOCR (ONNX)',
title: 'ONNX with RapidOCR', description: 'ONNX with RapidOCR'
disabled:
ocrHealthStatus.value?.rapid_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.rapid_ocr?.status === 'error'
}, },
{ {
value: 'mineru_ocr', value: 'mineru_ocr',
label: getOcrLabel('mineru_ocr', 'MinerU OCR'), label: 'MinerU OCR',
title: 'MinerU OCR', description: 'MinerU OCR'
disabled:
ocrHealthStatus.value?.mineru_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.mineru_ocr?.status === 'error'
}, },
{ {
value: 'mineru_official', value: 'mineru_official',
label: getOcrLabel('mineru_official', 'MinerU Official API'), label: 'MinerU Official API',
title: 'MinerU Official API', description: 'MinerU Official API'
disabled:
ocrHealthStatus.value?.mineru_official?.status === 'unavailable' ||
ocrHealthStatus.value?.mineru_official?.status === 'error'
}, },
{ {
value: 'pp_structure_v3_ocr', value: 'pp_structure_v3_ocr',
label: getOcrLabel('pp_structure_v3_ocr', 'PP-Structure-V3'), label: 'PP-Structure-V3',
title: 'PP-Structure-V3', description: 'PP-Structure-V3'
disabled:
ocrHealthStatus.value?.pp_structure_v3_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.pp_structure_v3_ocr?.status === 'error'
}, },
{ {
value: 'deepseek_ocr', value: 'deepseek_ocr',
label: getOcrLabel('deepseek_ocr', 'DeepSeek OCR'), label: 'DeepSeek OCR',
title: 'DeepSeek OCR (SiliconFlow)', description: 'DeepSeek OCR (SiliconFlow)'
disabled:
ocrHealthStatus.value?.deepseek_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.deepseek_ocr?.status === 'error'
} }
]) ]
// OCR const ocrStatusLabels = {
const selectedOcrStatus = computed(() => { local: '不启用',
switch (processingParams.value.ocr_engine) { healthy: '可用',
case 'rapid_ocr': unavailable: '不可用',
return ocrHealthStatus.value?.rapid_ocr?.status || 'unknown' unhealthy: '异常',
case 'mineru_ocr': timeout: '超时',
return ocrHealthStatus.value?.mineru_ocr?.status || 'unknown' error: '异常',
case 'mineru_official': checking: '检查中',
return ocrHealthStatus.value?.mineru_official?.status || 'unknown' unknown: '状态未知'
case 'pp_structure_v3_ocr':
return ocrHealthStatus.value?.pp_structure_v3_ocr?.status || 'unknown'
case 'deepseek_ocr':
return ocrHealthStatus.value?.deepseek_ocr?.status || 'unknown'
default:
return null
}
})
// OCR
const selectedOcrMessage = computed(() => {
switch (processingParams.value.ocr_engine) {
case 'rapid_ocr':
return ocrHealthStatus.value?.rapid_ocr?.message || ''
case 'mineru_ocr':
return ocrHealthStatus.value?.mineru_ocr?.message || ''
case 'mineru_official':
return ocrHealthStatus.value?.mineru_official?.message || ''
case 'pp_structure_v3_ocr':
return ocrHealthStatus.value?.pp_structure_v3_ocr?.message || ''
case 'deepseek_ocr':
return ocrHealthStatus.value?.deepseek_ocr?.message || ''
default:
return ''
}
})
// OCR
const STATUS_ICONS = {
healthy: '✅',
unavailable: '❌',
unhealthy: '⚠️',
timeout: '⏰',
error: '⚠️',
unknown: '❓'
} }
// OCR const getOcrStatus = (engine) => {
const getOcrLabel = (serviceKey, displayName) => { if (engine === 'disable') return 'local'
const status = ocrHealthStatus.value?.[serviceKey]?.status || 'unknown' const current = ocrHealthStatus.value?.[engine]
return `${STATUS_ICONS[status] || '❓'} ${displayName}` if (ocrHealthChecking.value && (!current || current.status === 'unknown')) return 'checking'
return current?.status || 'unknown'
}
const getOcrStatusLabel = (engine) => ocrStatusLabels[getOcrStatus(engine)] || '状态未知'
const getOcrDescription = (engine) => {
const option = ocrEngineOptions.find((item) => item.value === engine)
if (engine === 'disable') return option?.description || '不启用 OCR仅处理文本文件'
const messageText = ocrHealthStatus.value?.[engine]?.message
if (messageText) return messageText
const status = getOcrStatus(engine)
const fallbackMap = {
healthy: '服务正常',
unavailable: '服务不可用',
unhealthy: '服务异常',
timeout: '服务检查超时',
error: '服务异常',
checking: '正在检查服务状态',
unknown: option?.description || '服务状态未知'
}
return fallbackMap[status] || option?.description || '服务状态未知'
}
const isUnavailableOcrEngine = (engine) => ['unavailable', 'error'].includes(getOcrStatus(engine))
const availableOcrOptions = computed(() =>
ocrEngineOptions.filter((option) => !isUnavailableOcrEngine(option.value))
)
const unavailableOcrOptions = computed(() =>
ocrEngineOptions.filter((option) => isUnavailableOcrEngine(option.value))
)
const selectedOcrEngineLabel = computed(() => {
return (
ocrEngineOptions.find((option) => option.value === processingParams.value.ocr_engine)?.label ||
'选择 OCR 引擎'
)
})
const selectOcrEngine = (engine) => {
if (isUnavailableOcrEngine(engine)) return
processingParams.value.ocr_engine = engine
ocrPanelOpen.value = false
}
const toggleUnavailableOcrOptions = () => {
unavailableOcrExpanded.value = !unavailableOcrExpanded.value
} }
// OCR // OCR
@ -1018,10 +1076,9 @@ const validateOcrService = () => {
return true return true
} }
const status = selectedOcrStatus.value const engine = processingParams.value.ocr_engine
if (status === 'unavailable' || status === 'error') { if (isUnavailableOcrEngine(engine)) {
const ocrMessage = selectedOcrMessage.value message.error(`OCR服务不可用: ${getOcrDescription(engine)}`)
message.error(`OCR服务不可用: ${ocrMessage}`)
return false return false
} }
@ -1320,11 +1377,11 @@ const checkOcrHealth = async () => {
} }
} }
const handleOcrDropdownVisibleChange = (open) => { const handleOcrPanelOpenChange = (open) => {
if (!open) { ocrPanelOpen.value = open
return if (open) {
checkOcrHealth()
} }
checkOcrHealth()
} }
const getAuthHeaders = () => { const getAuthHeaders = () => {
@ -1698,6 +1755,118 @@ const chunkData = async () => {
white-space: nowrap; white-space: nowrap;
} }
.ocr-engine-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.ocr-engine-panel {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
}
.ocr-engine-option {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--gray-100);
border-radius: 8px;
background: var(--gray-0);
color: inherit;
cursor: pointer;
text-align: left;
}
.ocr-engine-option:hover:not(:disabled) {
border-color: var(--main-color);
background: color-mix(in srgb, var(--main-color) 6%, var(--gray-0));
}
.ocr-engine-option.selected {
border-color: var(--main-color);
background: color-mix(in srgb, var(--main-color) 8%, var(--gray-0));
}
.ocr-engine-option.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.unavailable-ocr-options,
.unavailable-ocr-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.unavailable-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 2px;
border: none;
background: transparent;
color: var(--gray-500);
cursor: pointer;
font-size: 12px;
}
.unavailable-toggle:hover {
color: var(--gray-800);
}
.ocr-engine-option-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ocr-engine-name {
color: var(--gray-900);
font-size: 13px;
font-weight: 500;
}
.ocr-engine-status {
flex: none;
font-size: 12px;
}
.ocr-engine-status.status-local,
.ocr-engine-status.status-healthy {
color: var(--color-success-700);
}
.ocr-engine-status.status-unavailable,
.ocr-engine-status.status-error {
color: var(--color-error-700);
}
.ocr-engine-status.status-unhealthy,
.ocr-engine-status.status-timeout,
.ocr-engine-status.status-unknown,
.ocr-engine-status.status-checking {
color: var(--color-warning-700);
}
.ocr-engine-desc {
color: var(--gray-500);
font-size: 12px;
line-height: 1.4;
}
:global(.ocr-engine-popover .ant-popover-inner-content) {
padding: 10px;
}
.param-description { .param-description {
font-size: 12px; font-size: 12px;
color: var(--gray-400); color: var(--gray-400);