mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(ocr): 截图导入支持多选并串行识别合并
- 文件选择与拖拽支持一次选多张截图,前端按队列逐张调用 import-image,同一时刻只跑一路 OCR,避免加重小内存机器峰值 - 多张识别结果按股票代码合并去重,优先保留已匹配项,已在自选标记取并集 - 展示多图缩略预览与「识别中 x/y」进度;一次最多 10 张 - request 增加 quiet 选项,队列内失败不逐条弹 toast,结束后统一提示
This commit is contained in:
@@ -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
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user