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-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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user