feat(personal): show scanned PDF OCR progress

This commit is contained in:
2026-07-12 21:45:28 +08:00
parent 2e8961c75c
commit 4d393d1751
5 changed files with 64 additions and 5 deletions
+17 -5
View File
@@ -2,7 +2,7 @@
<view class="page employee-page-shell item-page">
<view class="subpage-header"><button class="subpage-back-button" @click="goBack">‹</button><view class="subpage-heading"><text class="page-title">资料详情</text><text class="page-subtitle">解析正文只读,状态来自服务端</text></view></view>
<view v-if="loading" class="panel empty">加载中…</view>
<view v-else-if="message && !item" class="panel"><text class="status-message">{{ message }}</text><button class="secondary-button full-button" @click="load">重试</button></view>
<view v-else-if="message && !item" class="panel"><text class="status-message">{{ message }}</text><button class="secondary-button full-button" @click="load()">重试</button></view>
<template v-else-if="item">
<view class="panel employee-card detail-card">
<view class="detail-head"><text class="source-badge">{{ sourceText(item.sourceType) }}</text><text class="status-badge" :class="item.status.toLowerCase()">{{ statusText(item.status) }}</text></view>
@@ -14,6 +14,12 @@
<view class="panel employee-card status-card">
<text class="panel-title">处理状态</text>
<text class="status-copy">{{ statusDescription(item.status) }}</text>
<view v-if="item.ocr?.required" class="ocr-progress">
<view class="ocr-progress-head"><text>{{ ocrProgressText(item.ocr) }}</text><text>{{ ocrProgressPercent(item.ocr) }}%</text></view>
<view class="ocr-progress-track"><view class="ocr-progress-value" :style="{ width: `${ocrProgressPercent(item.ocr)}%` }" /></view>
<text v-if="item.ocr.failedPageNumbers.length" class="ocr-failed-pages">识别失败页:{{ item.ocr.failedPageNumbers.join('、') }}</text>
<button v-if="item.ocr.failedPages > 0 && !isOcrActive(item.ocr)" class="secondary-button full-button" :disabled="busy" @click="retryOcrPages">{{ busy ? '重试中' : '重试失败页' }}</button>
</view>
<text v-if="item.errorMessage" class="status-message">{{ item.errorMessage }}</text>
<button v-if="item.status === 'FAILED'" class="primary-button full-button" :disabled="busy" @click="retry">{{ busy ? '重试中' : '重新解析' }}</button>
<button v-if="item.sourceType === 'FILE' && item.status === 'READY'" class="secondary-button full-button" :disabled="busy" @click="download">下载原附件</button>
@@ -31,17 +37,22 @@
<script setup lang="ts">
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { onLoad, onUnload } from '@dcloudio/uni-app';
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus } from '@/types/api';
import { deletePersonalItem, downloadPersonalItem, loadPersonalItem, retryPersonalItem } from '@/services/personal-assistant';
import { deletePersonalItem, downloadPersonalItem, loadPersonalItem, retryPersonalItem, retryPersonalOcrPages } from '@/services/personal-assistant';
import { normalizeBackendId } from '@/services/personal-assistant-id';
import { isOcrActive, ocrProgressPercent, ocrProgressText } from '@/services/personal-assistant-ocr';
const itemId = ref(''); const item = ref<PersonalItem>(); const loading = ref(false); const busy = ref(false); const message = ref('');
let pollTimer: ReturnType<typeof setTimeout> | undefined;
const statusText = (status: PersonalItemStatus) => ({ QUEUED: '等待处理', PARSING: '解析中', READY: '可检索', FAILED: '解析失败', DELETING: '删除中', DELETED: '已删除' })[status];
const statusDescription = (status: PersonalItemStatus) => ({ QUEUED: '已进入解析队列。', PARSING: '正在提取正文和建立搜索索引。', READY: '已完成解析,可用于个人助理问答。', FAILED: '本次解析失败,可查看公开原因后重试。', DELETING: '正在清理附件、正文和索引。', DELETED: '资料已删除。' })[status];
const sourceText = (source: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[source];
const load = async () => { if (!itemId.value) return; loading.value = true; message.value = ''; try { item.value = await loadPersonalItem(itemId.value); } catch (error) { message.value = error instanceof Error ? error.message : '资料不存在或无权访问'; } finally { loading.value = false; } };
const stopPolling = () => { if (pollTimer) clearTimeout(pollTimer); pollTimer = undefined; };
const schedulePolling = () => { stopPolling(); if (item.value && (['QUEUED', 'PARSING'].includes(item.value.status) || isOcrActive(item.value.ocr))) pollTimer = setTimeout(() => void load(true), 2000); };
const load = async (silent = false) => { if (!itemId.value) return; if (!silent) loading.value = true; message.value = ''; try { item.value = await loadPersonalItem(itemId.value); schedulePolling(); } catch (error) { message.value = error instanceof Error ? error.message : '资料不存在或无权访问'; stopPolling(); } finally { if (!silent) loading.value = false; } };
const retry = async () => { busy.value = true; message.value = ''; try { item.value = await retryPersonalItem(itemId.value); } catch (error) { message.value = error instanceof Error ? error.message : '重试失败'; } finally { busy.value = false; } };
const retryOcrPages = async () => { busy.value = true; message.value = ''; try { item.value = await retryPersonalOcrPages(itemId.value); schedulePolling(); } catch (error) { message.value = error instanceof Error ? error.message : '失败页重试失败'; } finally { busy.value = false; } };
const download = async () => { busy.value = true; try { const result = await downloadPersonalItem(itemId.value); if (!result.url) throw new Error('附件下载地址不可用'); if (typeof window !== 'undefined') window.open(result.url, '_blank', 'noopener,noreferrer'); else uni.downloadFile({ url: result.url }); } catch (error) { message.value = error instanceof Error ? error.message : '下载失败'; } finally { busy.value = false; } };
const confirmDelete = () => uni.showModal({ title: '确认删除资料', content: '附件、解析正文和搜索索引会清除,删除后无法恢复。', confirmText: '确认删除', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(); } });
const remove = async () => { busy.value = true; message.value = ''; try { await deletePersonalItem(itemId.value); uni.redirectTo({ url: '/pages/user/assistant/library' }); } catch (error) { message.value = error instanceof Error ? error.message : '删除失败,请重试'; } finally { busy.value = false; } };
@@ -54,8 +65,9 @@ onLoad((options) => {
message.value = '资料不存在或无权访问';
}
});
onUnload(stopPolling);
</script>
<style scoped>
.item-page { overflow-x: hidden; }.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.source-badge, .status-badge { padding: 4px 8px; border-radius: var(--radius); background: #eef3f9; color: var(--knowledge); font-size: 12px; }.status-badge.ready { color: var(--success); }.status-badge.failed, .danger-title { color: #b42318; }.item-title, .meta, .summary, .status-copy, .danger-copy { display: block; }.item-title { margin-top: 14px; font-size: 20px; font-weight: 900; }.meta { margin-top: 8px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }.status-copy, .summary, .danger-copy { margin-top: 10px; line-height: 1.65; }.summary { white-space: pre-wrap; }.full-button { width: 100%; margin-top: 12px; }.danger-panel { border-color: #f5c2c7; }.danger-button { color: #b42318; }
.item-page { overflow-x: hidden; }.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.source-badge, .status-badge { padding: 4px 8px; border-radius: var(--radius); background: #eef3f9; color: var(--knowledge); font-size: 12px; }.status-badge.ready { color: var(--success); }.status-badge.failed, .danger-title { color: #b42318; }.item-title, .meta, .summary, .status-copy, .danger-copy { display: block; }.item-title { margin-top: 14px; font-size: 20px; font-weight: 900; }.meta { margin-top: 8px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }.status-copy, .summary, .danger-copy { margin-top: 10px; line-height: 1.65; }.summary { white-space: pre-wrap; }.full-button { width: 100%; margin-top: 12px; }.ocr-progress { margin-top: 14px; }.ocr-progress-head { display: flex; justify-content: space-between; gap: 12px; color: #344054; font-size: 13px; }.ocr-progress-track { height: 8px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #eef1f5; }.ocr-progress-value { height: 100%; border-radius: 4px; background: #e70014; transition: width 180ms ease; }.ocr-failed-pages { display: block; margin-top: 8px; color: #b54708; font-size: 12px; }.danger-panel { border-color: #f5c2c7; }.danger-button { color: #b42318; }
</style>
@@ -0,0 +1,20 @@
import type { PersonalOcrProgress } from '@/types/api';
export const ocrProgressPercent = (ocr?: PersonalOcrProgress) => {
if (!ocr?.required || ocr.totalPages <= 0) return 0;
return Math.min(100, Math.max(0, Math.round((ocr.processedPages / ocr.totalPages) * 100)));
};
export const ocrProgressText = (ocr?: PersonalOcrProgress) => {
if (!ocr?.required) return '';
if (ocr.processedPages < ocr.totalPages && ['PENDING', 'RUNNING', 'RETRY'].includes(ocr.status)) {
return `正在识别扫描 PDF:${ocr.processedPages}/${ocr.totalPages} 页`;
}
if (ocr.failedPages > 0 && ocr.successPages > 0) return `已收录,${ocr.failedPages} 页识别失败`;
if (ocr.failedPages > 0) return `${ocr.failedPages} 页识别失败`;
if (ocr.totalPages > 0) return `扫描 PDF 已识别:${ocr.successPages}/${ocr.totalPages} 页`;
return '正在准备扫描 PDF 识别';
};
export const isOcrActive = (ocr?: PersonalOcrProgress) =>
Boolean(ocr?.required && ['PENDING', 'RUNNING', 'RETRY'].includes(ocr.status));
@@ -134,6 +134,11 @@ export const retryPersonalItem = (id: string) => apiRequest<PersonalItem>({
method: 'POST'
});
export const retryPersonalOcrPages = (id: string) => apiRequest<PersonalItem>({
url: personalItemApiPath(id, '/ocr/retry-failed'),
method: 'POST'
});
export const deletePersonalItem = (id: string) => apiRequest<{ cleanupJobId: string }>({
url: personalItemApiPath(id),
method: 'DELETE',
+11
View File
@@ -161,6 +161,17 @@ export interface PersonalItem {
tags?: string[];
capturedAt?: string;
parsedAt?: string;
ocr?: PersonalOcrProgress;
}
export interface PersonalOcrProgress {
required: boolean;
status: string;
totalPages: number;
processedPages: number;
successPages: number;
failedPages: number;
failedPageNumbers: number[];
}
export interface PersonalPage<T> {
@@ -95,6 +95,17 @@ test('个人资料详情包含全部状态、重试、下载和明确删除提
assert.doesNotMatch(page, /Number\(options\?\.id|parseInt\(options\?\.id|Number\.isSafeInteger/);
});
test('扫描 PDF 详情展示识别进度、部分失败和失败页重试', async () => {
const ocr = await import('../src/services/personal-assistant-ocr.ts');
const page = await source('../src/pages/user/assistant/item.vue');
assert.equal(ocr.ocrProgressText({ required: true, status: 'RUNNING', totalPages: 86, processedPages: 20, successPages: 20, failedPages: 0, failedPageNumbers: [] }), '正在识别扫描 PDF:20/86 页');
assert.equal(ocr.ocrProgressText({ required: true, status: 'PARTIAL', totalPages: 8, processedPages: 8, successPages: 6, failedPages: 2, failedPageNumbers: [3, 7] }), '已收录,2 页识别失败');
assert.match(page, /retryPersonalOcrPages/);
assert.match(page, /识别失败页/);
assert.match(page, /ocrProgressPercent/);
assert.match(page, /setTimeout/);
});
test('会话页覆盖列表、详情和删除且不伪造会话', async () => {
const page = await source('../src/pages/user/assistant/sessions.vue');
assert.match(page, /loadPersonalSessions/);