import { useRef, useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { X, Loader2, Upload, Plus, AlertCircle, Tag, Clock } from 'lucide-react' import { api, type ExtDataField } from '@/lib/api' import { QK } from '@/lib/queryKeys' export function CreateExtDialog({ onClose }: { onClose: () => void }) { const qc = useQueryClient() const [id, setId] = useState('') const [label, setLabel] = useState('') const [description, setDescription] = useState('') const [mode, setMode] = useState<'snapshot' | 'timeseries'>('snapshot') const [fields, setFields] = useState([]) const [error, setError] = useState('') const detectFileRef = useRef(null) const [detecting, setDetecting] = useState(false) const [dragOver, setDragOver] = useState(false) const [symbolMap, setSymbolMap] = useState>({}) const [codeMap, setCodeMap] = useState>({}) const [matchStatus, setMatchStatus] = useState<'none' | 'partial' | 'full'>('none') const [selectMapping, setSelectMapping] = useState<{ fields: { name: string; dtype: string; label: string }[]; need: 'symbol' | 'code' | 'both' } | null>(null) const create = useMutation({ mutationFn: () => { const userF = fields.filter((f) => f.name.trim() && f.name !== 'symbol' && f.name !== 'code') return api.extDataCreate({ id, label, mode, fields: [ { name: 'symbol', dtype: 'string', label: '标的代码' }, { name: 'code', dtype: 'string', label: '代码' }, ...userF, ], description: description.trim() || undefined, symbol_map: symbolMap, code_map: codeMap, }) }, onSuccess: () => { qc.invalidateQueries({ queryKey: QK.extData }) onClose() }, onError: (err) => setError(String(err)), }) const addField = () => setFields([...fields, { name: '', dtype: 'string', label: '' }]) const removeField = (i: number) => setFields(fields.filter((_, idx) => idx !== i)) const updateField = (i: number, key: keyof ExtDataField, val: string) => setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f))) const valid = id.trim() && label.trim() && fields.some((f) => f.name.trim()) && matchStatus !== 'none' const processDetection = ( detected: { name: string; dtype: string; label: string }[], symCands: string[], codeCands: string[], ) => { let sm: Record = {} let cm: Record = {} let status: 'none' | 'partial' | 'full' = 'none' if (symCands.length === 1 && codeCands.length === 1) { sm = { type: 'mapped', col: symCands[0] } cm = { type: 'mapped', col: codeCands[0] } status = 'full' } else if (symCands.length === 1 && codeCands.length === 0) { sm = { type: 'mapped', col: symCands[0] } cm = { type: 'computed', from: 'symbol', method: 'strip_exchange' } status = 'full' } else if (symCands.length === 0 && codeCands.length === 1) { cm = { type: 'mapped', col: codeCands[0] } sm = { type: 'computed', from: 'code', method: 'append_exchange' } status = 'full' } else if (symCands.length > 1 || codeCands.length > 1) { setSelectMapping({ fields: detected, need: symCands.length > 0 && codeCands.length > 0 ? 'both' : symCands.length > 0 ? 'symbol' : 'code', }) setFields(detected) return } else { setSelectMapping({ fields: detected, need: 'both' }) setFields(detected) return } setSymbolMap(sm) setCodeMap(cm) setMatchStatus(status) setFields(detected) setSelectMapping(null) } const handleDetectFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' setDetecting(true); setError(''); setSelectMapping(null) api.extDataDetectFields(file) .then((res) => { processDetection(res.fields, res.symbol_candidates, res.code_candidates) }) .catch((err) => setError(String(err))) .finally(() => setDetecting(false)) } const userFields = fields.filter(f => f.name !== 'symbol' && f.name !== 'code') return (

新增扩展数据

接入自有数据,与标的自动关联(第三方接口或CSV/Excel),支持概念、人气、资金流、舆情、研报评分标签等场景

数据类型
{(['snapshot', 'timeseries'] as const).map((m) => { const active = mode === m return ( ) })}
显示名称
setLabel(e.target.value)} placeholder={mode === 'snapshot' ? '例: 概念' : '例: 资金流'} className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow" />
标识符
setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} placeholder={mode === 'snapshot' ? '例: concept' : '例: money_flow'} className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground font-mono placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow" />
描述
setDescription(e.target.value)} placeholder="扩展数据 · 与标的 JOIN(可选自定义描述)" className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow" />
字段定义
{selectMapping && (
未自动识别到标的代码,请选择哪一列作为标的代码(symbol)
{selectMapping.fields.map(f => ( ))}
)} {userFields.length === 0 ? (
detectFileRef.current?.click()} onDragOver={e => { e.preventDefault(); setDragOver(true) }} onDragLeave={() => setDragOver(false)} onDrop={e => { e.preventDefault() setDragOver(false) const file = e.dataTransfer.files[0] if (file) { setDetecting(true); setError(''); setSelectMapping(null); api.extDataDetectFields(file).then(res => { processDetection(res.fields, res.symbol_candidates, res.code_candidates); }).catch(err => setError(String(err))).finally(() => setDetecting(false)) } }} className={`rounded-xl border-2 border-dashed py-6 flex flex-col items-center justify-center gap-2 cursor-pointer transition-colors ${ dragOver ? 'border-accent bg-accent/[0.06]' : detecting ? 'border-border/40 pointer-events-none' : 'border-border/30 hover:border-accent/40 hover:bg-accent/[0.02]' }`} > {detecting ? ( <>检测字段中… ) : ( <> 上传数据文件(CSV / Excel)自动识别数据格式 自动识别列名和类型,symbol 列自动匹配 )}
) : ( <>
标的代码 symbol 文本 {matchStatus !== 'none' ? {symbolMap.type === 'mapped' ? `← ${symbolMap.col}` : '← 计算'} : }
代码 code 文本 {matchStatus !== 'none' ? {codeMap.type === 'mapped' ? `← ${codeMap.col}` : codeMap.method === 'strip_exchange' ? '← symbol截取' : '← 推算'} : }
{userFields.map((f) => { const idx = fields.indexOf(f) return (
updateField(idx, 'label', e.target.value)} placeholder="显示名" className="w-[72px] h-7 px-2 rounded-md border border-border bg-base text-[11px] text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40" /> updateField(idx, 'name', e.target.value)} placeholder="字段名" className="flex-1 h-7 px-2 rounded-md border border-border bg-base text-[11px] font-mono text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40" />
) })}
)}
{error && (
{error}
)}
数据需自行维护和更新,系统不会自动同步
) }