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) =>