复制 local 代码到 serve
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Settings, Tag, Upload, Code, RefreshCw, CheckCircle2, Loader2, Pencil, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
|
||||
import { api, type ExtDataConfig } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { SettingsModal } from '@/components/data/SettingsModal'
|
||||
import { ExtDataPullPanel } from './ExtDataPullPanel'
|
||||
import { ExtDataApiPanel } from './ExtDataApiPanel'
|
||||
|
||||
export function ExtDataStatCard({ config, onDelete, deleting, onEdit }: {
|
||||
config: ExtDataConfig
|
||||
onDelete: () => void
|
||||
deleting: boolean
|
||||
onEdit?: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<{ rows: number; date: string } | null>(null)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [ingestTab, setIngestTab] = useState<'file' | 'api' | 'pull'>('file')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [fieldsExpanded, setFieldsExpanded] = useState(false)
|
||||
const fieldsRef = useRef<HTMLDivElement>(null)
|
||||
const [fieldsOverflow, setFieldsOverflow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = fieldsRef.current
|
||||
if (!el) return
|
||||
const prev = el.style.maxHeight
|
||||
el.style.maxHeight = 'none'
|
||||
const full = el.scrollHeight
|
||||
el.style.maxHeight = prev
|
||||
setFieldsOverflow(full > 68)
|
||||
}, [config.fields])
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => api.extDataUpload(id, file),
|
||||
onMutate: () => { setUploading(true); setUploadResult(null) },
|
||||
onSuccess: (data) => {
|
||||
setUploadResult({ rows: data.rows, date: data.date })
|
||||
qc.invalidateQueries({ queryKey: QK.extData })
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
setTimeout(() => {
|
||||
setUploadResult(null)
|
||||
setSettingsOpen(false)
|
||||
}, 1500)
|
||||
},
|
||||
onSettled: () => setUploading(false),
|
||||
})
|
||||
|
||||
const doUpload = (file: File) => upload.mutate({ id: config.id, file })
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) doUpload(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) doUpload(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-card border flex flex-col transition-all duration-300 ${
|
||||
config.mode === 'snapshot'
|
||||
? 'border-blue-500/30 bg-blue-500/[0.03]'
|
||||
: 'border-amber-500/30 bg-amber-500/[0.03]'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||
<h3 className="text-sm font-medium text-foreground">{config.label}</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[10px] px-1.5 py-px rounded font-medium ${
|
||||
config.mode === 'snapshot'
|
||||
? 'bg-blue-500/10 text-blue-400'
|
||||
: 'bg-amber-500/10 text-amber-400'
|
||||
}`}>
|
||||
{config.mode === 'snapshot' ? '快照' : '时序'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(v => !v)}
|
||||
className="p-0.5 rounded hover:bg-elevated transition-colors text-secondary"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1.5 text-[10px] text-muted/70 leading-relaxed line-clamp-2">
|
||||
{config.description || `扩展数据 · ${config.mode === 'snapshot' ? '与标的 JOIN' : '与日K JOIN'}`}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-[81px] px-4 pb-2">
|
||||
<div className={`relative overflow-hidden transition-all duration-300 ${fieldsExpanded ? '' : 'h-[75px]'}`}>
|
||||
<div
|
||||
ref={fieldsRef}
|
||||
className="flex flex-wrap gap-1"
|
||||
>
|
||||
{config.fields
|
||||
.filter(f => f.name !== 'symbol' && f.name !== 'code')
|
||||
.map((f) => (
|
||||
<span key={f.name} className="inline-flex items-center gap-1 text-[10px] bg-elevated rounded px-1.5 py-0.5">
|
||||
<Tag className="h-2.5 w-2.5 text-muted" />
|
||||
<span className="text-secondary">{f.label || f.name}</span>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{fieldsOverflow && !fieldsExpanded && (
|
||||
<div
|
||||
className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-surface to-transparent cursor-pointer flex items-end justify-center"
|
||||
onClick={() => setFieldsExpanded(true)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3 text-muted" />
|
||||
</div>
|
||||
)}
|
||||
{fieldsExpanded && fieldsOverflow && (
|
||||
<button
|
||||
onClick={() => setFieldsExpanded(false)}
|
||||
className="w-full flex items-center justify-center pt-1 text-[10px] text-muted hover:text-secondary transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto px-4 pb-4 pt-2 border-t border-border/50">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-muted">标识</span>
|
||||
<span className="font-mono text-secondary">{config.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] mt-1">
|
||||
<span className="text-muted">最新</span>
|
||||
<span className="text-secondary">{config.latest_sync_date ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{settingsOpen && (
|
||||
<SettingsModal title={`${config.label} · 设置`} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="space-y-3">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => { setSettingsOpen(false); onEdit() }}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-border bg-elevated text-foreground text-xs font-medium hover:bg-border/30 transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
编辑配置
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
api.extDataFixSymbol(config.id).then((res) => {
|
||||
setUploadResult({ rows: res.fixed_files, date: '格式修复完成' })
|
||||
qc.invalidateQueries({ queryKey: QK.extData })
|
||||
setTimeout(() => setUploadResult(null), 3000)
|
||||
})
|
||||
}}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-border bg-elevated text-foreground text-xs font-medium hover:bg-border/30 transition-colors"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
修复代码格式
|
||||
</button>
|
||||
|
||||
<div className="flex gap-1 rounded-lg bg-elevated/60 p-0.5">
|
||||
<button
|
||||
onClick={() => setIngestTab('file')}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||
ingestTab === 'file' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-3 w-3" />文件
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIngestTab('api')}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||
ingestTab === 'api' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Code className="h-3 w-3" />推送
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIngestTab('pull')}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||
ingestTab === 'pull' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />拉取
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ingestTab === 'file' ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<div
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`relative cursor-pointer rounded-lg border-2 border-dashed transition-colors py-5 flex flex-col items-center justify-center gap-1.5 ${
|
||||
dragOver
|
||||
? 'border-accent bg-accent/10'
|
||||
: uploading
|
||||
? 'border-border/50 bg-elevated/30 pointer-events-none'
|
||||
: 'border-border/40 hover:border-accent/50 hover:bg-accent/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 text-accent animate-spin" />
|
||||
<span className="text-[11px] text-muted">上传中…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className={`h-5 w-5 ${dragOver ? 'text-accent' : 'text-muted'}`} />
|
||||
<span className={`text-[11px] ${dragOver ? 'text-accent' : 'text-secondary'}`}>
|
||||
拖拽文件到此处上传
|
||||
</span>
|
||||
<span className="text-[10px] text-muted">支持 CSV / Excel,需包含 symbol 列</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : ingestTab === 'api' ? (
|
||||
<ExtDataApiPanel config={config} copied={copied} setCopied={setCopied} />
|
||||
) : (
|
||||
<ExtDataPullPanel config={config} onSaved={() => qc.invalidateQueries({ queryKey: QK.extData })} />
|
||||
)}
|
||||
|
||||
{uploadResult && (
|
||||
<div className="text-[11px] text-green-400 bg-green-500/10 rounded-lg px-3 py-2 text-center flex items-center justify-center gap-1.5 font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />上传成功 · {uploadResult.rows} 行 · {uploadResult.date}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="w-full text-center text-[10px] text-danger/60 hover:text-danger transition-colors"
|
||||
>
|
||||
删除此扩展
|
||||
</button>
|
||||
</div>
|
||||
</SettingsModal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 删除二次确认弹窗 */}
|
||||
<AnimatePresence>
|
||||
{showDelete && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => !deleting && setShowDelete(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 h-10 w-10 rounded-full bg-danger/12 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1.5">确认删除「{config.label}」?</h3>
|
||||
<p className="text-xs text-secondary leading-relaxed">
|
||||
将<span className="text-danger font-medium">永久删除</span>该扩展数据配置及其全部数据,包括字段、已上传/拉取的所有记录。
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-danger/90">
|
||||
操作不可恢复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={() => setShowDelete(false)}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onDelete(); setShowDelete(false) }}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 rounded-btn bg-danger/90 text-base text-sm font-medium hover:bg-danger disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user