feat(attachment): 改进附件解析功能,增加解析方法选择和状态显示
This commit is contained in:
parent
6438f8b7c2
commit
54bf9b61a8
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user