@@ -44,6 +44,7 @@ import {
|
||||
BookOpenCheck,
|
||||
ExternalLink,
|
||||
X,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { api, type IndexQuote } from '@/lib/api'
|
||||
@@ -78,6 +79,7 @@ const nav = [
|
||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||
{ to: '/trading', label: '交易', icon: Cable },
|
||||
{ to: '/data', label: '数据', icon: Database },
|
||||
{ to: '/sync', label: '数据同步', icon: Upload },
|
||||
] as const
|
||||
|
||||
function fmtIndexValue(v: number | null | undefined) {
|
||||
|
||||
@@ -1752,6 +1752,14 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ strategy_id: strategyId, code }),
|
||||
}),
|
||||
|
||||
// ===== Sync =====
|
||||
syncStatus: (): Promise<SyncStatusResponse> =>
|
||||
request('/api/data/sync/status'),
|
||||
syncConfig: (body: SyncConfigReq): Promise<{ ok: boolean }> =>
|
||||
request('/api/data/sync/config', { method: 'POST', body: JSON.stringify(body) }),
|
||||
syncPush: (parts: string[]): Promise<SyncPushResponse> =>
|
||||
request('/api/data/sync/push', { method: 'POST', body: JSON.stringify({ parts }) }),
|
||||
}
|
||||
|
||||
// ===== Pipeline =====
|
||||
@@ -1931,3 +1939,39 @@ export interface AnalysisMenu {
|
||||
updated_at?: string | null
|
||||
builtin?: boolean
|
||||
}
|
||||
|
||||
// ===== Sync =====
|
||||
export interface SyncPartInfo {
|
||||
file_count: number
|
||||
size_bytes: number
|
||||
last_modified: string | null
|
||||
}
|
||||
|
||||
export interface SyncStatusResponse {
|
||||
parts: Record<string, SyncPartInfo>
|
||||
config: {
|
||||
serve_url: string
|
||||
sync_key: string
|
||||
enable_auto: boolean
|
||||
interval_minutes: number
|
||||
}
|
||||
last_sync?: string | null
|
||||
}
|
||||
|
||||
export interface SyncPushResponse {
|
||||
ok: boolean
|
||||
parts_sent: string[]
|
||||
size_bytes: number
|
||||
serve_response: {
|
||||
ok: boolean
|
||||
parts: string[]
|
||||
file_count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SyncConfigReq {
|
||||
serve_url: string
|
||||
sync_key: string
|
||||
enable_auto?: boolean
|
||||
interval_minutes?: number
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Branding } from './pages/Branding'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { Indices } from './pages/Indices'
|
||||
import { Dev } from './pages/Dev'
|
||||
import { Sync } from './pages/Sync'
|
||||
import { useSettings } from './lib/useSharedQueries'
|
||||
import { Logo } from './components/Logo'
|
||||
|
||||
@@ -77,6 +78,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'data', element: <Data /> },
|
||||
{ path: 'monitor', element: <Monitor /> },
|
||||
{ path: 'trading', element: <Trading /> },
|
||||
{ path: 'sync', element: <Sync /> },
|
||||
{ path: 'limit-ladder', element: <LimitUpLadder /> },
|
||||
{ path: 'indices', element: <Indices /> },
|
||||
{ path: 'branding', element: <Branding /> },
|
||||
|
||||
Reference in New Issue
Block a user