新增 local→serve 数据同步功能

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 10:27:00 +08:00
parent 1ae2102b4c
commit 274b90eef6
11 changed files with 706 additions and 5 deletions
+310
View File
@@ -0,0 +1,310 @@
import { useCallback, useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
CheckCircle2,
Cloud,
CloudOff,
Database,
HardDrive,
Loader2,
RefreshCw,
Save,
Settings,
Upload,
} from 'lucide-react'
import { toast } from '@/components/Toast'
import { PageHeader } from '@/components/PageHeader'
import { api, type SyncStatusResponse, type SyncPushResponse } from '@/lib/api'
import { cn } from '@/lib/cn'
type PartEntry = {
key: string
label: string
}
const ALL_PARTS: PartEntry[] = [
{ key: 'kline_daily', label: '日 K 线' },
{ key: 'kline_daily_enriched', label: '日 K 增强' },
{ key: 'kline_index_daily', label: '指数日 K' },
{ key: 'kline_index_enriched', label: '指数日 K 增强' },
{ key: 'kline_etf_daily', label: 'ETF 日 K' },
{ key: 'kline_etf_enriched', label: 'ETF 日 K 增强' },
{ key: 'kline_etf_minute', label: 'ETF 分钟 K' },
{ key: 'kline_minute', label: '分钟 K' },
{ key: 'adj_factor', label: '复权因子' },
{ key: 'adj_factor_etf', label: 'ETF 复权因子' },
{ key: 'instruments', label: '股票列表' },
{ key: 'instruments_index', label: '指数列表' },
{ key: 'instruments_etf', label: 'ETF 列表' },
{ key: 'financials', label: '财务数据' },
{ key: 'pools', label: '板块池' },
]
function fmtSize(bytes: number): string {
if (bytes === 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function Sync() {
const qc = useQueryClient()
const [showSettings, setShowSettings] = useState(false)
const [serveUrl, setServeUrl] = useState('')
const [syncKey, setSyncKey] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set(ALL_PARTS.map(p => p.key)))
// Status
const { data: status, isLoading, refetch } = useQuery<SyncStatusResponse>({
queryKey: ['sync-status'],
queryFn: api.syncStatus,
refetchInterval: 30000,
})
// Init form from server config
useEffect(() => {
if (status?.config) {
setServeUrl(status.config.serve_url)
setSyncKey(status.config.sync_key)
}
}, [status?.config])
// Save config
const saveCfg = useMutation<{ ok: boolean }, Error, void>({
mutationFn: () => api.syncConfig({ serve_url: serveUrl, sync_key: syncKey }),
onSuccess: () => {
toast('同步配置已保存', 'success')
qc.invalidateQueries({ queryKey: ['sync-status'] })
setShowSettings(false)
},
})
// Push sync
const push = useMutation<SyncPushResponse, Error, void>({
mutationFn: () => api.syncPush([...selected]),
onSuccess: (res) => {
if (res.ok) {
toast(
`同步完成: ${res.parts_sent.length} 个目录, ${res.serve_response.file_count} 个文件`,
'success',
)
qc.invalidateQueries({ queryKey: ['sync-status'] })
}
},
})
const togglePart = useCallback((key: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}, [])
const selectAll = () => setSelected(new Set(ALL_PARTS.map(p => p.key)))
const selectNone = () => setSelected(new Set())
return (
<div className="mx-auto max-w-5xl space-y-6 px-4 py-6">
<PageHeader
title="数据同步"
subtitle="将本地数据推送到 serve 服务器"
/>
{/* 连接配置 */}
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{status?.config.serve_url ? (
<Cloud className="h-5 w-5 text-accent" />
) : (
<CloudOff className="h-5 w-5 text-muted" />
)}
<div>
<div className="text-sm font-medium text-foreground">Serve </div>
<div className="text-xs text-muted">
{status?.config.serve_url
? status.config.serve_url
: '未配置 — 点击右侧设置按钮'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{status?.last_sync && (
<span className="text-[10px] text-muted">
: {new Date(status.last_sync).toLocaleString('zh-CN')}
</span>
)}
<button
onClick={() => { setShowSettings(v => !v); refetch() }}
className="rounded-btn p-2 text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="同步设置"
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={() => refetch()}
className="rounded-btn p-2 text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="刷新状态"
>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
</button>
</div>
</div>
{showSettings && (
<div className="mt-4 space-y-3 border-t border-border pt-4">
<div>
<label className="text-xs text-muted">Serve </label>
<input
value={serveUrl}
onChange={e => setServeUrl(e.target.value)}
placeholder="http://your-server:3018"
className="mt-1 w-full rounded border border-border bg-base px-3 py-2 text-sm text-foreground placeholder:text-muted focus:border-accent focus:outline-none"
/>
</div>
<div>
<label className="text-xs text-muted"> Key</label>
<input
value={syncKey}
onChange={e => setSyncKey(e.target.value)}
placeholder="留空则不校验"
type="password"
className="mt-1 w-full rounded border border-border bg-base px-3 py-2 text-sm text-foreground placeholder:text-muted focus:border-accent focus:outline-none"
/>
</div>
<button
onClick={() => saveCfg.mutate()}
disabled={saveCfg.isPending}
className="inline-flex items-center gap-2 rounded-btn bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
{saveCfg.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</button>
</div>
)}
</section>
{/* 数据选择 */}
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground"></h3>
<div className="flex items-center gap-2">
<button
onClick={selectAll}
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
</button>
<span className="text-xs text-muted">/</span>
<button
onClick={selectNone}
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{ALL_PARTS.map(({ key, label }) => {
const info = status?.parts?.[key]
const hasData = info && info.file_count > 0
return (
<label
key={key}
className={cn(
'flex items-center gap-3 rounded-lg border px-3 py-2.5 cursor-pointer transition-colors',
selected.has(key)
? 'border-accent/40 bg-accent/5'
: 'border-border bg-base hover:border-muted',
)}
>
<input
type="checkbox"
checked={selected.has(key)}
onChange={() => togglePart(key)}
className="h-4 w-4 rounded border-border text-accent focus:ring-accent"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm text-foreground">{label}</span>
{hasData && (
<HardDrive className="h-3 w-3 text-accent shrink-0" />
)}
</div>
{info && (
<div className="mt-0.5 text-[10px] text-muted">
{info.file_count > 0
? `${info.file_count} 文件 · ${fmtSize(info.size_bytes)}`
: '无数据'}
</div>
)}
</div>
</label>
)
})}
</div>
</section>
{/* 操作 */}
<section className="flex items-center justify-between rounded-card border border-border bg-surface p-5">
<div className="flex items-center gap-2 text-sm text-muted">
<Database className="h-4 w-4" />
<span>
{selected.size}
{push.data && (
<span className="ml-2 text-bull">
<CheckCircle2 className="inline h-3.5 w-3.5 mr-0.5" />
</span>
)}
</span>
</div>
<button
onClick={() => push.mutate()}
disabled={push.isPending || selected.size === 0}
className="inline-flex items-center gap-2 rounded-btn bg-accent px-5 py-2.5 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
>
{push.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
<>
<Upload className="h-4 w-4" />
</>
)}
</button>
</section>
{/* 同步日志 */}
{push.data && (
<section className="rounded-card border border-border bg-surface p-4">
<div className="text-xs text-muted font-mono leading-relaxed">
<div> </div>
<div> : {push.data.parts_sent.join(', ')}</div>
<div> : {fmtSize(push.data.size_bytes)}</div>
<div> : {push.data.serve_response.file_count}</div>
</div>
</section>
)}
{push.error && (
<section className="rounded-card border border-danger/30 bg-danger/5 p-4">
<div className="text-xs text-danger font-mono leading-relaxed">
<div> </div>
<div> {String(push.error)}</div>
</div>
</section>
)}
</div>
)
}