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-for="item in fileItems" :key="item.localId" class="attachment-item">
<div class="attachment-item-main">
<div class="attachment-name-row">
<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 class="attachment-file-icon" :style="{ color: getFileIconColor(item.fileName) }">
<component :is="getFileIcon(item.fileName)" />
</div>
<div class="attachment-actions">
<template v-if="item.parseSupported && item.status !== 'uploading' && item.status !== 'error'">
<a-select
v-model:value="item.selectedParseMethod"
:options="getParseMethodOptions(item)"
size="small"
class="parse-select"
:disabled="item.status === 'parsing' || confirming"
@change="(value) => handleParseMethodChange(item.localId, value)"
/>
<div class="attachment-item-content">
<div class="attachment-name-row">
<span class="attachment-name" :title="item.fileName">{{ item.fileName }}</span>
<a-button
size="small"
:loading="item.status === 'parsing'"
:disabled="!item.selectedParseMethod || confirming"
@click="handleParse(item)"
type="text"
class="remove-btn"
:disabled="confirming"
@click="removeItem(item.localId)"
>
解析
<X :size="16" />
</a-button>
</template>
<a-button size="small" type="text" :disabled="confirming" @click="removeItem(item.localId)">
移除
</a-button>
</div>
<div class="attachment-status-row">
<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>
@ -66,7 +149,10 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { ChevronDown, ChevronUp, X } from 'lucide-vue-next'
import { threadApi } from '@/apis'
import { ocrApi } from '@/apis/system_api'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
const props = defineProps({
open: { type: Boolean, default: false },
@ -89,7 +175,34 @@ const methodLabels = {
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(() =>
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
}
const getDefaultParseMethod = (methods) => {
if (!Array.isArray(methods) || methods.length === 0) return null
return methods.includes('disable') ? 'disable' : methods[0]
}
const getDefaultParseMethod = () => null
const normalizeTmpUpload = (response) => ({
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 localId = `${Date.now()}-${localIdSeed++}`
const item = {
@ -149,7 +276,11 @@ const uploadFile = async (file) => {
try {
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) {
updateItem(localId, {
status: 'error',
@ -163,13 +294,49 @@ const handleBeforeUpload = (file) => {
return false
}
const getParseMethodOptions = (item) => {
return (item.parseMethods || []).map((method) => ({
label: methodLabels[method] || method,
value: method
}))
const getMethodStatus = (method) => {
if (method === 'disable') return 'local'
const current = ocrHealthStatus.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 = {
parsedObjectName: 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) => {
if (!item.objectName || !item.selectedParseMethod) return
@ -223,6 +397,18 @@ const removeItem = (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 () => {
if (confirmDisabled.value) return
@ -290,7 +476,7 @@ const formatFileSize = (size) => {
<style lang="less" scoped>
.attachment-dropzone {
margin-bottom: 12px;
margin-bottom: 0;
}
.dropzone-title {
@ -309,23 +495,35 @@ const formatFileSize = (size) => {
.attachment-list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
max-height: 360px;
margin-top: 16px;
overflow: auto;
}
.attachment-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px;
padding: 12px;
border: 1px solid var(--gray-100);
border-radius: 8px;
border-radius: 10px;
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;
flex: 1;
}
@ -338,6 +536,7 @@ const formatFileSize = (size) => {
}
.attachment-name {
flex: 1;
overflow: hidden;
color: var(--gray-900);
font-size: 13px;
@ -346,32 +545,154 @@ const formatFileSize = (size) => {
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;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
margin-top: 6px;
color: var(--gray-500);
font-size: 12px;
}
.attachment-status-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.attachment-error {
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);
}
.attachment-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
.parse-method-status.status-unavailable,
.parse-method-status.status-error {
color: var(--color-error-700);
}
.parse-select {
width: 150px;
.parse-method-status.status-unhealthy,
.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>

View File

@ -76,25 +76,76 @@
</a-tooltip>
</div>
<div class="setting-content">
<a-select
v-model:value="processingParams.ocr_engine"
:options="enableOcrOptions"
style="width: 100%"
class="ocr-select"
@dropdownVisibleChange="handleOcrDropdownVisibleChange"
/>
<p class="param-description">
<template v-if="!isOcrEnabled"> 不启用 OCR仅处理文本文件 </template>
<template v-else-if="selectedOcrStatus === 'healthy'">
{{ selectedOcrMessage || '服务正常' }}
<a-popover
v-model:open="ocrPanelOpen"
placement="bottomLeft"
trigger="click"
overlayClassName="ocr-engine-popover"
@openChange="handleOcrPanelOpenChange"
>
<template #content>
<div class="ocr-engine-panel">
<button
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 v-else-if="selectedOcrStatus === 'unknown'">
点击刷新图标检查服务状态
</template>
<template v-else>
{{ selectedOcrMessage || '服务异常' }}
</template>
</p>
<a-button class="ocr-engine-trigger" block :loading="ocrHealthChecking">
<span>{{ selectedOcrEngineLabel }}</span>
<ChevronDown :size="14" />
</a-button>
</a-popover>
</div>
</div>
</div>
@ -206,7 +257,13 @@
<span class="workspace-current-path" :title="workspaceCurrentPath">
{{ workspaceCurrentPath }}
</span>
<span>已选择 {{ selectedWorkspacePaths.length }} 个文件注意上传会扁平化上传不保留文件层级结构</span>
<span
>已选择
{{
selectedWorkspacePaths.length
}}
个文件注意上传会扁平化上传不保留文件层级结构</span
>
</div>
<div class="workspace-actions">
<a-button
@ -836,6 +893,8 @@ const ocrHealthStatus = ref({
// OCR
const ocrHealthChecking = ref(false)
const ocrPanelOpen = ref(false)
const unavailableOcrExpanded = ref(false)
//
const processingParams = ref({
@ -911,105 +970,104 @@ const hasZipFiles = computed(() => {
})
})
// OCR
const enableOcrOptions = computed(() => [
const ocrEngineOptions = [
{
value: 'disable',
label: '不启用',
title: '不启用'
description: '不启用 OCR仅处理文本文件'
},
{
value: 'rapid_ocr',
label: getOcrLabel('rapid_ocr', 'RapidOCR (ONNX)'),
title: 'ONNX with RapidOCR',
disabled:
ocrHealthStatus.value?.rapid_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.rapid_ocr?.status === 'error'
label: 'RapidOCR (ONNX)',
description: 'ONNX with RapidOCR'
},
{
value: 'mineru_ocr',
label: getOcrLabel('mineru_ocr', 'MinerU OCR'),
title: 'MinerU OCR',
disabled:
ocrHealthStatus.value?.mineru_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.mineru_ocr?.status === 'error'
label: 'MinerU OCR',
description: 'MinerU OCR'
},
{
value: 'mineru_official',
label: getOcrLabel('mineru_official', 'MinerU Official API'),
title: 'MinerU Official API',
disabled:
ocrHealthStatus.value?.mineru_official?.status === 'unavailable' ||
ocrHealthStatus.value?.mineru_official?.status === 'error'
label: 'MinerU Official API',
description: 'MinerU Official API'
},
{
value: 'pp_structure_v3_ocr',
label: getOcrLabel('pp_structure_v3_ocr', 'PP-Structure-V3'),
title: 'PP-Structure-V3',
disabled:
ocrHealthStatus.value?.pp_structure_v3_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.pp_structure_v3_ocr?.status === 'error'
label: 'PP-Structure-V3',
description: 'PP-Structure-V3'
},
{
value: 'deepseek_ocr',
label: getOcrLabel('deepseek_ocr', 'DeepSeek OCR'),
title: 'DeepSeek OCR (SiliconFlow)',
disabled:
ocrHealthStatus.value?.deepseek_ocr?.status === 'unavailable' ||
ocrHealthStatus.value?.deepseek_ocr?.status === 'error'
label: 'DeepSeek OCR',
description: 'DeepSeek OCR (SiliconFlow)'
}
])
]
// OCR
const selectedOcrStatus = computed(() => {
switch (processingParams.value.ocr_engine) {
case 'rapid_ocr':
return ocrHealthStatus.value?.rapid_ocr?.status || 'unknown'
case 'mineru_ocr':
return ocrHealthStatus.value?.mineru_ocr?.status || 'unknown'
case 'mineru_official':
return ocrHealthStatus.value?.mineru_official?.status || '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: '❓'
const ocrStatusLabels = {
local: '不启用',
healthy: '可用',
unavailable: '不可用',
unhealthy: '异常',
timeout: '超时',
error: '异常',
checking: '检查中',
unknown: '状态未知'
}
// OCR
const getOcrLabel = (serviceKey, displayName) => {
const status = ocrHealthStatus.value?.[serviceKey]?.status || 'unknown'
return `${STATUS_ICONS[status] || '❓'} ${displayName}`
const getOcrStatus = (engine) => {
if (engine === 'disable') return 'local'
const current = ocrHealthStatus.value?.[engine]
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
@ -1018,10 +1076,9 @@ const validateOcrService = () => {
return true
}
const status = selectedOcrStatus.value
if (status === 'unavailable' || status === 'error') {
const ocrMessage = selectedOcrMessage.value
message.error(`OCR服务不可用: ${ocrMessage}`)
const engine = processingParams.value.ocr_engine
if (isUnavailableOcrEngine(engine)) {
message.error(`OCR服务不可用: ${getOcrDescription(engine)}`)
return false
}
@ -1320,11 +1377,11 @@ const checkOcrHealth = async () => {
}
}
const handleOcrDropdownVisibleChange = (open) => {
if (!open) {
return
const handleOcrPanelOpenChange = (open) => {
ocrPanelOpen.value = open
if (open) {
checkOcrHealth()
}
checkOcrHealth()
}
const getAuthHeaders = () => {
@ -1698,6 +1755,118 @@ const chunkData = async () => {
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 {
font-size: 12px;
color: var(--gray-400);