From 3e150ff600f613033afef10f9fc7c64ec2819699 Mon Sep 17 00:00:00 2001 From: CJohn Date: Sun, 26 Jul 2026 12:51:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(ocr):=20=E6=88=AA=E5=9B=BE=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E5=B9=B6=E4=B8=B2?= =?UTF-8?q?=E8=A1=8C=E8=AF=86=E5=88=AB=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件选择与拖拽支持一次选多张截图,前端按队列逐张调用 import-image,同一时刻只跑一路 OCR,避免加重小内存机器峰值 - 多张识别结果按股票代码合并去重,优先保留已匹配项,已在自选标记取并集 - 展示多图缩略预览与「识别中 x/y」进度;一次最多 10 张 - request 增加 quiet 选项,队列内失败不逐条弹 toast,结束后统一提示 --- .../src/components/WatchlistImportDialog.tsx | 173 ++++++++++++++---- frontend/src/lib/api.ts | 19 +- 2 files changed, 154 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/WatchlistImportDialog.tsx b/frontend/src/components/WatchlistImportDialog.tsx index cc49b90..1e15c71 100644 --- a/frontend/src/components/WatchlistImportDialog.tsx +++ b/frontend/src/components/WatchlistImportDialog.tsx @@ -11,15 +11,52 @@ interface Props { onClose: () => void } +/** 一次最多排队识别的图片数,避免误选大量文件拖垮小内存机器。 */ +const MAX_IMPORT_IMAGES = 10 + +function isImageFile(file: File): boolean { + return file.type.startsWith('image/') || /\.(jpe?g|png|webp|bmp|gif)$/i.test(file.name) +} + +/** 按 code 合并多图 OCR 结果:优先保留已匹配项,已在自选取并集。 */ +export function mergeImportCandidates( + lists: WatchlistImportCandidate[][], +): WatchlistImportCandidate[] { + const byCode = new Map() + for (const list of lists) { + for (const c of list) { + const prev = byCode.get(c.code) + if (!prev) { + byCode.set(c.code, c) + continue + } + if (c.matched && !prev.matched) { + byCode.set(c.code, c) + continue + } + if (c.matched && prev.matched) { + byCode.set(c.code, { + ...prev, + symbol: prev.symbol || c.symbol, + name: prev.name || c.name, + already_in_watchlist: prev.already_in_watchlist || c.already_in_watchlist, + }) + } + } + } + return [...byCode.values()] +} + export function WatchlistImportDialog({ open, onClose }: Props) { const inputRef = useRef(null) const abortRef = useRef(null) const genRef = useRef(0) const [busy, setBusy] = useState(false) + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) const [provider, setProvider] = useState('') const [candidates, setCandidates] = useState([]) const [selected, setSelected] = useState>(new Set()) - const [previewUrl, setPreviewUrl] = useState(null) + const [previewUrls, setPreviewUrls] = useState([]) const [ocrAvailable, setOcrAvailable] = useState(null) const [installHint, setInstallHint] = useState('') const batchAdd = useWatchlistBatchAdd() @@ -30,20 +67,25 @@ export function WatchlistImportDialog({ open, onClose }: Props) { genRef.current += 1 }, []) + const revokePreviews = useCallback((urls: string[]) => { + for (const url of urls) URL.revokeObjectURL(url) + }, []) + const reset = useCallback(() => { abortInFlight() setBusy(false) + setProgress(null) setCandidates([]) setSelected(new Set()) setProvider('') setOcrAvailable(null) setInstallHint('') - setPreviewUrl(prev => { - if (prev) URL.revokeObjectURL(prev) - return null + setPreviewUrls(prev => { + revokePreviews(prev) + return [] }) if (inputRef.current) inputRef.current.value = '' - }, [abortInFlight]) + }, [abortInFlight, revokePreviews]) useEffect(() => { if (!open) { @@ -68,51 +110,96 @@ export function WatchlistImportDialog({ open, onClose }: Props) { } }, [open]) // eslint-disable-line react-hooks/exhaustive-deps - const runRecognize = async (file: File) => { - if (!file.type.startsWith('image/') && !/\.(jpe?g|png|webp|bmp|gif)$/i.test(file.name)) { + const runRecognizeQueue = async (files: File[]) => { + const images = files.filter(isImageFile) + if (images.length === 0) { toast('请选择图片文件', 'error') return } + if (images.length < files.length) { + toast('已忽略非图片文件', 'error') + } + const queue = images.slice(0, MAX_IMPORT_IMAGES) + if (images.length > MAX_IMPORT_IMAGES) { + toast(`一次最多识别 ${MAX_IMPORT_IMAGES} 张,已取前 ${MAX_IMPORT_IMAGES} 张`, 'error') + } + abortInFlight() const controller = new AbortController() abortRef.current = controller const gen = genRef.current - setPreviewUrl(prev => { - if (prev) URL.revokeObjectURL(prev) - return URL.createObjectURL(file) + setPreviewUrls(prev => { + revokePreviews(prev) + return queue.map(f => URL.createObjectURL(f)) }) setBusy(true) + setProgress({ done: 0, total: queue.length }) setCandidates([]) setSelected(new Set()) + setProvider('') + + const mergedLists: WatchlistImportCandidate[][] = [] + let lastProvider = '' + let failed = 0 + let lastError = '' + try { - const res = await api.watchlistImportImage(file, controller.signal) + for (let i = 0; i < queue.length; i++) { + if (gen !== genRef.current || controller.signal.aborted) return + try { + // quiet:避免每张失败各弹一条 toast,结束时统一提示 + const res = await api.watchlistImportImage(queue[i], controller.signal, true) + if (gen !== genRef.current) return + lastProvider = res.provider + mergedLists.push(res.candidates) + } catch (err) { + if (gen !== genRef.current) return + if (controller.signal.aborted) return + failed += 1 + lastError = err instanceof Error ? err.message : '' + } + if (gen === genRef.current) { + setProgress({ done: i + 1, total: queue.length }) + } + } + if (gen !== genRef.current) return - setProvider(res.provider) - setCandidates(res.candidates) + + const merged = mergeImportCandidates(mergedLists) + setProvider(lastProvider) + setCandidates(merged) const defaults = new Set( - res.candidates + merged .filter(c => c.matched && c.symbol && !c.already_in_watchlist) .map(c => c.symbol!), ) setSelected(defaults) - if (res.candidates.length === 0) { - toast('未识别到股票代码,请换一张更清晰的自选列表截图', 'error') - } else if (res.matched_count === 0) { + + if (merged.length === 0) { + toast( + lastError + || (failed > 0 + ? '识别失败或未识别到股票代码,请换更清晰的截图' + : '未识别到股票代码,请换一张更清晰的自选列表截图'), + 'error', + ) + } else if (merged.every(c => !c.matched)) { toast('识别到代码但未能匹配证券主数据', 'error') + } else if (failed > 0) { + toast(`有 ${failed} 张识别失败,已合并其余结果`, 'error') } - } catch (err) { - if (gen !== genRef.current) return - if (controller.signal.aborted) return - /* toast already in request() for non-abort errors */ - void err } finally { - if (gen === genRef.current) setBusy(false) + if (gen === genRef.current) { + setBusy(false) + setProgress(null) + } } } - const onPick = (file: File | undefined | null) => { - if (file) void runRecognize(file) + const onPick = (list: FileList | File[] | null | undefined) => { + if (!list || list.length === 0) return + void runRecognizeQueue(Array.from(list)) } const toggle = (symbol: string) => { @@ -151,6 +238,12 @@ export function WatchlistImportDialog({ open, onClose }: Props) { if (!open) return null const ocrBlocked = ocrAvailable === false + const progressLabel = + progress && progress.total > 1 + ? `识别中 ${progress.done}/${progress.total}…` + : progress + ? '识别中…' + : null return ( {ocrBlocked ? 'OCR 引擎不可用' - : '上传券商自选列表截图,识别代码后确认添加'} + : '可多选截图,将逐张识别并合并结果后确认添加'} {provider ? ` · ${provider}` : ''}

@@ -190,9 +283,13 @@ export function WatchlistImportDialog({ open, onClose }: Props) { onPick(e.target.files?.[0])} + onChange={e => { + onPick(e.target.files) + e.target.value = '' + }} /> - {previewUrl && ( -
- 预览 + {previewUrls.length > 0 && ( +
+ {previewUrls.map((url, i) => ( +
+ {`预览 +
+ ))}
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b96bc5c..f594b60 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -7,13 +7,19 @@ import { toast } from '@/components/Toast' const BASE = '' -async function request(path: string, init?: RequestInit): Promise { - const isFormData = init?.body instanceof FormData +type RequestOptions = RequestInit & { + /** 为 true 时不弹错误 toast(由调用方自行汇总提示,如多图串行队列) */ + quiet?: boolean +} + +async function request(path: string, init?: RequestOptions): Promise { + const { quiet, ...fetchInit } = init ?? {} + const isFormData = fetchInit.body instanceof FormData const headers: Record = {} if (!isFormData) headers['Content-Type'] = 'application/json' // 合并调用方传入的 headers (此前会被整体覆盖丢弃) - Object.assign(headers, init?.headers as Record | undefined) - const res = await fetch(`${BASE}${path}`, { ...init, headers }) + Object.assign(headers, fetchInit.headers as Record | undefined) + const res = await fetch(`${BASE}${path}`, { ...fetchInit, headers }) if (!res.ok) { let detail = '' try { @@ -30,7 +36,7 @@ async function request(path: string, init?: RequestInit): Promise { } catch { /* ignore */ } const msg = detail || `${res.status} ${res.statusText}` // 401 (未登录/会话过期) 不弹 toast — 由全局认证拦截器统一跳登录页, 避免刷屏 - if (res.status !== 401) toast(msg, 'error') + if (res.status !== 401 && !quiet) toast(msg, 'error') throw new Error(msg) } return res.json() as Promise @@ -1380,13 +1386,14 @@ export const api = { }), watchlistOcrStatus: () => request<{ provider: string; available: boolean }>('/api/watchlist/ocr-status'), - watchlistImportImage: (file: File, signal?: AbortSignal) => { + watchlistImportImage: (file: File, signal?: AbortSignal, quiet = false) => { const fd = new FormData() fd.append('file', file) return request('/api/watchlist/import-image', { method: 'POST', body: fd, signal, + quiet, }) }, watchlistRemove: (symbol: string) =>