feat(ocr): 截图导入支持多选并串行识别合并

- 文件选择与拖拽支持一次选多张截图,前端按队列逐张调用
  import-image,同一时刻只跑一路 OCR,避免加重小内存机器峰值
- 多张识别结果按股票代码合并去重,优先保留已匹配项,已在自选标记取并集
- 展示多图缩略预览与「识别中 x/y」进度;一次最多 10 张
- request 增加 quiet 选项,队列内失败不逐条弹 toast,结束后统一提示
This commit is contained in:
CJohn
2026-07-26 12:51:55 +08:00
parent e526d25fd0
commit 3e150ff600
2 changed files with 154 additions and 38 deletions
+141 -32
View File
@@ -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<string, WatchlistImportCandidate>()
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<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
const genRef = useRef(0)
const [busy, setBusy] = useState(false)
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null)
const [provider, setProvider] = useState<string>('')
const [candidates, setCandidates] = useState<WatchlistImportCandidate[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewUrls, setPreviewUrls] = useState<string[]>([])
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(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 (
<Modal
@@ -166,7 +259,7 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
<p className="text-[11px] text-muted mt-0.5">
{ocrBlocked
? 'OCR 引擎不可用'
: '上传券商自选列表截图,识别代码后确认添加'}
: '可多选截图,将逐张识别并合并结果后确认添加'}
{provider ? ` · ${provider}` : ''}
</p>
</div>
@@ -190,9 +283,13 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
<input
ref={inputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/bmp,image/gif,.jpg,.jpeg,.png"
className="hidden"
onChange={e => onPick(e.target.files?.[0])}
onChange={e => {
onPick(e.target.files)
e.target.value = ''
}}
/>
<button
@@ -202,7 +299,7 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
onDrop={e => {
e.preventDefault()
onPick(e.dataTransfer.files?.[0])
onPick(e.dataTransfer.files)
}}
className="w-full flex flex-col items-center justify-center gap-2 rounded-btn border border-dashed border-border bg-elevated/40 hover:bg-elevated/70 px-4 py-6 text-secondary transition-colors disabled:opacity-50"
>
@@ -212,13 +309,25 @@ export function WatchlistImportDialog({ open, onClose }: Props) {
<ImagePlus className="h-6 w-6 text-accent" />
)}
<span className="text-xs">
{busy ? '识别中…' : ocrAvailable === null ? '检查 OCR…' : '点击选择或拖拽截图到此处'}
{progressLabel
?? (ocrAvailable === null ? '检查 OCR…' : '点击选择或拖拽截图(支持多选)')}
</span>
</button>
{previewUrl && (
<div className="rounded-btn overflow-hidden border border-border bg-black/40 max-h-40">
<img src={previewUrl} alt="预览" className="w-full h-full object-contain max-h-40" />
{previewUrls.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{previewUrls.map((url, i) => (
<div
key={url}
className="shrink-0 w-20 h-20 rounded-btn overflow-hidden border border-border bg-black/40"
>
<img
src={url}
alt={`预览 ${i + 1}`}
className="w-full h-full object-contain"
/>
</div>
))}
</div>
)}
+13 -6
View File
@@ -7,13 +7,19 @@ import { toast } from '@/components/Toast'
const BASE = ''
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = init?.body instanceof FormData
type RequestOptions = RequestInit & {
/** 为 true 时不弹错误 toast(由调用方自行汇总提示,如多图串行队列) */
quiet?: boolean
}
async function request<T>(path: string, init?: RequestOptions): Promise<T> {
const { quiet, ...fetchInit } = init ?? {}
const isFormData = fetchInit.body instanceof FormData
const headers: Record<string, string> = {}
if (!isFormData) headers['Content-Type'] = 'application/json'
// 合并调用方传入的 headers (此前会被整体覆盖丢弃)
Object.assign(headers, init?.headers as Record<string, string> | undefined)
const res = await fetch(`${BASE}${path}`, { ...init, headers })
Object.assign(headers, fetchInit.headers as Record<string, string> | undefined)
const res = await fetch(`${BASE}${path}`, { ...fetchInit, headers })
if (!res.ok) {
let detail = ''
try {
@@ -30,7 +36,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} 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<T>
@@ -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<WatchlistImportResult>('/api/watchlist/import-image', {
method: 'POST',
body: fd,
signal,
quiet,
})
},
watchlistRemove: (symbol: string) =>