项目目录从 refer 迁移到 local

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-04 16:32:48 +08:00
parent 648a8b7f1c
commit aad34202f1
302 changed files with 0 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity, Loader2, Lock, RefreshCw, Search } from 'lucide-react'
import { api, type IndexInstrument, type KlineRow, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCapabilities } from '@/lib/useSharedQueries'
import { EChartsCandlestick, type OHLC } from '@/components/EChartsCandlestick'
import { EChartsIntraday } from '@/components/EChartsIntraday'
function defaultRange() {
const now = new Date()
const end = now.toISOString().slice(0, 10)
const s = new Date(now)
s.setMonth(s.getMonth() - 6)
return { start: s.toISOString().slice(0, 10), end }
}
function toOHLC(rows: KlineRow[]): OHLC[] {
return rows
.filter(r => r?.date != null && r.open != null && r.close != null)
.map(r => ({
date: typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date),
open: Number(r.open),
high: Number(r.high),
low: Number(r.low),
close: Number(r.close),
volume: Number(r.volume ?? 0),
ma5: r.ma5 != null ? Number(r.ma5) : null,
ma10: r.ma10 != null ? Number(r.ma10) : null,
ma20: r.ma20 != null ? Number(r.ma20) : null,
ma60: r.ma60 != null ? Number(r.ma60) : null,
macd_dif: r.macd_dif != null ? Number(r.macd_dif) : null,
macd_dea: r.macd_dea != null ? Number(r.macd_dea) : null,
macd_hist: r.macd_hist != null ? Number(r.macd_hist) : null,
rsi_6: r.rsi_6 != null ? Number(r.rsi_6) : null,
rsi_14: r.rsi_14 != null ? Number(r.rsi_14) : null,
rsi_24: r.rsi_24 != null ? Number(r.rsi_24) : null,
kdj_k: r.kdj_k != null ? Number(r.kdj_k) : null,
kdj_d: r.kdj_d != null ? Number(r.kdj_d) : null,
kdj_j: r.kdj_j != null ? Number(r.kdj_j) : null,
boll_upper: r.boll_upper != null ? Number(r.boll_upper) : null,
boll_lower: r.boll_lower != null ? Number(r.boll_lower) : null,
}))
}
function fmtPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v).toFixed(2)}%`
}
function fmtNum(v: number | null | undefined, digits = 2) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(digits)
}
const PINNED_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
]
function pinnedRank(item: IndexInstrument) {
return PINNED_INDEXES.findIndex(p => item.symbol === p.symbol || item.name === p.name)
}
export function Indices() {
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [keyword, setKeyword] = useState('')
const symbolParam = searchParams.get('symbol') ?? ''
const [selected, setSelected] = useState<string>(symbolParam)
const [range, setRange] = useState(defaultRange)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
// 分时数据需 Pro+ (kline.minute.batch) 能力
const caps = useCapabilities()
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
const list = useQuery({
queryKey: QK.indexList,
queryFn: api.indexList,
})
const search = useQuery({
queryKey: ['index-search', keyword],
queryFn: () => api.indexSearch(keyword, 50),
enabled: keyword.trim().length > 0,
})
const rows: IndexInstrument[] = keyword.trim()
? (search.data?.results ?? [])
: (list.data?.results ?? [])
const topRows = useMemo(() => {
const all = list.data?.results ?? []
return PINNED_INDEXES.map(p => (
all.find(item => item.symbol === p.symbol || item.name === p.name) ?? { symbol: p.symbol, name: p.name, asset_type: 'index' as const }
))
}, [list.data?.results])
const listRows = useMemo(() => rows.filter(item => pinnedRank(item) < 0), [rows])
const selectedSymbol = selected || topRows[0]?.symbol || listRows[0]?.symbol || ''
useEffect(() => {
if (symbolParam && symbolParam !== selected) setSelected(symbolParam)
}, [selected, symbolParam])
const selectIndex = (symbol: string) => {
setSelected(symbol)
setSearchParams({ symbol })
}
const quotes = useQuery({
queryKey: QK.indexQuotes,
queryFn: () => api.indexQuotes(),
placeholderData: (prev) => prev,
})
const daily = useQuery({
queryKey: QK.indexDaily(selectedSymbol, range.start, range.end),
queryFn: () => api.indexDaily(selectedSymbol, 180, range),
enabled: !!selectedSymbol,
placeholderData: (prev) => prev,
})
const minute = useQuery({
queryKey: QK.indexMinute(selectedSymbol, selectedDate ?? ''),
queryFn: () => api.indexMinute(selectedSymbol, selectedDate ?? undefined),
enabled: !!selectedSymbol && !!selectedDate && hasMinuteCap,
placeholderData: (prev) => prev,
})
const syncInstruments = useMutation({
mutationFn: api.syncIndexInstruments,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
},
})
const syncDaily = useMutation({
mutationFn: () => api.syncIndexDaily(365),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
const quoteBySymbol = useMemo(() => {
const m = new Map<string, any>()
for (const q of quotes.data?.rows ?? []) m.set(q.symbol, q)
return m
}, [quotes.data?.rows])
const selectedQuote = selectedSymbol ? quoteBySymbol.get(selectedSymbol) : null
const selectedQuoteValue = selectedQuote?.last_price ?? selectedQuote?.price ?? selectedQuote?.close
const selectedQuotePct = selectedQuote?.change_pct ?? selectedQuote?.pct
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
const selectedIdx = selectedDate ? chartRows.findIndex(r => r.date === selectedDate) : -1
const prevClose = selectedIdx > 0
? chartRows[selectedIdx - 1].close
: chartRows.length >= 2
? chartRows[chartRows.length - 2].close
: undefined
useEffect(() => {
setSelectedDate(null)
setLinkedPrice(null)
}, [selectedSymbol])
useEffect(() => {
if ((!selectedDate || !chartRows.some(r => r.date === selectedDate)) && chartRows.length > 0 && daily.data?.symbol === selectedSymbol) {
setSelectedDate(chartRows[chartRows.length - 1].date)
}
}, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol])
const renderIndexItem = (item: IndexInstrument) => {
const q = quoteBySymbol.get(item.symbol)
const pct = q?.change_pct ?? q?.pct
const current = q?.last_price ?? q?.price ?? q?.close
const active = item.symbol === selectedSymbol
return (
<button
key={item.symbol}
onClick={() => selectIndex(item.symbol)}
className={`w-full rounded-btn px-2 py-2 text-left transition-colors ${active ? 'bg-accent/15 text-foreground' : 'hover:bg-elevated text-secondary'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium">{item.name || item.symbol}</span>
<span className={`text-[10px] font-mono ${Number(pct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(pct)}</span>
</div>
<div className="mt-0.5 flex items-center justify-between text-[10px] font-mono text-muted">
<span>{item.symbol}</span>
<span>{fmtNum(current)}</span>
</div>
</button>
)
}
return (
<div className="h-full overflow-auto bg-base p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground"></h1>
<p className="mt-1 text-xs text-muted">
使 kline_index_* parquet
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => syncInstruments.mutate()}
disabled={syncInstruments.isPending}
className="inline-flex items-center gap-1.5 rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground disabled:opacity-50"
>
{syncInstruments.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => syncDaily.mutate()}
disabled={syncDaily.isPending}
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-base hover:bg-accent/90 disabled:opacity-50"
>
{syncDaily.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
K
</button>
</div>
</div>
<div className="grid grid-cols-[15rem_1fr] gap-4">
<aside className="rounded-card border border-border bg-surface p-3">
<div className="relative mb-3">
<Search className="pointer-events-none absolute left-2 top-2 h-3.5 w-3.5 text-muted" />
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索指数代码/名称"
className="w-full rounded-btn border border-border bg-base py-1.5 pl-7 pr-2 text-xs text-foreground outline-none focus:border-accent"
/>
</div>
<div className="mb-3 space-y-1 border-b border-border/60 pb-3">
{topRows.map(renderIndexItem)}
</div>
<div className="max-h-[calc(100vh-24rem)] space-y-1 overflow-auto pr-1">
{(list.isLoading || search.isLoading) && <div className="py-4 text-center text-xs text-muted"></div>}
{!list.isLoading && listRows.length === 0 && (
<div className="rounded-btn bg-elevated p-3 text-xs text-muted">
{keyword.trim() ? '无匹配指数。' : '暂无更多指数,先点击“同步指数列表”。'}
</div>
)}
{listRows.map(renderIndexItem)}
</div>
</aside>
<main className="min-w-0 rounded-card border border-border bg-surface p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-accent" />
<h2 className="truncate text-sm font-semibold text-foreground">
{selectedInfo?.name || selectedSymbol || '未选择指数'}
</h2>
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
{selectedSymbol && <span className="font-mono text-xs text-foreground">{fmtNum(selectedQuoteValue)}</span>}
{selectedSymbol && <span className={`font-mono text-xs ${Number(selectedQuotePct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(selectedQuotePct)}</span>}
</div>
<div className="mt-1 text-xs text-muted">
{quotes.data?.count ?? 0} · K来源 {daily.data?.source ?? '--'}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<input
type="date"
value={range.start}
onChange={e => setRange(r => ({ ...r, start: e.target.value }))}
className="rounded-btn border border-border bg-base px-2 py-1 text-secondary outline-none focus:border-accent"
/>
<span className="text-muted"></span>
<input
type="date"
value={range.end}
onChange={e => setRange(r => ({ ...r, end: e.target.value }))}
className="rounded-btn border border-border bg-base px-2 py-1 text-secondary outline-none focus:border-accent"
/>
</div>
</div>
{daily.isLoading && <div className="py-10 text-center text-sm text-muted">K加载中</div>}
{daily.isError && <div className="py-4 text-sm text-danger">K加载失败</div>}
{!daily.isLoading && !daily.isError && chartRows.length === 0 && (
<div className="rounded-card bg-elevated p-6 text-center text-sm text-muted">
K数据K
</div>
)}
{chartRows.length > 0 && (
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<EChartsCandlestick
data={chartRows}
height={620}
showMA={true}
showInfoBar={true}
showMarkers={false}
symbol={selectedSymbol}
linkedPrice={linkedPrice}
onDateClick={setSelectedDate}
visibleBars={48}
activeIndicators={['vol', 'macd']}
/>
</div>
<div className="min-w-0 flex-1 border-l border-border pl-3" style={{ height: 620 }}>
{!hasMinuteCap ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<Lock className="h-5 w-5 text-muted" />
<div className="text-xs text-secondary"> Pro+</div>
<div className="text-[10px] text-muted"></div>
</div>
) : (
<>
{minute.isLoading && <div className="py-2 text-xs text-muted"></div>}
{!minute.isLoading && minuteRows.length === 0 && (
<div className="flex h-full items-center justify-center text-xs text-muted">
</div>
)}
{minuteRows.length > 0 && (
<EChartsIntraday
data={minuteRows}
height={620}
prevClose={prevClose}
date={selectedDate ?? undefined}
symbol={selectedSymbol}
showLimitLines={false}
showAvgLine={false}
onPriceHover={setLinkedPrice}
/>
)}
</>
)}
</div>
</div>
)}
</main>
</div>
</div>
)
}