mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(watchlist): batch import CSV / pasted codes into groups
自选页新增批量导入:支持 CSV/TXT 文件与粘贴证券代码两种来源,解析出 候选并在弹窗内确认后按 M:N 分组一次性写入(目标分组可多选或就地新建, 默认只勾新增标的,已在自选的可并入所选分组)。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,67 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ImagePlus, Loader2, Upload, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { FileText, ImagePlus, Keyboard, Loader2, Plus, Upload, X } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { api, type WatchlistGroupColor, type WatchlistImportCandidate } from '@/lib/api'
|
||||
import { api, type WatchlistGroup, type WatchlistGroupColor, type WatchlistImportCandidate, type WatchlistImportResult } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
|
||||
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
|
||||
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
|
||||
import {
|
||||
DEFAULT_WATCHLIST_GROUP_COLOR,
|
||||
WATCHLIST_GROUP_COLORS,
|
||||
resolveWatchlistGroupColor,
|
||||
} from '@/lib/watchlist-group-colors'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 页面当前所在分组,作为默认目标分组(null=未分组)。 */
|
||||
groupId?: string | null
|
||||
groupName?: string
|
||||
groupColor?: WatchlistGroupColor
|
||||
/** 分组列表与成员快照由页面持有并下发,避免弹窗重复拉取。 */
|
||||
groups: WatchlistGroup[]
|
||||
existingBySymbol: ReadonlyMap<string, string[]>
|
||||
}
|
||||
|
||||
/** 一次最多排队识别的图片数,避免误选大量文件拖垮小内存机器。 */
|
||||
const MAX_IMPORT_IMAGES = 10
|
||||
const NO_MATCH_MSG = '未能匹配证券主数据,已跳过'
|
||||
const DROP_ACCEPT =
|
||||
'image/jpeg,image/png,image/webp,image/bmp,image/gif,text/csv,text/plain,' +
|
||||
'.csv,.txt,.jpg,.jpeg,.png,.webp,.bmp,.gif'
|
||||
|
||||
interface RowState {
|
||||
eligible: boolean
|
||||
inWatchlist: boolean
|
||||
inAllSelected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 未分组目标(空数组)只收新增;选定分组时,同时属于全部所选分组的标的不再可加。
|
||||
* 其余已匹配标的可勾选(并入尚未属于的分组)。
|
||||
*/
|
||||
function rowState(
|
||||
sym: string | null,
|
||||
matched: boolean,
|
||||
membership: ReadonlyMap<string, string[]>,
|
||||
targetIds: string[],
|
||||
): RowState {
|
||||
if (!matched || !sym) return { eligible: false, inWatchlist: false, inAllSelected: false }
|
||||
const gids = membership.get(sym)
|
||||
const inWatchlist = gids !== undefined
|
||||
const inAllSelected = targetIds.length > 0
|
||||
&& gids !== undefined && targetIds.every(gid => gids.includes(gid))
|
||||
const eligible = targetIds.length === 0 ? !inWatchlist : !inAllSelected
|
||||
return { eligible, inWatchlist, inAllSelected }
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith('image/') || /\.(jpe?g|png|webp|bmp|gif)$/i.test(file.name)
|
||||
}
|
||||
|
||||
/** 按 code 合并多图 OCR 结果:优先保留已匹配项,已在自选取并集。 */
|
||||
function isCsvFile(file: File): boolean {
|
||||
return /\.(csv|txt)$/i.test(file.name)
|
||||
}
|
||||
|
||||
/** 多来源候选(多图 / 截图+文件混搭)按 code 合并,保留已匹配项。 */
|
||||
export function mergeImportCandidates(
|
||||
lists: WatchlistImportCandidate[][],
|
||||
): WatchlistImportCandidate[] {
|
||||
@@ -51,20 +90,102 @@ export function mergeImportCandidates(
|
||||
return [...byCode.values()]
|
||||
}
|
||||
|
||||
export function WatchlistImportDialog({ open, onClose, groupId, groupName, groupColor }: Props) {
|
||||
type Row = { c: WatchlistImportCandidate; state: RowState }
|
||||
|
||||
function Dropzone({
|
||||
busy,
|
||||
label,
|
||||
onPick,
|
||||
}: {
|
||||
busy: boolean
|
||||
label: string
|
||||
onPick: (list: FileList | File[] | null | undefined) => void
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={DROP_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
onPick(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
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"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
) : (
|
||||
<ImagePlus className="h-6 w-6 text-accent" />
|
||||
)}
|
||||
<span className="text-xs">{busy ? label : '点击选择或拖拽券商自选截图 / CSV / TXT'}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WatchlistImportDialog({
|
||||
open,
|
||||
onClose,
|
||||
groupId,
|
||||
groups,
|
||||
existingBySymbol,
|
||||
}: Props) {
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const genRef = useRef(0)
|
||||
const qc = useQueryClient()
|
||||
|
||||
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 [previewUrls, setPreviewUrls] = useState<string[]>([])
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
|
||||
const [installHint, setInstallHint] = useState('')
|
||||
const [sourceFile, setSourceFile] = useState('')
|
||||
const [pasteOpen, setPasteOpen] = useState(false)
|
||||
const [codesText, setCodesText] = useState('')
|
||||
const [showSkipped, setShowSkipped] = useState(false)
|
||||
const [targetGroupIds, setTargetGroupIds] = useState<string[]>([])
|
||||
const [newGroupOpen, setNewGroupOpen] = useState(false)
|
||||
const [newGroupName, setNewGroupName] = useState('')
|
||||
const [newGroupColor, setNewGroupColor] = useState<WatchlistGroupColor>(DEFAULT_WATCHLIST_GROUP_COLOR)
|
||||
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||
const batchAdd = useWatchlistBatchAdd()
|
||||
|
||||
const membership = existingBySymbol
|
||||
const groupNameById = useMemo(() => {
|
||||
const m = new Map<string, string>()
|
||||
for (const g of groups) m.set(g.id, g.name)
|
||||
return m
|
||||
}, [groups])
|
||||
|
||||
const { eligible, skipped } = useMemo(() => {
|
||||
const eligible: Row[] = []
|
||||
const skipped: Row[] = []
|
||||
for (const c of candidates) {
|
||||
const state = rowState(c.symbol, c.matched, membership, targetGroupIds)
|
||||
;(state.eligible ? eligible : skipped).push({ c, state })
|
||||
}
|
||||
return { eligible, skipped }
|
||||
}, [candidates, membership, targetGroupIds])
|
||||
const skippedCount = skipped.length
|
||||
const matchedCount = useMemo(
|
||||
() => candidates.filter(c => c.matched && c.symbol).length,
|
||||
[candidates],
|
||||
)
|
||||
|
||||
const abortInFlight = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
@@ -81,14 +202,17 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
|
||||
setProgress(null)
|
||||
setCandidates([])
|
||||
setSelected(new Set())
|
||||
setProvider('')
|
||||
setOcrAvailable(null)
|
||||
setInstallHint('')
|
||||
setPreviewUrls(prev => {
|
||||
revokePreviews(prev)
|
||||
return []
|
||||
})
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
setSourceFile('')
|
||||
setPasteOpen(false)
|
||||
setCodesText('')
|
||||
setShowSkipped(false)
|
||||
setNewGroupOpen(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupColor(DEFAULT_WATCHLIST_GROUP_COLOR)
|
||||
}, [abortInFlight, revokePreviews])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,100 +220,99 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
|
||||
reset()
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void api.watchlistOcrStatus().then(
|
||||
res => {
|
||||
if (cancelled) return
|
||||
setOcrAvailable(res.available)
|
||||
if (!res.available) setInstallHint(getOcrInstallHint())
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return
|
||||
setOcrAvailable(false)
|
||||
setInstallHint(getOcrInstallHint())
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
setTargetGroupIds(groupId ? [groupId] : [])
|
||||
}, [open, groupId, reset])
|
||||
|
||||
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')
|
||||
const defaultSelection = (list: WatchlistImportCandidate[]) => {
|
||||
const out = new Set<string>()
|
||||
for (const c of list) {
|
||||
if (c.matched && c.symbol && !membership.has(c.symbol)) out.add(c.symbol)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 切目标分组后,只摘掉不再可选的已勾标的(新增默认勾选在解析时一次性设定)。 */
|
||||
const changeTargetGroups = (next: string[]) => {
|
||||
setTargetGroupIds(next)
|
||||
const eligibleNow = new Set<string>()
|
||||
for (const c of candidates) {
|
||||
const sym = c.symbol
|
||||
if (sym && rowState(sym, c.matched, membership, next).eligible) eligibleNow.add(sym)
|
||||
}
|
||||
setSelected(prev => {
|
||||
let changed = false
|
||||
const kept = new Set<string>()
|
||||
for (const sym of prev) {
|
||||
if (eligibleNow.has(sym)) kept.add(sym)
|
||||
else changed = true
|
||||
}
|
||||
return changed ? kept : prev
|
||||
})
|
||||
}
|
||||
|
||||
const stage = async (run: (signal: AbortSignal) => Promise<WatchlistImportResult>) => {
|
||||
abortInFlight()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const gen = genRef.current
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await run(controller.signal)
|
||||
if (gen !== genRef.current) return
|
||||
setCandidates(res.candidates)
|
||||
setSelected(defaultSelection(res.candidates))
|
||||
if (res.candidates.length > 0 && res.matched_count === 0) toast(NO_MATCH_MSG, 'error')
|
||||
} catch {
|
||||
/* 请求错误已由 request 封装弹出 */
|
||||
} finally {
|
||||
if (gen === genRef.current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runRecognizeQueue = async (files: File[]) => {
|
||||
const images = files.filter(isImageFile)
|
||||
const queue = images.slice(0, MAX_IMPORT_IMAGES)
|
||||
if (images.length > MAX_IMPORT_IMAGES) {
|
||||
toast(`一次最多识别 ${MAX_IMPORT_IMAGES} 张,已取前 ${MAX_IMPORT_IMAGES} 张`, 'error')
|
||||
}
|
||||
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 = ''
|
||||
setSourceFile('')
|
||||
setShowSkipped(false)
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const gen = genRef.current
|
||||
const merged: WatchlistImportCandidate[][] = []
|
||||
let failed = 0
|
||||
let lastError = ''
|
||||
|
||||
setProgress({ done: 0, total: queue.length })
|
||||
setBusy(true)
|
||||
try {
|
||||
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)
|
||||
merged.push(res.candidates)
|
||||
} catch (err) {
|
||||
if (gen !== genRef.current) return
|
||||
if (controller.signal.aborted) return
|
||||
if (gen !== genRef.current || controller.signal.aborted) return
|
||||
failed += 1
|
||||
lastError = err instanceof Error ? err.message : ''
|
||||
}
|
||||
if (gen === genRef.current) {
|
||||
setProgress({ done: i + 1, total: queue.length })
|
||||
}
|
||||
setProgress({ done: i + 1, total: queue.length })
|
||||
}
|
||||
|
||||
if (gen !== genRef.current) return
|
||||
|
||||
const merged = mergeImportCandidates(mergedLists)
|
||||
setProvider(lastProvider)
|
||||
setCandidates(merged)
|
||||
const defaults = new Set(
|
||||
merged
|
||||
.filter(c => c.matched && c.symbol && !c.already_in_watchlist)
|
||||
.map(c => c.symbol!),
|
||||
)
|
||||
setSelected(defaults)
|
||||
|
||||
if (merged.length === 0) {
|
||||
const all = mergeImportCandidates(merged)
|
||||
setCandidates(all)
|
||||
setSelected(defaultSelection(all))
|
||||
if (all.length === 0) {
|
||||
toast(
|
||||
lastError
|
||||
|| (failed > 0
|
||||
? '识别失败或未识别到股票代码,请换更清晰的截图'
|
||||
: '未识别到股票代码,请换一张更清晰的自选列表截图'),
|
||||
|| (failed > 0 ? '识别失败或未识别到股票代码' : '未识别到股票代码,请换更清晰的截图'),
|
||||
'error',
|
||||
)
|
||||
} else if (merged.every(c => !c.matched)) {
|
||||
toast('识别到代码但未能匹配证券主数据', 'error')
|
||||
} else if (all.every(c => !c.matched)) {
|
||||
toast(NO_MATCH_MSG, 'error')
|
||||
} else if (failed > 0) {
|
||||
toast(`有 ${failed} 张识别失败,已合并其余结果`, 'error')
|
||||
}
|
||||
@@ -201,9 +324,69 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
|
||||
}
|
||||
}
|
||||
|
||||
const onPick = (list: FileList | File[] | null | undefined) => {
|
||||
const runCsvImport = async (file: File) => {
|
||||
setSourceFile(file.name)
|
||||
setPreviewUrls(prev => { revokePreviews(prev); return [] })
|
||||
setShowSkipped(false)
|
||||
await stage(signal => api.watchlistImportCsv(file, signal))
|
||||
}
|
||||
|
||||
const runCodesParse = async () => {
|
||||
setSourceFile('')
|
||||
setShowSkipped(false)
|
||||
await stage(signal => api.watchlistImportCodes(codesText.trim(), signal))
|
||||
}
|
||||
|
||||
const onSourcePick = async (list: FileList | File[] | null | undefined) => {
|
||||
if (!list || list.length === 0) return
|
||||
void runRecognizeQueue(Array.from(list))
|
||||
const files = Array.from(list)
|
||||
const images = files.filter(isImageFile)
|
||||
const csvs = files.filter(isCsvFile)
|
||||
if (csvs.length > 0) {
|
||||
if (csvs.length > 1 || images.length > 0) {
|
||||
toast('截图与 CSV 请分别导入', 'error')
|
||||
return
|
||||
}
|
||||
await runCsvImport(csvs[0])
|
||||
return
|
||||
}
|
||||
if (images.length > 0) {
|
||||
if (images.length < files.length) toast('已忽略非截图文件', 'error')
|
||||
await runRecognizeQueue(images)
|
||||
return
|
||||
}
|
||||
toast('请选择券商自选截图或 CSV / TXT 文件', 'error')
|
||||
}
|
||||
|
||||
const runCodes = async () => {
|
||||
if (!codesText.trim()) {
|
||||
toast('请粘贴或输入股票代码', 'error')
|
||||
return
|
||||
}
|
||||
await runCodesParse()
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
const name = newGroupName.trim()
|
||||
if (!name) return
|
||||
setCreatingGroup(true)
|
||||
try {
|
||||
const data = await api.watchlistGroupCreate(name, newGroupColor)
|
||||
// 服务端返回全量分组列表;侧栏/分组条与本弹窗共用 QK.watchlistGroups 缓存
|
||||
qc.setQueryData(QK.watchlistGroups, { groups: data.groups })
|
||||
changeTargetGroups(
|
||||
targetGroupIds.includes(data.group.id)
|
||||
? targetGroupIds
|
||||
: [...targetGroupIds, data.group.id],
|
||||
)
|
||||
setNewGroupOpen(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupColor(DEFAULT_WATCHLIST_GROUP_COLOR)
|
||||
} catch {
|
||||
/* 已由 request 弹出 */
|
||||
} finally {
|
||||
setCreatingGroup(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = (symbol: string) => {
|
||||
@@ -215,13 +398,23 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
|
||||
})
|
||||
}
|
||||
|
||||
const matched = candidates.filter(c => c.matched && c.symbol)
|
||||
const selectable = matched.filter(c => !c.already_in_watchlist)
|
||||
const allSelected = selectable.length > 0 && selectable.every(c => selected.has(c.symbol!))
|
||||
let allSelected = eligible.length > 0
|
||||
for (const row of eligible) {
|
||||
if (!selected.has(row.c.symbol!)) {
|
||||
allSelected = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) setSelected(new Set())
|
||||
else setSelected(new Set(selectable.map(c => c.symbol!)))
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const row of eligible) {
|
||||
if (allSelected) next.delete(row.c.symbol!)
|
||||
else next.add(row.c.symbol!)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const confirmAdd = async () => {
|
||||
@@ -230,25 +423,84 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
|
||||
toast('请至少选择一只股票', 'error')
|
||||
return
|
||||
}
|
||||
const newCount = symbols.filter(sym => !membership.has(sym)).length
|
||||
const mergedCount = symbols.length - newCount
|
||||
try {
|
||||
const data = await batchAdd.mutateAsync({ symbols, groupId })
|
||||
toast(`已添加 ${data.added} 只自选`, 'success')
|
||||
await batchAdd.mutateAsync({ symbols, groupIds: targetGroupIds })
|
||||
const names = targetGroupIds
|
||||
.map(id => groupNameById.get(id))
|
||||
.filter((n): n is string => !!n)
|
||||
.join('、')
|
||||
if (names) {
|
||||
toast(
|
||||
mergedCount > 0
|
||||
? `已导入 ${symbols.length} 只到「${names}」(新增 ${newCount},并入 ${mergedCount})`
|
||||
: `已导入 ${newCount} 只到「${names}」`,
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
toast(`已添加 ${newCount} 只自选`, 'success')
|
||||
}
|
||||
onClose()
|
||||
} catch {
|
||||
/* toast in request */
|
||||
/* 已由 request 弹出 */
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const ocrBlocked = ocrAvailable === false
|
||||
const progressLabel =
|
||||
progress && progress.total > 1
|
||||
? `识别中 ${progress.done}/${progress.total}…`
|
||||
: progress
|
||||
? '识别中…'
|
||||
: null
|
||||
const selectedGroupColor = resolveWatchlistGroupColor(groupColor)
|
||||
|
||||
const renderRow = ({ c, state }: Row) => {
|
||||
const sym = c.symbol
|
||||
const checked = !!sym && selected.has(sym)
|
||||
let status: ReactNode = null
|
||||
if (!c.matched || !sym) {
|
||||
status = <span className="text-[10px] text-warning/90">{NO_MATCH_MSG}</span>
|
||||
} else if (state.inAllSelected) {
|
||||
status = <span className="text-[10px] text-muted">已在所选分组</span>
|
||||
} else if (state.inWatchlist) {
|
||||
status = (
|
||||
<span className="text-[10px] text-muted">
|
||||
{targetGroupIds.length > 0 ? '已在自选 · 将并入所选分组' : '已在自选'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<li key={sym ?? c.code}>
|
||||
<label
|
||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm ${
|
||||
state.eligible ? 'cursor-pointer hover:bg-elevated/50' : 'opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!state.eligible}
|
||||
checked={checked}
|
||||
onChange={() => sym && toggle(sym)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{c.name || (c.matched && sym ? sym : '未匹配')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted tabular-nums shrink-0">
|
||||
{c.code}
|
||||
{sym ? ` · ${sym}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{status}
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -259,22 +511,11 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<div>
|
||||
<h2 id="watchlist-import-title" className="text-sm font-semibold text-foreground">
|
||||
从截图导入自选
|
||||
批量导入自选
|
||||
</h2>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-[11px] text-muted">
|
||||
{ocrBlocked
|
||||
? 'OCR 引擎不可用'
|
||||
: '可多选截图,将逐张识别并合并结果后确认添加'}
|
||||
{provider ? ` · ${provider}` : ''}
|
||||
</p>
|
||||
{groupName && (
|
||||
<span className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${selectedGroupColor.text} ${selectedGroupColor.border} ${selectedGroupColor.background}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${selectedGroupColor.dot}`} />
|
||||
{groupName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
截图 / CSV / TXT / 粘贴代码均支持,解析后按证券主数据匹配,可多选分组导入
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -287,147 +528,246 @@ export function WatchlistImportDialog({ open, onClose, groupId, groupName, group
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 overflow-y-auto flex-1 space-y-3">
|
||||
{ocrBlocked ? (
|
||||
<div className="rounded-btn border border-border bg-elevated/40 px-4 py-5 text-xs text-secondary leading-relaxed whitespace-pre-wrap">
|
||||
{installHint}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
{busy && progressLabel && (
|
||||
<p className="text-[11px] text-muted">{progressLabel}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Dropzone busy={busy} label={progressLabel ?? '解析中…'} onPick={(l) => void onSourcePick(l)} />
|
||||
{!pasteOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || ocrAvailable === null}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
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"
|
||||
onClick={() => setPasteOpen(true)}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-btn border border-dashed border-border bg-elevated/40 px-3 py-2 text-xs text-secondary hover:bg-elevated/70"
|
||||
>
|
||||
{busy || ocrAvailable === null ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-accent" />
|
||||
) : (
|
||||
<ImagePlus className="h-6 w-6 text-accent" />
|
||||
)}
|
||||
<span className="text-xs">
|
||||
{progressLabel
|
||||
?? (ocrAvailable === null ? '检查 OCR…' : '点击选择或拖拽截图(支持多选)')}
|
||||
</span>
|
||||
<Keyboard className="h-3.5 w-3.5 text-accent" />
|
||||
或粘贴证券代码
|
||||
</button>
|
||||
|
||||
{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"
|
||||
) : (
|
||||
<div className="space-y-2 rounded-btn border border-border bg-elevated/40 p-2.5">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={codesText}
|
||||
onChange={e => setCodesText(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void runCodes()
|
||||
}}
|
||||
placeholder={'示例:\n600519\n000001 平安银行\n515880 通信ETF国泰'}
|
||||
rows={4}
|
||||
className="w-full resize-y rounded-btn border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-muted focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted">空格 / 逗号 / 换行分隔,Ctrl/⌘+Enter 解析</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPasteOpen(false); setCodesText('') }}
|
||||
className="h-7 px-2 rounded-btn text-[11px] text-secondary hover:bg-elevated"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`预览 ${i + 1}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
收起
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !codesText.trim()}
|
||||
onClick={() => void runCodes()}
|
||||
className="h-7 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Keyboard className="h-3.5 w-3.5" />}
|
||||
解析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewUrls.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{previewUrls.map((url, i) => (
|
||||
<div
|
||||
key={url}
|
||||
className="shrink-0 w-16 h-16 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>
|
||||
)}
|
||||
|
||||
{sourceFile && (
|
||||
<div className="flex items-center gap-2 rounded-btn border border-border bg-elevated/40 px-3 py-2 text-xs text-secondary">
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate flex-1">{sourceFile}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-secondary">
|
||||
{candidates.length} 个代码 · 匹配 {matchedCount} · 可添加 {eligible.length}
|
||||
</span>
|
||||
{eligible.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-[11px] text-accent hover:underline shrink-0"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选可添加'}
|
||||
</button>
|
||||
) : matchedCount > 0 ? (
|
||||
<span className="text-[11px] text-muted shrink-0">所选目标分组已包含这些标的,无需导入</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{skippedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSkipped(v => !v)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted hover:text-secondary"
|
||||
>
|
||||
<span className="transition-transform" style={{ transform: showSkipped ? 'rotate(90deg)' : undefined }}>▸</span>
|
||||
已略过 {skippedCount} 个(已在自选/所选分组,或主数据未匹配)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary">
|
||||
识别 {candidates.length} 个代码 · 匹配 {matched.length} · 已选 {selected.size}
|
||||
</span>
|
||||
{selectable.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-[11px] text-accent hover:underline"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选可添加'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60 rounded-btn border border-border overflow-hidden">
|
||||
{candidates.map(c => {
|
||||
const key = c.symbol || c.code
|
||||
const disabled = !c.matched || !c.symbol || c.already_in_watchlist
|
||||
const checked = !!(c.symbol && selected.has(c.symbol))
|
||||
return (
|
||||
<li key={key}>
|
||||
<label
|
||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-elevated/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
onChange={() => c.symbol && toggle(c.symbol)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{c.name || (c.matched ? c.symbol : '未匹配')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted tabular-nums shrink-0">
|
||||
{c.code}
|
||||
{c.symbol ? ` · ${c.symbol}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{c.already_in_watchlist && (
|
||||
<span className="text-[10px] text-muted">已在自选</span>
|
||||
)}
|
||||
{!c.matched && (
|
||||
<span className="text-[10px] text-warning/90">主数据未找到,已跳过</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ul className="divide-y divide-border/60 rounded-btn border border-border overflow-hidden">
|
||||
{eligible.map(renderRow)}
|
||||
{showSkipped && skipped.map(renderRow)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-8 px-3 rounded-btn text-xs text-secondary hover:bg-elevated"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={ocrBlocked || selected.size === 0 || batchAdd.isPending || busy}
|
||||
onClick={() => void confirmAdd()}
|
||||
className="h-8 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
|
||||
>
|
||||
{batchAdd.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<div className="px-4 py-3 border-t border-border shrink-0 space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[11px] text-secondary pt-1.5 shrink-0">导入到分组</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeTargetGroups([])}
|
||||
aria-pressed={targetGroupIds.length === 0}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] transition-colors ${
|
||||
targetGroupIds.length === 0
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-border bg-elevated text-secondary hover:text-foreground'
|
||||
}`}
|
||||
title="不加到任何分组,仅新增标的到自选"
|
||||
>
|
||||
未分组
|
||||
</button>
|
||||
<span className="mx-1 h-3 w-px shrink-0 self-center bg-border/60" aria-hidden="true" />
|
||||
{groups.map(g => {
|
||||
const active = targetGroupIds.includes(g.id)
|
||||
const c = resolveWatchlistGroupColor(g.color)
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => changeTargetGroups(
|
||||
active
|
||||
? targetGroupIds.filter(id => id !== g.id)
|
||||
: [...targetGroupIds, g.id],
|
||||
)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] transition-colors ${
|
||||
active
|
||||
? `${c.border} ${c.background} ${c.text}`
|
||||
: 'border-border bg-elevated text-secondary hover:bg-elevated/80 hover:text-foreground'
|
||||
}`}
|
||||
title={`${active ? '移出' : '加入'}目标分组「${g.name}」`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${active ? c.dot : 'bg-border'}`} />
|
||||
{g.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{!newGroupOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewGroupOpen(true)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border bg-elevated/40 px-2 py-1 text-[11px] text-accent hover:bg-elevated/70"
|
||||
title="新建分组接收这批导入"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
新建
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{newGroupOpen && (
|
||||
<div className="rounded-btn border border-border bg-elevated/40 p-2 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
maxLength={24}
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') void createGroup()
|
||||
if (e.key === 'Escape') { setNewGroupOpen(false); setNewGroupName(''); setNewGroupColor(DEFAULT_WATCHLIST_GROUP_COLOR) }
|
||||
}}
|
||||
placeholder="新分组名称,Enter 创建"
|
||||
className="h-8 min-w-0 flex-1 rounded-btn border border-border bg-surface px-2 text-xs text-foreground placeholder:text-muted focus:border-accent/50 focus:outline-none"
|
||||
aria-label="新分组名称"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createGroup()}
|
||||
disabled={creatingGroup || !newGroupName.trim()}
|
||||
title="创建分组,并作为本次导入的目标分组"
|
||||
className="h-8 shrink-0 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
|
||||
>
|
||||
{creatingGroup ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<span className="text-[11px] text-secondary shrink-0">颜色</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{WATCHLIST_GROUP_COLORS.map(option => {
|
||||
const active = option.id === newGroupColor
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setNewGroupColor(option.id)}
|
||||
aria-pressed={active}
|
||||
aria-label={`颜色 ${option.label}`}
|
||||
title={option.label}
|
||||
className={`h-4 w-4 rounded-full transition-transform ${option.dot} ${active ? `ring-2 ${option.ring} scale-110` : 'hover:scale-110'}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
添加所选 ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-8 px-3 rounded-btn text-xs text-secondary hover:bg-elevated"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.size === 0 || batchAdd.isPending || busy}
|
||||
onClick={() => void confirmAdd()}
|
||||
className="h-8 px-3 rounded-btn text-xs inline-flex items-center gap-1.5 bg-accent text-white hover:bg-accent/90 disabled:opacity-40"
|
||||
>
|
||||
{batchAdd.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
导入所选 ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
+23
-2
@@ -2187,10 +2187,15 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ symbol, note, group_id: groupId ?? null }),
|
||||
}),
|
||||
watchlistBatchAdd: (symbols: string[], note = '', groupId?: string | null) =>
|
||||
watchlistBatchAdd: (symbols: string[], note = '', groupId?: string | null, groupIds?: string[]) =>
|
||||
request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ symbols, note, group_id: groupId ?? null }),
|
||||
body: JSON.stringify({
|
||||
symbols,
|
||||
note,
|
||||
group_id: groupId ?? null,
|
||||
group_ids: groupIds?.length ? groupIds : null,
|
||||
}),
|
||||
}),
|
||||
watchlistGroups: () =>
|
||||
request<{ groups: WatchlistGroup[] }>('/api/watchlist/groups'),
|
||||
@@ -2246,6 +2251,22 @@ export const api = {
|
||||
quiet,
|
||||
})
|
||||
},
|
||||
watchlistImportCsv: (file: File, signal?: AbortSignal, quiet = false) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return request<WatchlistImportResult>('/api/watchlist/import-csv', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
signal,
|
||||
quiet,
|
||||
})
|
||||
},
|
||||
watchlistImportCodes: (text: string, signal?: AbortSignal) =>
|
||||
request<WatchlistImportResult>('/api/watchlist/import-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
signal,
|
||||
}),
|
||||
watchlistRemove: (symbol: string) =>
|
||||
request<{ symbols: WatchlistEntry[] }>(
|
||||
`/api/watchlist/${encodeURIComponent(symbol)}`,
|
||||
|
||||
@@ -32,14 +32,15 @@ export function useUpdateQuoteInterval() {
|
||||
interface WatchlistBatchAddInput {
|
||||
symbols: string[]
|
||||
groupId?: string | null
|
||||
groupIds?: string[]
|
||||
}
|
||||
|
||||
/** 批量添加自选 — Screener / 截图导入共用 */
|
||||
export function useWatchlistBatchAdd() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ symbols, groupId }: WatchlistBatchAddInput) =>
|
||||
api.watchlistBatchAdd(symbols, '', groupId),
|
||||
mutationFn: ({ symbols, groupId, groupIds }: WatchlistBatchAddInput) =>
|
||||
api.watchlistBatchAdd(symbols, '', groupId, groupIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
// 前缀匹配: 实际 key 为 ['watchlist-enriched', extColumnsParam],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus, FolderPlus } from 'lucide-react'
|
||||
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, FileUp, FolderOpen, FolderMinus, FolderPlus } from 'lucide-react'
|
||||
import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type WatchlistGroupColor } from '@/lib/api'
|
||||
import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -32,7 +32,6 @@ import { ExtensionSlot } from '@/extensions/ExtensionSlot'
|
||||
|
||||
// 分时列开放排序 (StockDataTable 实例级白名单; 表头眼睛/刷新按钮已 stopPropagation)
|
||||
const INTRADAY_SORTABLE_KEYS = new Set(['intraday'])
|
||||
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
|
||||
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
|
||||
import { StockDataTable } from '@/components/stock-table/StockDataTable'
|
||||
import { VIRTUAL_LIST_THRESHOLD, useParentScroll } from '@/components/virtual-list/useParentScroll'
|
||||
@@ -681,8 +680,6 @@ export function Watchlist() {
|
||||
const g = (searchParams.get('group') as WatchlistGroupFilter | null) ?? 'all'
|
||||
setSelectedGroup(g)
|
||||
}, [searchParams])
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null)
|
||||
const [ocrInstallHint, setOcrInstallHint] = useState('')
|
||||
const columnsLoaded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -691,25 +688,6 @@ export function Watchlist() {
|
||||
loadColumnConfig().then(setColumns)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void api.watchlistOcrStatus().then(
|
||||
res => {
|
||||
if (cancelled) return
|
||||
setOcrAvailable(res.available)
|
||||
if (!res.available) setOcrInstallHint(getOcrInstallHint())
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return
|
||||
setOcrAvailable(false)
|
||||
setOcrInstallHint(getOcrInstallHint())
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleColumnsChange = useCallback((next: ColumnConfig[]) => {
|
||||
setColumns(next)
|
||||
saveColumnConfig(next)
|
||||
@@ -1115,9 +1093,6 @@ export function Watchlist() {
|
||||
if (selectedGroup === 'ungrouped') return rowsWithGroup.filter(row => row.group_ids.length === 0)
|
||||
return rowsWithGroup.filter(row => row.group_ids.includes(selectedGroup))
|
||||
}, [groupBySymbol, rows, selectedGroup])
|
||||
const activeGroup = activeGroupId
|
||||
? groups.find(group => group.id === activeGroupId)
|
||||
: undefined
|
||||
const watchlistContentLoading = list.isLoading || (allSymbols.length > 0 && enriched.isLoading)
|
||||
|
||||
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
|
||||
@@ -1432,19 +1407,12 @@ export function Watchlist() {
|
||||
memberPending={addGroupMember.isPending || removeGroupMember.isPending}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (ocrAvailable === false) return
|
||||
setImportOpen(true)
|
||||
}}
|
||||
disabled={ocrAvailable === false}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-elevated disabled:hover:text-secondary"
|
||||
title={
|
||||
ocrAvailable === false
|
||||
? ocrInstallHint || 'OCR 不可用,请先安装 Tesseract'
|
||||
: '从截图导入自选'
|
||||
}
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth"
|
||||
title="批量导入自选(截图 / CSV / 粘贴代码)"
|
||||
aria-label="批量导入自选"
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<FileUp className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
{/* 视图 */}
|
||||
@@ -1655,13 +1623,13 @@ export function Watchlist() {
|
||||
<EmptyState
|
||||
icon={Star}
|
||||
title="自选股为空"
|
||||
hint="点击右上角搜索添加标的,或点击图片图标从券商自选截图批量导入。"
|
||||
hint="点击右上角搜索添加标的,或用导入按钮从券商自选 CSV / 截图批量导入、粘贴代码。"
|
||||
/>
|
||||
) : rowsInSelectedGroup.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title="该分组暂无标的"
|
||||
hint="使用右上角搜索添加,或通过股票旁的分组按钮移入当前分组。"
|
||||
hint="使用右上角搜索添加、通过股票旁的分组按钮移入,或用导入弹窗把整批标的并入本组。"
|
||||
/>
|
||||
) : groupCardsOpen ? (
|
||||
<WatchlistGroupCards
|
||||
@@ -2025,8 +1993,8 @@ export function Watchlist() {
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
groupId={activeGroupId}
|
||||
groupName={activeGroup?.name}
|
||||
groupColor={activeGroup?.color}
|
||||
groups={groups}
|
||||
existingBySymbol={groupBySymbol}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user