将项目文件整理到 refer 目录

This commit is contained in:
2026-07-03 22:26:00 +08:00
parent 49880e785f
commit 9f57af4d37
1223 changed files with 98 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import {
Plus,
Copy,
Trash2,
ArrowLeft,
ToggleLeft,
ToggleRight,
Loader2,
Check,
AlertCircle,
} from 'lucide-react'
import { api } from '@/lib/api'
import { toast } from '@/components/Toast'
import { Logo } from '@/components/Logo'
const BRAND = '#8B5CF6'
function formatTime(ts: number) {
const d = new Date(ts * 1000)
return d.toLocaleString('zh-CN')
}
export function AdminUuids() {
const navigate = useNavigate()
const qc = useQueryClient()
const [label, setLabel] = useState('')
const [copied, setCopied] = useState<string | null>(null)
const { data: records = [], isLoading } = useQuery({
queryKey: ['admin-uuids'],
queryFn: () => api.listUuids(),
})
const create = useMutation({
mutationFn: () => api.createUuid(label),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-uuids'] })
setLabel('')
toast('已创建新 UUID', 'success')
},
onError: (err: any) => toast(err?.message || '创建失败', 'error'),
})
const toggle = useMutation({
mutationFn: ({ uuid, enabled }: { uuid: string; enabled: boolean }) =>
api.toggleUuid(uuid, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-uuids'] }),
onError: (err: any) => toast(err?.message || '操作失败', 'error'),
})
const remove = useMutation({
mutationFn: (uuid: string) => api.deleteUuid(uuid),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-uuids'] })
toast('已删除', 'success')
},
onError: (err: any) => toast(err?.message || '删除失败', 'error'),
})
const handleCopy = async (uuid: string) => {
try {
await navigator.clipboard.writeText(uuid)
setCopied(uuid)
setTimeout(() => setCopied(null), 1500)
} catch {
toast('复制失败', 'error')
}
}
return (
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
{/* 背景光晕 */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
</div>
{/* 顶栏 */}
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<Logo
size={24}
className="shrink-0"
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
/>
<span className="text-sm font-semibold tracking-tight">Stock Panel</span>
</div>
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
</header>
{/* 内容 */}
<main className="relative z-10 flex-1 px-6 py-10">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="max-w-3xl mx-auto"
>
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-foreground">访 UUID </h1>
<p className="mt-1 text-sm text-secondary">访 UUID</p>
</div>
</div>
{/* 创建区 */}
<div className="mt-6 flex items-center gap-3">
<input
type="text"
placeholder="备注(可选)"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="flex-1 px-3 py-2 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
<button
onClick={() => create.mutate()}
disabled={create.isPending}
className="inline-flex items-center gap-2 px-4 h-10 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 disabled:opacity-60 transition-all"
>
{create.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
UUID
</button>
</div>
{/* 列表 */}
<div className="mt-6 border border-border rounded-card overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
) : records.length === 0 ? (
<div className="py-12 text-center text-sm text-secondary">
UUID,
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-elevated/50 text-secondary">
<tr>
<th className="text-left px-4 py-2.5 font-medium">UUID</th>
<th className="text-left px-4 py-2.5 font-medium"></th>
<th className="text-left px-4 py-2.5 font-medium"></th>
<th className="text-left px-4 py-2.5 font-medium"></th>
<th className="text-right px-4 py-2.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{records.map((r) => (
<tr key={r.uuid} className="hover:bg-elevated/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs text-foreground">
<div className="flex items-center gap-2">
<span className="truncate max-w-[12rem]">{r.uuid}</span>
<button
onClick={() => handleCopy(r.uuid)}
className="text-muted hover:text-accent transition-colors"
title="复制"
>
{copied === r.uuid ? (
<Check className="h-3.5 w-3.5 text-bull" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
</td>
<td className="px-4 py-3 text-secondary">{r.label || '—'}</td>
<td className="px-4 py-3 text-secondary">{formatTime(r.created_at)}</td>
<td className="px-4 py-3">
<button
onClick={() => toggle.mutate({ uuid: r.uuid, enabled: !r.enabled })}
disabled={toggle.isPending}
className="text-muted hover:text-accent transition-colors"
title={r.enabled ? '禁用' : '启用'}
>
{r.enabled ? (
<ToggleRight className="h-5 w-5 text-bull" />
) : (
<ToggleLeft className="h-5 w-5 text-muted" />
)}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => remove.mutate(r.uuid)}
disabled={remove.isPending}
className="inline-flex items-center gap-1 text-xs text-danger hover:text-danger/80 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="mt-4 flex items-start gap-2 text-xs text-secondary">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>提示:禁用 UUID , UUID 访,</span>
</div>
</div>
</motion.div>
</main>
</div>
)
}
+291
View File
@@ -0,0 +1,291 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { BarChart3, ChevronDown, ChevronUp, Plus, Save, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api, type AnalysisColumn, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
function dtypeToColumnType(dtype: string): AnalysisColumn['type'] {
return dtype === 'int' || dtype === 'float' ? 'number' : 'string'
}
function buildColumn(field: ExtDataField): AnalysisColumn {
return {
field: field.name,
label: field.label || field.name,
type: dtypeToColumnType(field.dtype),
precision: field.dtype === 'float' ? 2 : null,
sortable: field.dtype === 'int' || field.dtype === 'float',
visible: true,
}
}
function firstMatchingField(config: ExtDataConfig | undefined, keywords: string[]) {
if (!config) return ''
for (const keyword of keywords) {
const lower = keyword.toLowerCase()
const matched = config.fields.find(f => f.name.toLowerCase().includes(lower) || f.label.toLowerCase().includes(lower))
if (matched) return matched.name
}
return config.fields.find(f => !['symbol', 'code'].includes(f.name) && f.dtype === 'string')?.name ?? ''
}
export function Analysis() {
const qc = useQueryClient()
const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
const extData = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const configs = extData.data?.items ?? []
const [showCreate, setShowCreate] = useState(false)
const [id, setId] = useState('')
const [label, setLabel] = useState('')
const [dataSource, setDataSource] = useState('')
const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table'>('dimension_rank')
const [dimensionField, setDimensionField] = useState('')
const [rankField, setRankField] = useState('')
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [error, setError] = useState('')
const menuItems = menus.data?.items ?? []
const activeConfig = configs.find(c => c.id === dataSource) ?? configs[0]
const fields = activeConfig?.fields ?? []
const resetForm = () => {
const cfg = configs[0]
setId('')
setLabel('')
setDataSource(cfg?.id ?? '')
setTemplate('dimension_rank')
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField('')
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
setError('')
}
const save = useMutation({
mutationFn: () => {
const cfg = activeConfig
if (!cfg) throw new Error('请选择扩展数据源')
if (!id.trim()) throw new Error('请输入菜单标识')
if (!label.trim()) throw new Error('请输入菜单名称')
if (template === 'dimension_rank' && !dimensionField) throw new Error('请选择分组字段')
if (template === 'ranking' && !rankField) throw new Error('请选择排名字段')
const detailColumns = selectedColumns
.map(name => cfg.fields.find(f => f.name === name))
.filter(Boolean)
.map(f => buildColumn(f as ExtDataField))
const groupColumns: AnalysisColumn[] = template === 'dimension_rank'
? [
{ field: '__dimension', label: cfg.fields.find(f => f.name === dimensionField)?.label || '分组', type: 'string', visible: true },
{ field: '__count', label: '股票数', type: 'number', sortable: true, visible: true },
...detailColumns.filter(c => c.type === 'number').slice(0, 2).map(c => ({ ...c, label: `平均${c.label || c.field}`, aggregate: 'avg' as const })),
]
: []
return api.analysisMenuSave(id.trim(), {
label: label.trim(),
icon: template === 'dimension_rank' ? 'tags' : 'chart',
data_source: cfg.id,
template,
dimension_field: template === 'dimension_rank' ? dimensionField : null,
rank_field: template === 'ranking' ? rankField : null,
group_columns: groupColumns,
detail_columns: detailColumns,
default_sort: template === 'ranking' && rankField ? { field: rankField, order: 'desc' } : null,
visible: true,
order: menuItems.length + 100,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.analysisMenus })
setShowCreate(false)
resetForm()
},
onError: (err) => setError(String((err as any)?.message ?? err)),
})
const del = useMutation({
mutationFn: api.analysisMenuDelete,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }),
})
const reorder = useMutation({
mutationFn: api.analysisMenuReorder,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }),
})
const moveMenu = (idx: number, dir: -1 | 1) => {
const next = [...menuItems]
const target = idx + dir
if (target < 0 || target >= next.length) return
const [item] = next.splice(idx, 1)
next.splice(target, 0, item)
reorder.mutate(next.map(m => m.id))
}
const numericFields = useMemo(() => fields.filter(f => f.dtype === 'int' || f.dtype === 'float'), [fields])
return (
<>
<PageHeader
title="扩展分析"
subtitle="自定义分析菜单 · 动态字段 · 动态列"
right={
<button
onClick={() => { resetForm(); setShowCreate(v => !v) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
}
/>
<div className="px-8 py-6 max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(139,92,246,0.14),transparent_38%)]">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] text-secondary">
<BarChart3 className="h-3.5 w-3.5" />
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
使
</p>
</section>
{showCreate && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={id} onChange={e => setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} placeholder="如 concept_hot" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="如 概念热度" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select
value={dataSource || activeConfig?.id || ''}
onChange={e => {
const cfg = configs.find(c => c.id === e.target.value)
setDataSource(e.target.value)
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
}}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{configs.map(cfg => <option key={cfg.id} value={cfg.id}>{cfg.label}</option>)}
</select>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={template} onChange={e => setTemplate(e.target.value as any)} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="dimension_rank"></option>
<option value="ranking"></option>
<option value="table"></option>
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={dimensionField} onChange={e => setDimensionField(e.target.value)} disabled={template !== 'dimension_rank'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{fields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={rankField} onChange={e => setRankField(e.target.value)} disabled={template !== 'ranking'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{numericFields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
</div>
<div>
<div className="text-[11px] text-muted mb-2"></div>
<div className="flex flex-wrap gap-2">
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
const active = selectedColumns.includes(f.name)
return (
<button
key={f.name}
onClick={() => setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
>
{f.label || f.name}
</button>
)
})}
</div>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</section>
)}
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{menuItems.map((menu, idx) => (
<div key={menu.id} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{menu.label}</h3>
<p className="mt-1 text-[11px] text-muted font-mono">{menu.id}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => moveMenu(idx, -1)}
disabled={idx === 0 || reorder.isPending}
className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 disabled:opacity-30"
title="上移"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button
onClick={() => moveMenu(idx, 1)}
disabled={idx === menuItems.length - 1 || reorder.isPending}
className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 disabled:opacity-30"
title="下移"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{menu.builtin ? (
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"></span>
) : (
<button onClick={() => del.mutate(menu.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1 text-[11px] text-secondary">
<div><span className="font-mono text-muted">{menu.data_source}</span></div>
<div>{menu.template}</div>
{menu.dimension_field && <div>{menu.dimension_field}</div>}
<div>{menu.detail_columns.length} </div>
</div>
<Link to={`/analysis/${menu.id}`} className="mt-4 inline-flex w-full items-center justify-center rounded-btn border border-border bg-elevated px-3 py-1.5 text-xs text-foreground hover:bg-border/30 transition-colors">
</Link>
</div>
))}
{menuItems.length === 0 && (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted md:col-span-2 xl:col-span-3"></div>
)}
</section>
</div>
</>
)
}
@@ -0,0 +1,7 @@
import { useParams } from 'react-router-dom'
import { ExtDimensionAnalysis } from '@/components/ExtDimensionAnalysis'
export function AnalysisDetail() {
const { menuId } = useParams()
return <ExtDimensionAnalysis menuId={menuId} />
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { PageHeader } from '@/components/PageHeader'
import { FactorBacktest } from './backtest/FactorBacktest'
import { StrategyBacktest } from './backtest/StrategyBacktest'
import { BarChart3, FlaskConical } from 'lucide-react'
type Tab = 'factor' | 'strategy'
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
factor: {
title: '因子回测',
subtitle: '验证单个因子是否有预测能力',
hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。',
},
strategy: {
title: '策略回测',
subtitle: '验证完整选股和交易规则',
hint: '看净值曲线、回撤、胜率和交易明细,适合判断策略是否可执行。',
},
}
export function Backtest() {
const [activeTab, setActiveTab] = useState<Tab>('strategy')
const modeSwitch = (
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
{(['factor', 'strategy'] as const).map(tab => {
const Icon = tab === 'factor' ? BarChart3 : FlaskConical
const active = activeTab === tab
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
active
? 'bg-accent text-white shadow-sm'
: 'text-secondary hover:bg-elevated hover:text-foreground'
}`}
>
<Icon className="h-3.5 w-3.5" />
{MODES[tab].title}
</button>
)
})}
</div>
)
return (
<div className="min-h-full bg-base flex flex-col">
<PageHeader
title="回测工作台"
subtitle={`${MODES[activeTab].title} · ${MODES[activeTab].hint}`}
right={modeSwitch}
className="shrink-0 bg-base/95"
/>
<main className="flex-1 min-h-0 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
{activeTab === 'factor' && <FactorBacktest />}
{activeTab === 'strategy' && <StrategyBacktest />}
</main>
</div>
)
}
+203
View File
@@ -0,0 +1,203 @@
import { motion } from 'framer-motion'
import {
RadioTower,
Square,
GitFork,
Sparkles,
Star,
LineChart,
ScanSearch,
History,
Signal as SignalIcon,
Eye,
FileText,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
interface Variant {
id: string
name: string
tagline: string
hint: string
icon: React.ComponentType<{ className?: string }>
iconAccent: string // tailwind text color
nameClass: string // 文字本身样式(字号/字重/字距/字体)
glow?: string // 名字下方的发光线条 hex
}
// 同一个名字 "Stock Panel" 在 4 种风格语言里的呈现
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
const VARIANTS: Variant[] = [
{
id: 'pulsar',
name: 'Stock Panel',
tagline: 'A-SHARE · SIGNAL TERMINAL',
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
icon: RadioTower,
iconAccent: 'text-[#3DD68C]',
nameClass: 'font-sans font-black text-base tracking-[0.10em]',
glow: '#3DD68C',
},
{
id: 'vanta',
name: 'Stock Panel',
tagline: 'MARKET · INTELLIGENCE',
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
icon: Square,
iconAccent: 'text-[#FAFAFA]',
nameClass: 'font-sans font-black text-base tracking-[0.18em]',
glow: '#FAFAFA',
},
{
id: 'helix',
name: 'Stock Panel',
tagline: 'QUANT · TERMINAL',
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
icon: GitFork,
iconAccent: 'text-[#8B5CF6]',
nameClass: 'font-mono font-bold text-base tracking-[0.08em]',
glow: '#8B5CF6',
},
{
id: 'aurora',
name: 'Stock Panel',
tagline: 'A-SHARE · DASHBOARD',
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
icon: Sparkles,
iconAccent: 'text-[#22D3EE]',
nameClass: 'font-sans font-light text-base tracking-[0.12em]',
glow: '#22D3EE',
},
]
const MOCK_NAV = [
{ icon: Star, label: '自选' },
{ icon: LineChart, label: 'K 线' },
{ icon: ScanSearch, label: '策略' },
{ icon: History, label: '回测' },
{ icon: SignalIcon, label: '信号' },
{ icon: Eye, label: '监控' },
{ icon: FileText, label: '财务' },
]
export function Branding() {
return (
<>
<PageHeader
title="视觉风格预览"
subtitle="名字保持 Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
/>
<div className="px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{VARIANTS.map((v) => (
<Sample key={v.id} v={v} />
))}
</div>
<div className="mt-8 rounded-card border border-border bg-surface p-5 text-sm text-secondary leading-relaxed max-w-2xl">
<div className="font-medium text-foreground mb-2">?</div>
<code className="font-mono text-accent">pulsar / vanta / helix / aurora</code> ,
("用 VANTA 但换青色"),
</div>
</div>
</>
)
}
function Sample({ v }: { v: Variant }) {
const Icon = v.icon
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="rounded-card border border-border overflow-hidden bg-base flex"
>
{/* 模拟侧边栏 */}
<div className="w-56 bg-surface border-r border-border flex flex-col">
{/* Logo 区 */}
<div className="px-5 py-5 border-b border-border">
<div className="flex items-center gap-2.5">
<div
className="grid place-items-center h-7 w-7 rounded-md"
style={{
background: `${v.glow}1a`,
boxShadow: `0 0 12px ${v.glow}33`,
}}
>
<Icon className={`h-4 w-4 ${v.iconAccent}`} />
</div>
<div className={`${v.nameClass} text-foreground leading-none`}>
{v.name}
</div>
</div>
<div className="mt-2 text-[10px] uppercase tracking-[0.18em] text-secondary">
{v.tagline}
</div>
<div
className="mt-3 h-px"
style={{
background: `linear-gradient(90deg, ${v.glow}66, transparent)`,
}}
/>
<div className="mt-2 text-xs text-secondary">
· <span className="text-foreground font-medium font-mono">Pro</span>
</div>
</div>
{/* 模拟导航 */}
<nav className="px-2 py-3 space-y-0.5">
{MOCK_NAV.slice(0, 5).map(({ icon: I, label }, i) => (
<div
key={label}
className={`flex items-center gap-3 px-3 py-2 rounded-btn text-sm ${
i === 0
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80'
}`}
>
<I className="h-4 w-4" />
{label}
</div>
))}
</nav>
</div>
{/* 右侧说明 + 大字预览 */}
<div className="flex-1 p-5 flex flex-col">
<div className="flex-1">
<div className="text-xs font-medium text-muted uppercase tracking-widest">{v.id}</div>
<div className="mt-2 leading-relaxed text-sm text-secondary">
{v.hint}
</div>
</div>
{/* 大字 wordmark 预览 */}
<div className="mt-6 pt-6 border-t border-border">
<div
className={`${v.nameClass} text-foreground`}
style={{
textShadow: `0 0 24px ${v.glow}55`,
}}
>
{v.name}
</div>
<div className="mt-1.5 text-[10px] uppercase tracking-[0.2em] text-secondary">
{v.tagline}
</div>
</div>
{/* 模拟一个数据卡片,看与配色协调度 */}
<div className="mt-5 rounded-btn bg-surface border border-border px-3 py-2 flex items-baseline justify-between">
<span className="text-xs text-secondary">600519.SH</span>
<span className="font-mono text-sm" style={{ color: v.glow }}>
+1.85%
</span>
</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,761 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Database,
Layers3,
RefreshCw,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
const KEYWORDS = ['concept', '概念', 'theme', '题材', '板块']
const CANDIDATE_FIELDS = ['concept', '概念', 'theme', '题材', '板块', 'concept_name', '概念名称']
const PAGE_LIMIT = 12000
const MAX_RENDERED_CONCEPTS = 120
const MAX_RENDERED_STOCKS = 160
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface ConceptStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
function loadConfig(): AnalysisFieldConfig {
return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.conceptAnalysisConfig.set(c)
}
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
let best = ''
let bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) {
bestScore = score
best = c.id
}
}
return best
}
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
function calcConceptStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): ConceptStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: ConceptStat, b: ConceptStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
export function ConceptAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
// 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择,
// 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。
const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId)
const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const stats = useMemo(() => {
return resolved.groups
.map(g => calcConceptStat(g, marketMap))
.filter(s => s.count > 0)
}, [resolved.groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeConcept = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const conceptBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
if (!activeConfig) {
return (
<div className="flex h-full flex-col">
<PageHeader
title="概念分析"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<EmptyState icon={Database} title="暂无概念数据" hint={'请先在"数据"页面创建包含概念/题材字段的扩展数据源'} />
</div>
)
}
return (
<>
<PageHeader
title="概念分析"
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(59,130,246,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<HeroPanel leading={leading[0]} falling={falling[0]} activeConcept={activeConcept} conceptBreadth={conceptBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }}
/>
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<ConceptRail
stats={filteredStats.slice(0, MAX_RENDERED_CONCEPTS)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<ConceptFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
) : (
<EmptyState icon={Layers3} title="未匹配到概念数据" hint={resolved.hint || '请检查扩展数据是否包含概念/题材相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
/>
)}
</>
)
}
function HeroPanel({
leading,
falling,
activeConcept,
conceptBreadth,
}: {
leading?: ConceptStat
falling?: ConceptStat
activeConcept?: ConceptStat | null
conceptBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强主线" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌板块"
value={<><span className="text-bull">{conceptBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{conceptBreadth.down}</span></>}
hint={<><span className="text-bull"></span><span className="mx-1 text-muted">/</span><span className="text-bear"></span>{conceptBreadth.flat ? <span className="text-muted"> · {conceptBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeConcept?.key ?? '—'} hint={activeConcept ? fmtBigNum(activeConcept.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
}: {
leading: ConceptStat[]
falling: ConceptStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
}: {
title: string
items: ConceptStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40"></span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
function ConceptRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: ConceptStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索概念" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-accent/15 text-accent' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-blue-400/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}</span>
<span className="text-bull">{item.upCount}</span>
<span className="text-bear">{item.downCount}</span>
<span className="ml-auto"> {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string) => void }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-blue-400/10 px-2 py-0.5 text-[10px] text-blue-300"> {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} </span>
<span className={priceColorClass(stat.avgPct)}> {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span> {(stat.upRate * 100).toFixed(0)}%</span>
<span> {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span> {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted"> {MAX_RENDERED_STOCKS} {stat.stocks.length} </div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<span className="text-[11px] text-muted"> / / / / / </span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
+714
View File
@@ -0,0 +1,714 @@
import { useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useTheme } from '@/components/ThemeProvider'
import { Activity, AlertTriangle, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer, ExternalLink } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { boardTag } from '@/components/stock-table/primitives'
function n(v: number | null | undefined) {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function scoreColor(v: number) {
// A 股惯例: 强势=红, 弱式=绿
if (v >= 70) return '#F04438'
if (v >= 55) return '#FB923C'
if (v >= 45) return '#F59E0B'
if (v >= 30) return '#84CC16'
return '#12B76A'
}
function fmtPrice(v: number | null | undefined, digits = 2) {
const x = n(v)
return x == null ? '—' : x.toFixed(digits)
}
function fmtIndexPct(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
return `${x >= 0 ? '+' : ''}${x.toFixed(2)}%`
}
function fmtStockPct(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
return `${x >= 0 ? '+' : ''}${(x * 100).toFixed(2)}%`
}
function pctClass(v: number | null | undefined) {
const x = n(v)
if (x == null || x === 0) return 'text-muted'
return x > 0 ? 'text-bull' : 'text-bear'
}
function quoteAge(ms?: number | null) {
if (ms == null) return '—'
if (ms < 1000) return `${Math.round(ms)}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m${s % 60}s`
}
function compactCount(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
if (x >= 1000) return `${(x / 1000).toFixed(1)}k`
return x.toFixed(0)
}
function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: ReactNode }) {
return (
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground">{title}</h2>
</div>
{hint && <span className="font-mono text-[10px] text-muted">{hint}</span>}
</div>
)
}
// 看板监控中心小组件 — 显示前 10 条触发记录 + 更多按钮
const _SOURCE_BADGE: Record<string, string> = {
strategy: 'bg-amber-400/10 text-amber-400',
signal: 'bg-accent/10 text-accent',
price: 'bg-emerald-400/10 text-emerald-400',
market: 'bg-purple-500/10 text-purple-400',
}
const _SOURCE_LABEL: Record<string, string> = {
strategy: '策略', signal: '信号', price: '价格', market: '异动',
}
const _SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
}
function MonitorWidget() {
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const alerts = useQuery({
queryKey: ['alerts', ''],
queryFn: () => api.alertsList({ days: 7, limit: 10 }),
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const events: AlertEvent[] = alerts.data?.alerts ?? []
if (events.length === 0) {
return (
<div className="mt-1 py-6 text-center text-[11px] text-muted"></div>
)
}
return (
<>
<div className="mt-1 space-y-1.5">
{events
.filter((ev: AlertEvent) => !(ev.source === 'strategy' && !ev.symbol))
.map((ev, i) => {
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
const pct = ev.change_pct ?? 0
const isStrategy = ev.source === 'strategy'
const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
return (
<motion.div
key={`${ev.ts}-${i}`}
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
<div className="flex items-center gap-1.5">
<button
onClick={() => ev.symbol && setPreviewEv(ev)}
title={ev.symbol ? `查看 ${ev.symbol} 日K` : undefined}
className="inline-flex items-center gap-1 min-w-0 shrink-0 rounded hover:bg-elevated/60 transition-colors -mx-0.5 px-0.5 cursor-pointer"
>
<span className="font-mono text-[10px] font-medium text-foreground/80 hover:text-accent">{ev.symbol?.replace(/\.(SH|SZ|BJ)$/, '')}</span>
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return board && (
<span className={`inline-flex items-center justify-center h-3 w-3 rounded text-[7px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)
})()}
{ev.name && <span className="text-[10px] text-secondary truncate max-w-[5rem] hover:text-foreground">{ev.name}</span>}
</button>
<span className="flex-1" />
{ev.price != null && (
<span className="text-[10px] font-mono text-foreground/60 shrink-0">{fmtPrice(ev.price)}</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[10px] font-mono font-medium shrink-0 w-12 text-right', pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(pct)}
</span>
)}
</div>
{/* 第二行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('text-[9px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[9px] text-muted"></span>
<span className="text-[9px] font-medium text-amber-400">{sname}</span>
<span className="flex-1" />
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
) : (
<>
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
{_SOURCE_LABEL[ev.source] ?? ev.source}
</span>
{ev.message && (
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
)}
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{ev.signals.map((s, j) => (
<span key={j} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(s)}</span>
))}
</div>
)}
</>
)}
</motion.div>
)
})}
</div>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
ts: previewEv.ts,
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
/>
</>
)
}
function KpiCell({ label, value, sub, tone = 'neutral' }: { label: ReactNode; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) {
const isPlain = typeof value === 'string' || typeof value === 'number'
const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground'
return (
<div className="min-w-0 rounded-lg border border-border bg-surface/80 px-3 py-2">
<div className="flex items-center gap-1 text-[11px] text-muted">{label}</div>
<div className={`mt-1 truncate font-mono text-lg font-semibold leading-none tabular-nums ${isPlain ? color : 'text-foreground'}`}>{value}</div>
{sub && <div className="mt-1 truncate text-[10px] text-muted">{sub}</div>}
</div>
)
}
function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) {
const pct = item.change_pct
const isUp = (n(pct) ?? 0) >= 0
return (
<Link
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="grid min-w-0 grid-cols-[1fr_auto] items-center gap-x-2 gap-y-0.5 rounded-lg border border-border bg-elevated/45 px-2.5 py-1.5 transition-colors hover:border-accent/40 hover:bg-elevated"
>
<div className="truncate text-xs font-medium text-foreground">{item.name || item.symbol}</div>
<div className={`font-mono text-xs font-semibold ${pctClass(pct)}`}>{fmtIndexPct(pct)}</div>
<div className="font-mono text-[10px] text-muted">{item.symbol}</div>
<div className={`flex items-center gap-1 font-mono text-[11px] ${pctClass(pct)}`}>
{isUp ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
{fmtPrice(item.last_price)}
</div>
</Link>
)
}
function BreadthBar({ data }: { data: OverviewMarket['breadth'] }) {
const denom = Math.max(data.total, 1)
const upW = data.up / denom * 100
const downW = data.down / denom * 100
const flatW = Math.max(0, 100 - upW - downW)
return (
<div className="space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-elevated">
<div className="bg-bull/85" style={{ width: `${upW}%` }} />
<div className="bg-muted/45" style={{ width: `${flatW}%` }} />
<div className="bg-bear/85" style={{ width: `${downW}%` }} />
</div>
<div className="grid grid-cols-3 gap-1.5 text-[11px]">
<div className="rounded bg-bull/8 px-2 py-1 text-bull"> <span className="font-mono">{data.up}</span></div>
<div className="rounded bg-elevated/70 px-2 py-1 text-muted"> <span className="font-mono">{data.flat}</span></div>
<div className="rounded bg-bear/8 px-2 py-1 text-bear"> <span className="font-mono">{data.down}</span></div>
</div>
</div>
)
}
function DistributionBars({ rows }: { rows: OverviewMarket['distribution'] }) {
const maxCount = Math.max(...rows.map(r => r.count), 1)
return (
<div className="grid h-24 grid-cols-8 items-end gap-1 pt-1">
{rows.map((r, i) => {
const positive = i >= 4
return (
<div key={r.label} className="flex h-full min-w-0 flex-col items-center justify-end gap-0.5">
<div className="font-mono text-[9px] text-muted">{r.count || ''}</div>
<div
className={`w-2 rounded-full ${positive ? 'bg-gradient-to-t from-bull/45 to-bull/90' : 'bg-gradient-to-t from-bear/45 to-bear/90'}`}
style={{ height: `${Math.max(4, r.count / maxCount * 86)}%` }}
title={`${r.label}: ${r.count}`}
/>
<div className="truncate text-[9px] text-muted">{r.label}</div>
</div>
)
})}
</div>
)
}
function EmotionRadar({ radar, score }: { radar: OverviewMarket['radar']; score: number }) {
const { resolved } = useTheme()
const isDark = resolved === 'dark'
const size = 240
const cx = size / 2
const cy = size / 2
const maxR = 78
const color = scoreColor(score)
const centerFillStart = isDark ? 'rgba(15,23,42,0.92)' : 'rgba(255,255,255,0.92)'
const centerFillMid = isDark ? 'rgba(15,23,42,0.70)' : 'rgba(255,255,255,0.70)'
const gridFillEven = isDark ? 'rgba(30,41,59,0.26)' : 'rgba(228,228,231,0.35)'
const gridFillOdd = isDark ? 'rgba(15,23,42,0.16)' : 'rgba(244,244,245,0.45)'
const gridStrokeStrong = isDark ? 'rgba(148,163,184,0.22)' : 'rgba(0,0,0,0.12)'
const gridStrokeWeak = isDark ? 'rgba(148,163,184,0.12)' : 'rgba(0,0,0,0.06)'
const spokeStroke = isDark ? 'rgba(148,163,184,0.08)' : 'rgba(0,0,0,0.05)'
const pointStroke = isDark ? 'rgba(15,23,42,0.9)' : 'rgba(255,255,255,0.9)'
if (!radar.length) return <div className="flex h-52 items-center justify-center text-xs text-muted"></div>
const points = radar.map((r, i) => {
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
const radius = maxR * Math.max(0, Math.min(100, r.value)) / 100
return {
...r,
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
lx: cx + Math.cos(angle) * (maxR + 27),
ly: cy + Math.sin(angle) * (maxR + 27),
gx: cx + Math.cos(angle) * maxR,
gy: cy + Math.sin(angle) * maxR,
}
})
const polygon = points.map(p => `${p.x},${p.y}`).join(' ')
const gridPolygons = [1, 0.66, 0.33].map((level, idx) => ({
level,
idx,
points: radar.map((_, i) => {
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
return `${cx + Math.cos(angle) * maxR * level},${cy + Math.sin(angle) * maxR * level}`
}).join(' '),
}))
return (
<div className="flex justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-56 w-full">
<defs>
<radialGradient id="emotionRadarFill" cx="50%" cy="45%" r="70%">
<stop offset="0%" stopColor={`${color}57`} />
<stop offset="100%" stopColor={`${color}1f`} />
</radialGradient>
<radialGradient id="emotionRadarCenter" cx="50%" cy="50%" r="55%">
<stop offset="0%" stopColor={centerFillStart} />
<stop offset="68%" stopColor={centerFillMid} />
<stop offset="100%" stopColor="rgba(0,0,0,0)" />
</radialGradient>
</defs>
{gridPolygons.map(g => (
<polygon
key={g.level}
points={g.points}
fill={g.idx % 2 === 0 ? gridFillEven : gridFillOdd}
stroke={g.level === 1 ? gridStrokeStrong : gridStrokeWeak}
strokeWidth={g.level === 1 ? 1.2 : 0.8}
/>
))}
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke={spokeStroke} />)}
<polygon points={polygon} fill="url(#emotionRadarFill)" stroke={color} strokeWidth="2" />
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke={pointStroke} strokeWidth="1" />)}
<circle cx={cx} cy={cy} r="29" fill="url(#emotionRadarCenter)" />
<text x={cx} y={cy + 7} textAnchor="middle" className="fill-foreground font-mono text-[24px] font-bold">{score}</text>
{points.map(p => (
<text key={`${p.key}-label`} x={p.lx} y={p.ly + 4} textAnchor="middle" className="fill-secondary text-[10px] font-medium">{p.label}</text>
))}
</svg>
</div>
)
}
function LadderMini({ limit }: { limit: OverviewMarket['limit'] }) {
const tiers = limit.tiers.filter(t => t.boards >= 2).slice(0, 6)
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between rounded bg-elevated/55 px-2 py-1.5 text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-accent">{(limit.seal_rate ?? 0).toFixed(0)}%</span>
</div>
{tiers.length === 0 && <div className="rounded border border-dashed border-border py-5 text-center text-xs text-muted"> 2 </div>}
{tiers.map(t => (
<div key={t.boards} className="grid grid-cols-[42px_1fr_auto] items-center gap-2 rounded bg-elevated/35 px-2 py-1.5">
<span className={`font-mono text-sm font-bold ${t.boards >= 5 ? 'text-bull' : t.boards >= 3 ? 'text-accent' : 'text-secondary'}`}>{t.boards}</span>
<div className="h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full bg-bull/70" style={{ width: `${Math.min(100, t.count * 12)}%` }} />
</div>
<span className="font-mono text-xs text-foreground">{t.count}</span>
</div>
))}
</div>
)
}
function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string; value: string; cls?: string }) {
return (
<div className="rounded bg-elevated/45 px-2 py-1.5">
<div className="text-[10px] text-muted">{label}</div>
<div className={`mt-0.5 font-mono text-xs font-semibold ${cls}`}>{value}</div>
</div>
)
}
function StockList({ title, rows, mode }: { title: string; rows: MarketSnapshotRow[]; mode: 'gain' | 'loss' | 'amount' | 'active' }) {
return (
<div className="rounded-card border border-border bg-surface/80 p-2.5">
<div className="mb-1.5 flex items-center justify-between">
<h3 className="text-xs font-semibold text-foreground">{title}</h3>
<span className="text-[9px] text-muted">TOP {Math.min(rows.length, 8)}</span>
</div>
<div className="space-y-1">
{rows.slice(0, 8).map((r, idx) => (
<div key={`${r.symbol}-${idx}`} className="grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded bg-elevated/40 px-1.5 py-1">
<span className="text-center font-mono text-[10px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground">{r.name || r.symbol}</div>
<div className="font-mono text-[9px] text-muted">{r.symbol}</div>
</div>
<div className="text-right">
{mode === 'amount' ? (
<>
<div className="font-mono text-[11px] text-foreground">{fmtBigNum(r.amount)}</div>
<div className={`font-mono text-[9px] ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
</>
) : mode === 'active' ? (
<>
{/* overview 的 turnover_rate 为小数制, 需 ×100 转百分数显示 */}
<div className="font-mono text-[11px] text-accent">{fmtPrice(r.turnover_rate != null ? r.turnover_rate * 100 : null, 1)}%</div>
<div className={`font-mono text-[9px] ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
</>
) : (
<>
<div className={`font-mono text-[11px] font-semibold ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
<div className="font-mono text-[9px] text-muted">{fmtPrice(r.close)}</div>
</>
)}
</div>
</div>
))}
{rows.length === 0 && <div className="py-5 text-center text-xs text-muted"></div>}
</div>
</div>
)
}
function RankColumn({ title, rows, tone }: { title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear' }) {
return (
<div className="min-w-0 space-y-1">
<div className={`text-[10px] font-medium ${tone === 'bull' ? 'text-bull' : 'text-bear'}`}>{title}</div>
{rows.slice(0, 5).map((r, idx) => (
<div key={`${title}-${r.name}-${idx}`} className="grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded bg-elevated/40 px-1.5 py-1">
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground" title={r.name}>{r.name}</div>
<div className="truncate text-[9px] text-muted">{r.count} · {r.leader?.name ?? '—'}</div>
</div>
<div className={`font-mono text-[10px] font-semibold ${pctClass(r.avg_pct)}`}>{fmtStockPct(r.avg_pct)}</div>
</div>
))}
{rows.length === 0 && <div className="rounded border border-dashed border-border py-4 text-center text-xs text-muted"></div>}
</div>
)
}
function HotRankCard({ title, rank, configUrl }: { title: string; rank?: OverviewMarket['concept_rank']; configUrl: string }) {
const hasData = (rank?.leading?.length ?? 0) > 0 || (rank?.lagging?.length ?? 0) > 0
return (
<section className="rounded-card border border-border bg-surface/80 p-2.5">
<SectionTitle icon={Flame} title={title} hint="领涨/领跌" />
{hasData ? (
<div className="grid grid-cols-2 gap-2">
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" />
</div>
) : (
<div className="py-4 text-center">
<p className="text-[11px] text-muted"></p>
<Link
to={configUrl}
className="mt-1.5 inline-block text-[11px] text-accent hover:text-accent/80 transition-colors"
>
</Link>
</div>
)}
</section>
)
}
export function Dashboard() {
const [selectedDate, setSelectedDate] = useState<string | undefined>()
const [manualFetching, setManualFetching] = useState(false)
const dataStatus = useDataStatus({ staleTime: 60_000 })
const overview = useQuery({
queryKey: QK.overviewMarket(selectedDate),
queryFn: () => api.overviewMarket(selectedDate),
staleTime: 5_000,
placeholderData: (prev) => prev,
})
const data = overview.data
const caps = useCapabilities()
const settings = useSettings()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key)→ 显示升级提示横幅
const isNoKey = settings.data?.mode === 'none'
// 无本地数据(enriched/daily 都没有)→ 提示去数据页同步
// 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据
const ds = dataStatus.data
const hasNoData = !!ds
&& (ds.enriched?.trading_days ?? 0) === 0
&& (ds.daily?.trading_days ?? 0) === 0
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => {
setManualFetching(true)
overview.refetch().finally(() => setManualFetching(false))
}
if (overview.isLoading && !data) {
return (
<div className="flex h-full items-center justify-center bg-base">
<div className="flex items-center gap-2 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center bg-base p-6">
<div className="rounded-card border border-border bg-surface p-6 text-center">
<div className="text-sm text-danger"></div>
<button onClick={() => overview.refetch()} className="mt-3 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-base"></button>
</div>
</div>
)
}
const score = data.emotion?.score ?? 50
const strongUp = data.breadth.strong_up ?? 0
const strongDown = data.breadth.strong_down ?? 0
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
return (
<div className="min-h-full bg-base p-3">
{/* none 档(无 key)提示横幅 —— 引导用户领取免费 Key 解锁完整能力 */}
{isNoKey && (
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
API Key,
<a
href="https://tickflow.org/auth/register"
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 font-medium text-warning hover:underline"
>
<ExternalLink className="h-3 w-3 self-center" />
</a>
API Key,
</span>
</div>
)}
{/* 无本地数据提示 —— 引导用户去数据页同步 (仅当已配置 Key 时显示, 无 Key 时优先提示配置 Key) */}
{hasNoData && !isNoKey && (
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
,
<Link
to="/data"
className="ml-1 shrink-0 inline-flex items-center gap-0.5 font-medium text-warning hover:underline"
>
<ArrowUpRight className="h-3 w-3 self-center" />
</Link>
</span>
</div>
)}
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-card border border-border bg-surface/85 px-3 py-2">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
<h1 className="text-base font-semibold text-foreground"></h1>
<span
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={{
color: scoreColor(score),
borderColor: `${scoreColor(score)}40`,
background: `${scoreColor(score)}14`,
}}
>
{data.emotion.label} · {score}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted">
{currentDate ? (
<DatePicker
value={currentDate}
onChange={setSelectedDate}
min={dataStatus.data?.enriched?.earliest_date ?? undefined}
max={latestDate ?? undefined}
className="w-32"
/>
) : (
<span className="font-mono text-secondary"></span>
)}
<span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span>
<span className={quoteRunning ? 'text-accent' : 'text-warning'}>{quoteRunning ? '实时' : '非实时'}</span>
<button
onClick={handleRefresh}
disabled={manualFetching}
className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${manualFetching ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<div className="mb-3 grid grid-cols-4 gap-2">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
<div className="mb-3 grid grid-cols-6 gap-2">
<KpiCell label="个股涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
<KpiCell label="强势 / 弱势" value={<><span className="text-bull">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
<KpiCell label={<span className="inline-flex items-center gap-1"> / <SealedBadge degraded={isSealedDegrade} hasDepth={hasDepth} isHistorical={false} sealedReady={sealedReady} sealedCountsUp={{ real: data.limit.limit_up, fake: data.limit.fake_up ?? 0, pending: 0 }} sealedCountsDown={{ real: data.limit.limit_down, fake: data.limit.fake_down ?? 0, pending: 0 }} rawUp={data.limit.limit_up + (data.limit.fake_up ?? 0)} rawDown={data.limit.limit_down + (data.limit.fake_down ?? 0)} invalidateKeys={['overview-market', 'limit-ladder']} /></span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量 ${data.activity.high_vol_ratio}`} tone="accent" />
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_20rem]">
<main className="min-w-0 space-y-3">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<section className="rounded-card border border-border bg-surface/80 p-2.5">
<SectionTitle icon={BarChart3} title="涨跌分布 / 广度" hint={`${data.breadth.total}`} />
<DistributionBars rows={data.distribution} />
<div className="mt-2">
<BreadthBar data={data.breadth} />
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5">
<MiniMetric label="平均涨跌" value={fmtStockPct(data.breadth.avg_pct)} cls={pctClass(data.breadth.avg_pct)} />
<MiniMetric label="中位涨跌" value={fmtStockPct(data.breadth.median_pct)} cls={pctClass(data.breadth.median_pct)} />
</div>
</section>
<section
className="rounded-card border bg-surface/80 p-2.5"
style={{ borderColor: `${scoreColor(score)}40` }}
>
<SectionTitle icon={Sparkles} title="情绪雷达" hint={`情绪评分 ${score}`} />
<EmotionRadar radar={data.radar} score={score} />
</section>
<section className="flex flex-col rounded-card border border-border bg-surface/80 p-2.5">
<div>
<SectionTitle icon={LineChart} title="趋势强度" hint="均线/新高低" />
<div className="grid grid-cols-3 gap-1.5">
<MiniMetric label="站上MA5" value={`${data.trend.above_ma5_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="站上MA20" value={`${data.trend.above_ma20_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="站上MA60" value={`${data.trend.above_ma60_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="60日新高" value={compactCount(data.trend.new_high)} cls="text-bull" />
<MiniMetric label="60日新低" value={compactCount(data.trend.new_low)} cls="text-bear" />
<MiniMetric label="高低比" value={`${data.trend.new_high + data.trend.new_low > 0 ? Math.round(data.trend.new_high / (data.trend.new_high + data.trend.new_low) * 100) : 50}%`} cls={data.trend.new_high >= data.trend.new_low ? 'text-bull' : 'text-bear'} />
</div>
</div>
<div className="mt-3 border-t border-border pt-2.5">
<SectionTitle icon={Target} title="实用监控" hint="盘中观察" />
<div className="grid grid-cols-3 gap-1.5">
<MiniMetric label="炸板" value={`${data.limit.broken ?? 0}`} cls="text-warning" />
<MiniMetric label="跌停" value={`${data.limit.limit_down ?? 0}`} cls="text-bear" />
<MiniMetric label="站上MA60" value={`${data.trend.above_ma60_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="新高/新低" value={`${compactCount(data.trend.new_high)}/${compactCount(data.trend.new_low)}`} cls={data.trend.new_high >= data.trend.new_low ? 'text-bull' : 'text-bear'} />
<MiniMetric label="高换手数" value={`${data.activity.high_turnover}`} cls="text-accent" />
<MiniMetric label="放量家数" value={`${data.activity.high_vol_ratio}`} cls="text-accent" />
</div>
</div>
</section>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis" />
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StockList title="涨幅榜" rows={data.top_gainers} mode="gain" />
<StockList title="跌幅榜" rows={data.top_losers} mode="loss" />
<StockList title="成交额榜" rows={data.turnover_leaders} mode="amount" />
<StockList title="活跃换手" rows={data.active_leaders} mode="active" />
</div>
</main>
<aside className="min-w-0 space-y-3">
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
<LadderMini limit={data.limit} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<BellRing className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground"></h2>
<span className="font-mono text-[10px] text-muted"></span>
</div>
<Link to="/monitor" className="inline-flex items-center justify-center h-5 w-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors" title="进入监控中心">
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
<MonitorWidget />
</section>
</aside>
</div>
</div>
)
}
+977
View File
@@ -0,0 +1,977 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
Database,
Play,
Loader2,
HardDrive,
Clock,
Calendar,
Trash2,
Plus,
AlertTriangle,
Info,
} from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useQuoteInterval,
useDataStatus,
} from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
// 拆分出的子组件
import { StatCard, type FieldTab } from '@/components/data/StatCard'
import { ActiveJobCard } from '@/components/data/ActiveJobCard'
import { SectionTitle, HistoryRow } from '@/components/data/SectionTitle'
import { SettingsModal } from '@/components/data/SettingsModal'
import { ScheduleEditor } from '@/components/data/ScheduleEditor'
import { ExtendHistoryPanel } from '@/components/data/ExtendHistoryPanel'
import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton'
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
import { CreateExtDialog } from '@/components/ext-data/CreateExtDialog'
import { EditExtDialog } from '@/components/ext-data/EditExtDialog'
export function Data() {
const qc = useQueryClient()
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const startTime = useRef<number | null>(null)
const topRef = useRef<HTMLDivElement>(null)
const caps = useCapabilities()
const settings = useSettings()
const status = useDataStatus({
refetchInterval: activeJobId ? 2_000 : 30_000,
})
const history = useQuery({
queryKey: QK.pipelineJobs,
queryFn: () => api.pipelineJobs(15),
refetchInterval: activeJobId ? false : 60_000,
})
const job = useQuery({
queryKey: QK.pipelineJob(activeJobId ?? ''),
queryFn: () => api.pipelineJob(activeJobId!),
enabled: !!activeJobId,
refetchInterval: (q: any) => {
const j = q.state.data
return j && (j.status === 'succeeded' || j.status === 'failed') ? false : 1_000
},
})
const startSync = useMutation({
mutationFn: api.pipelineRun,
onSuccess: ({ job_id }) => {
setActiveJobId(job_id)
startTime.current = Date.now()
},
})
const [showClearConfirm, setShowClearConfirm] = useState(false)
const clearData = useMutation({
mutationFn: api.dataClear,
onSuccess: () => {
qc.invalidateQueries()
setShowClearConfirm(false)
},
})
const updateSchedule = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updatePipelineSchedule(hour, minute),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setShowScheduleEdit(false)
},
})
const updateInstSchedule = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateInstrumentsSchedule(hour, minute),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setShowInstScheduleEdit(false)
},
})
const [openSettings, setOpenSettings] = useState<string | null>(null)
const [showScheduleEdit, setShowScheduleEdit] = useState(false)
const [showInstScheduleEdit, setShowInstScheduleEdit] = useState(false)
const [indexExtendValue, setIndexExtendValue] = useState(6)
const [indexExtendUnit, setIndexExtendUnit] = useState<'month' | 'year'>('month')
const [schemaTable, setSchemaTable] = useState<string | null>(null)
// 测试端点入口已隐藏,后续需要时恢复下方状态与相关 JSX
// const [showEndpointTest, setShowEndpointTest] = useState(false)
const [showCreateExt, setShowCreateExt] = useState(false)
const [editingExt, setEditingExt] = useState<ExtDataConfig | null>(null)
const [indexBatchInput, setIndexBatchInput] = useState('100')
const extConfigs = useQuery({
queryKey: QK.extData,
queryFn: api.extDataList,
})
const deleteExt = useMutation({
mutationFn: (id: string) => api.extDataDelete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.extData }),
})
const syncIndexDaily = useMutation({
mutationFn: () => api.syncIndexDaily(indexSyncDays),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
const prefs = usePreferences()
const minuteAuto = prefs.data?.minute_sync_enabled ?? false
const pipelineSched = prefs.data?.pipeline_schedule ?? { hour: 15, minute: 30 }
const instrumentsSched = prefs.data?.instruments_schedule ?? { hour: 9, minute: 10 }
const indexDailyBatchSize = prefs.data?.index_daily_batch_size ?? 100
useEffect(() => {
setIndexBatchInput(String(indexDailyBatchSize))
}, [indexDailyBatchSize])
const updateIndexBatchSize = useMutation({
mutationFn: (size: number) => api.updateIndexDailyBatchSize(size),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const [showIntervalEdit, setShowIntervalEdit] = useState(false)
const handleToggleIntervalEdit = useCallback((fromEvent?: boolean) => {
setShowIntervalEdit(v => {
const next = !v
if (!fromEvent) {
window.dispatchEvent(new CustomEvent('quote-interval-editor-toggle', { detail: { source: 'data' } }))
}
return next
})
}, [])
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent
if (ce.detail?.source !== 'data') {
setShowIntervalEdit(v => !v)
}
}
window.addEventListener('quote-interval-editor-toggle', handler)
return () => window.removeEventListener('quote-interval-editor-toggle', handler)
}, [])
const quoteInterval = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const realtimeEnabled = prefs.data?.realtime_quotes_enabled ?? false
const quoteStatus = useQuoteStatus()
const toggleQuote = useToggleRealtimeQuotes()
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
const pipelineSteps = ['日K', ...(hasAdjCap ? ['复权'] : []), '指标', '指数', ...((hasMinuteCap && minuteAuto) ? ['分钟K'] : [])]
useEffect(() => {
if (job.data && (job.data.status === 'succeeded' || job.data.status === 'failed')) {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
const t = setTimeout(() => setActiveJobId(null), 5_000)
return () => clearTimeout(t)
}
}, [job.data?.status])
useEffect(() => {
if (job.isError && /404/.test(String((job.error as any)?.message ?? ''))) {
setActiveJobId(null)
}
}, [job.isError, job.error])
useEffect(() => {
if (!activeJobId && history.data?.active_id) {
setActiveJobId(history.data.active_id)
}
}, [history.data?.active_id])
const s = status.data
const isLoading = status.isLoading
const isRunning = job.data?.status === 'running' || job.data?.status === 'pending'
const isStarting = startSync.isPending
const hasData = !!(s?.instruments?.rows || s?.daily?.rows)
// none 档(无 key / 无效 key) → 禁用立即同步 (同步依赖付费档的批量端点)
const isNoKey = settings.data?.mode === 'none'
const indexOverviewStats = s ? {
rows: 0,
earliest_date: s.index_daily?.earliest_date ?? s.index_enriched?.earliest_date ?? null,
latest_date: s.index_daily?.latest_date ?? s.index_enriched?.latest_date ?? null,
symbols_covered: s.index_daily?.symbols_covered ?? s.index_instruments?.rows ?? 0,
trading_days: s.index_daily?.trading_days ?? s.index_enriched?.trading_days ?? 0,
} : null
const indexOverviewLabel = s ? '日 · 维表 · 日K · 指标' : undefined
const indexEarliestDate = s?.index_daily?.earliest_date ?? s?.index_enriched?.earliest_date ?? null
const indexOffsetDays = indexExtendUnit === 'month' ? indexExtendValue * 30 : indexExtendValue * 365
const indexTargetDate = (() => {
const d = indexEarliestDate ? new Date(indexEarliestDate) : new Date()
d.setDate(d.getDate() - indexOffsetDays)
return d
})()
const indexTargetDateText = indexTargetDate.toISOString().slice(0, 10)
const indexSyncDays = Math.min(
5000,
Math.max(30, Math.ceil((Date.now() - indexTargetDate.getTime()) / 86_400_000) + 1),
)
const STAGE_CARD: Record<string, string> = {
sync_instruments: 'instruments',
sync_daily: 'daily',
extend_history: 'daily',
sync_adj: 'adj_factor',
compute_enriched: 'enriched',
rebuild_enriched: 'enriched',
sync_index: 'index_daily',
sync_minute: 'minute',
extend_minute: 'minute',
}
const activeCard = isRunning && job.data ? STAGE_CARD[job.data.stage] ?? null : null
const skippedCards = new Set(
(job.data?.result?.skipped_stages ?? [])
.map(s => STAGE_CARD[s])
.filter(Boolean) as string[]
)
const prevStageRef = useRef<string | null>(null)
const [doneStages, setDoneStages] = useState<Set<string>>(new Set())
useEffect(() => {
if (!job.data?.stage) return
const stage = job.data.stage
if (stage === prevStageRef.current) return
const prev = prevStageRef.current
if (prev && STAGE_CARD[prev]) {
setDoneStages((s) => new Set(s).add(STAGE_CARD[prev]))
}
prevStageRef.current = stage
qc.invalidateQueries({ queryKey: QK.dataStatus })
}, [job.data?.stage])
useEffect(() => {
if (!activeJobId) {
setDoneStages(new Set())
prevStageRef.current = null
}
}, [activeJobId])
const handleJobClick = useCallback((id: string) => {
setActiveJobId(id)
requestAnimationFrame(() => {
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [])
return (
<>
<div ref={topRef} />
<PageHeader
title="数据"
subtitle="本地数据画像 · 同步状态 · 历史记录"
right={
<div className="flex items-center gap-3">
{!hasData && !isLoading && !isNoKey && (
<span className="text-xs text-accent animate-pulse">使</span>
)}
{isNoKey ? (
<button
disabled
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium opacity-40 cursor-not-allowed transition-all duration-150"
>
<Play className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => startSync.mutate()}
disabled={isStarting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150"
>
{(isRunning || isStarting) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
</button>
)}
<div className="w-px h-4 bg-border" />
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowCreateExt(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<Plus className="h-3.5 w-3.5" />
</button>
{/* 测试端点入口已隐藏
<button
onClick={() => setShowEndpointTest(true)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
>
<Wifi className="h-3.5 w-3.5" />
测试端点
</button>
*/}
<button
onClick={() => setShowClearConfirm(true)}
disabled={isRunning}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-muted hover:text-danger hover:bg-danger/8 text-xs transition-colors duration-150 disabled:opacity-40 disabled:pointer-events-none"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-6xl">
{/* 未配置 API Key 告警条 —— 引导用户去配置 Key 后才能同步 */}
{isNoKey && (
<div className="flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-secondary leading-relaxed">
API Key,,
<Link to="/settings?tab=account" className="mx-0.5 font-medium text-warning hover:underline">
</Link>
</span>
</div>
)}
{/* 实时进度 */}
<AnimatePresence>
{job.data && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ActiveJobCard job={job.data} />
</motion.div>
)}
</AnimatePresence>
{/* 实时行情 + 存储 + 调度 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<QuoteConfigCard
enabled={realtimeEnabled}
running={quoteStatus.data?.running ?? false}
isTrading={quoteStatus.data?.is_trading_hours ?? false}
lastFetchMs={quoteStatus.data?.last_fetch_ms ?? null}
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 10}
intervalMin={quoteInterval.data?.min_interval ?? 5}
intervalMax={quoteInterval.data?.max_interval ?? 60}
loading={quoteStatus.isLoading}
onToggle={(v) => toggleQuote.mutate(v)}
toggling={toggleQuote.isPending}
showIntervalEdit={showIntervalEdit}
onShowIntervalEdit={handleToggleIntervalEdit}
onIntervalChange={(v) => updateInterval.mutate(v)}
/>
{/* 自动调度 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
{isLoading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-16" /><Skeleton w="w-28" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-16" /><Skeleton w="w-28" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-6" /><Skeleton w="w-20" /></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted pb-2 border-b border-border/50">
<span className="text-accent/60 font-medium"></span>
<span></span>
<span className="text-border"></span>
<span className="text-accent/60 font-medium"></span>
{pipelineSteps.map((step, i) => (
<span key={step} className="contents">
{i > 0 && <span className="text-border"></span>}
<span>{step}</span>
</span>
))}
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">Asia/Shanghai</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"> · </span>
<span className="text-muted/50">·</span>
<span className="font-mono text-secondary">
{`${String(instrumentsSched.hour).padStart(2, '0')}:${String(instrumentsSched.minute).padStart(2, '0')}`}
</span>
<button
onClick={() => setShowInstScheduleEdit(v => !v)}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showInstScheduleEdit ? 'text-accent' : 'text-secondary'}`}
>
<Clock className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-2 font-mono text-secondary">
{s?.last_instruments_run && (
<span className={`inline-flex flex-col items-center leading-tight ${isToday(s.last_instruments_run) ? 'text-bear' : 'text-secondary/70'}`}>
<span> {formatScheduleDatePart(s.last_instruments_run)}</span>
<span>{formatScheduleTimePart(s.last_instruments_run)}</span>
</span>
)}
{s?.next_instruments_run && (
<span className="inline-flex flex-col items-center leading-tight text-foreground">
<span> {formatScheduleDatePart(s.next_instruments_run)}</span>
<span>{formatScheduleTimePart(s.next_instruments_run)}</span>
</span>
)}
</div>
</div>
<AnimatePresence>
{showInstScheduleEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ScheduleEditor
value={instrumentsSched}
onSave={(h, m) => updateInstSchedule.mutate({ hour: h, minute: m })}
loading={updateInstSchedule.isPending}
hint="不晚于 09:15"
/>
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"> · </span>
<span className="text-muted/50">·</span>
<span className="font-mono text-secondary">
{`${String(pipelineSched.hour).padStart(2, '0')}:${String(pipelineSched.minute).padStart(2, '0')}`}
</span>
<button
onClick={() => setShowScheduleEdit(v => !v)}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showScheduleEdit ? 'text-accent' : 'text-secondary'}`}
>
<Clock className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-2 font-mono text-secondary">
{s?.last_pipeline_run && (
<span className={`inline-flex flex-col items-center leading-tight ${isToday(s.last_pipeline_run) ? 'text-bear' : 'text-secondary/70'}`}>
<span> {formatScheduleDatePart(s.last_pipeline_run)}</span>
<span>{formatScheduleTimePart(s.last_pipeline_run)}</span>
</span>
)}
{s?.next_pipeline_run && (
<span className="inline-flex flex-col items-center leading-tight text-foreground">
<span> {formatScheduleDatePart(s.next_pipeline_run)}</span>
<span>{formatScheduleTimePart(s.next_pipeline_run)}</span>
</span>
)}
</div>
</div>
<AnimatePresence>
{showScheduleEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<ScheduleEditor
value={pipelineSched}
onSave={(h, m) => updateSchedule.mutate({ hour: h, minute: m })}
loading={updateSchedule.isPending}
hint="不早于 15:00"
/>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* 存储 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
{isLoading ? (
<Skeleton w="w-12" />
) : (
<span className="font-mono text-xs text-muted">{s ? `${s.storage.total_size_mb} MB` : '—'}</span>
)}
</div>
<div className="space-y-2">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton w="w-10" />
<div className="flex items-center gap-3">
<Skeleton w="w-14" />
<Skeleton w="w-16" />
</div>
</div>
))
) : [
{ label: '标的维表', files: s?.storage.instruments_files, size: s?.storage.instruments_size_mb },
{ label: '日 K', files: s?.storage.daily_files, size: s?.storage.daily_size_mb },
{ label: '除权因子', files: s?.storage.adj_factor_files, size: s?.storage.adj_factor_size_mb },
{ label: 'Enriched', files: s?.storage.enriched_files, size: s?.storage.enriched_size_mb },
{ label: '分钟 K', files: s?.storage.minute_files, size: s?.storage.minute_size_mb },
{ label: '财务数据', files: s?.storage.financials_files, size: s?.storage.financials_size_mb },
].map((item) => (
<div key={item.label} className="flex items-center justify-between text-[11px]">
<span className="text-muted">{item.label}</span>
<div className="flex items-center gap-3">
<span className="font-mono text-secondary">{item.files ?? 0} </span>
<span className="font-mono text-muted w-16 text-right">{(item.size ?? 0).toFixed(1)} MB</span>
</div>
</div>
))}
{/* 扩展数据 */}
{(extConfigs.data && (extConfigs.data.items?.length ?? 0) > 0) && (
<div className="flex items-center justify-between text-[11px] border-t border-border/50 pt-2 mt-1">
<span className="text-muted"></span>
<div className="flex items-center gap-3">
<span className="font-mono text-secondary">{s?.storage.ext_data_files ?? extConfigs.data.items.length} </span>
<span className="font-mono text-muted w-16 text-right">
{s?.storage.ext_data_size_mb != null ? `${s.storage.ext_data_size_mb.toFixed(1)} MB` : '—'}
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* 数据画像 */}
<div>
<SectionTitle icon={Database}></SectionTitle>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 items-stretch">
<StatCard
title="标的维表"
hint="盘前同步 · 元数据快照"
stats={s?.instruments}
isInstrument
loading={isLoading}
active={activeCard === 'instruments'}
done={doneStages.has('instruments')}
skipped={skippedCards.has('instruments')}
stagePct={activeCard === 'instruments' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="instruments"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('instruments')}
/>
<StatCard
title="日 K"
hint="增量同步 · 全市场"
stats={s?.daily}
loading={isLoading}
active={activeCard === 'daily'}
done={doneStages.has('daily')}
skipped={skippedCards.has('daily')}
stagePct={activeCard === 'daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'daily' ? null : 'daily') : undefined}
settingsOpen={openSettings === 'daily'}
/>
<StatCard
title="除权因子"
hint="增量同步 · 全市场"
stats={s?.adj_factor}
loading={isLoading}
active={activeCard === 'adj_factor'}
done={doneStages.has('adj_factor')}
skipped={skippedCards.has('adj_factor')}
stagePct={activeCard === 'adj_factor' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="adj_factor"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
onShowFields={() => setSchemaTable('adj_factor')}
/>
<StatCard
title="Enriched"
hint="复权 OHLCV + 技术指标"
stats={s?.enriched}
loading={isLoading}
active={activeCard === 'enriched'}
done={doneStages.has('enriched')}
skipped={skippedCards.has('enriched')}
stagePct={activeCard === 'enriched' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="enriched"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel="字段 · 指标 · 信号"
localBadgeSuffix={`${prefs.data?.enriched_batch_size ?? 1000}只/批`}
onShowFields={() => setSchemaTable('enriched')}
onSettings={hasData ? () => setOpenSettings(v => v === 'enriched' ? null : 'enriched') : undefined}
settingsOpen={openSettings === 'enriched'}
/>
<StatCard
title="指数数据"
hint="CN_Index · 独立存储"
stats={indexOverviewStats}
loading={isLoading}
active={activeCard === 'index_daily'}
done={doneStages.has('index_daily')}
skipped={skippedCards.has('index_daily')}
stagePct={activeCard === 'index_daily' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="daily"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto
subLabel={indexOverviewLabel}
fieldTabs={[
{ label: '维表', table: 'index_instruments' },
{ label: '日K', table: 'index_daily' },
{ label: '指标', table: 'index_enriched' },
] as FieldTab[]}
onShowFields={(t) => setSchemaTable(t ?? 'index_daily')}
onSettings={hasData ? () => setOpenSettings(v => v === 'index' ? null : 'index') : undefined}
settingsOpen={openSettings === 'index'}
/>
<StatCard
title="分钟 K"
hint="全市场同步"
stats={s?.minute}
loading={isLoading}
active={activeCard === 'minute'}
done={doneStages.has('minute')}
skipped={skippedCards.has('minute')}
stagePct={activeCard === 'minute' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="minute"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={minuteAuto}
onShowFields={() => setSchemaTable('minute')}
onSettings={hasData ? () => setOpenSettings(v => v === 'minute' ? null : 'minute') : undefined}
settingsOpen={openSettings === 'minute'}
/>
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
stats={s?.financials ? { rows: s.financials.rows } : null}
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
/>
{(extConfigs.data?.items ?? []).map((ext) => (
<ExtDataStatCard
key={ext.id}
config={ext}
onDelete={() => deleteExt.mutate(ext.id)}
deleting={deleteExt.isPending}
onEdit={() => setEditingExt(ext)}
/>
))}
</div>
</div>
{/* 同步历史 */}
<div>
<SectionTitle icon={Clock}></SectionTitle>
<div className="mt-3 rounded-card border border-border overflow-hidden">
{history.isLoading ? (
<div className="px-5 py-6 space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Skeleton w="w-4" h="h-4" rounded="rounded-full" />
<div className="space-y-1.5">
<Skeleton w="w-20" />
<Skeleton w="w-28" h="h-3" />
</div>
</div>
<Skeleton w="w-32" />
</div>
))}
</div>
) : history.data && history.data.jobs.length > 0 ? (
<div className="divide-y divide-border">
{history.data.jobs.map((j) => (
<HistoryRow key={j.id} job={j} onClick={() => handleJobClick(j.id)} />
))}
</div>
) : (
<div className="px-5 py-8 text-center text-sm text-muted">
"立即同步"
</div>
)}
</div>
</div>
{startSync.isError && (
<div className="rounded-btn border border-danger/40 bg-danger/5 px-3 py-2 text-sm text-danger">
:{String((startSync.error as any).message)}
</div>
)}
</div>
{/* 弹窗 */}
<EnrichedSchemaModal
table={schemaTable}
onClose={() => setSchemaTable(null)}
/>
{/* 测试端点弹窗已隐藏
{showEndpointTest && (
<EndpointTestDialog
hasKey={settings.data?.mode === 'api_key'}
tierLabel={settings.data?.tier_label ?? ''}
currentEndpoint={settings.data?.current_endpoint ?? ''}
onClose={() => setShowEndpointTest(false)}
/>
)}
*/}
<AnimatePresence>
{showCreateExt && (
<CreateExtDialog onClose={() => setShowCreateExt(false)} />
)}
{editingExt && (
<EditExtDialog config={editingExt} onClose={() => setEditingExt(null)} />
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'daily' && (
<SettingsModal title="日 K · 向前扩展历史" onClose={() => setOpenSettings(null)}>
<ExtendHistoryPanel
caps={caps.data}
isRunning={!!activeJobId}
earliestDate={s?.daily?.earliest_date ?? null}
onStart={() => setOpenSettings(null)}
/>
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'enriched' && (
<SettingsModal title="Enriched · 计算设置" onClose={() => setOpenSettings(null)}>
<EnrichedRebuildPanel isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'index' && (
<SettingsModal title="指数数据 · 手动获取" onClose={() => setOpenSettings(null)}>
<div className="space-y-4">
<div className="rounded-card border border-border bg-base/30 p-4 space-y-3">
<div>
<div className="text-sm font-medium text-foreground"> K</div>
<div className="text-[11px] text-muted mt-1"> CN_Index </div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setIndexExtendValue(v => Math.max(1, v - 1))}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{indexExtendValue}
</div>
<button
onClick={() => setIndexExtendValue(v => Math.min(indexExtendUnit === 'year' ? 10 : 36, v + 1))}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
<div className="flex rounded-btn border border-border overflow-hidden">
{(['month', 'year'] as const).map(u => (
<button
key={u}
onClick={() => { setIndexExtendUnit(u); if (u === 'year' && indexExtendValue > 10) setIndexExtendValue(1); if (u === 'month' && indexExtendValue > 36) setIndexExtendValue(6) }}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors disabled:opacity-40 ${
indexExtendUnit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'month' ? '月' : '年'}</button>
))}
</div>
</div>
<div className="text-[10px] text-muted">
<span className="font-mono text-secondary">{indexTargetDateText}</span>
{indexEarliestDate && <span> (: <span className="font-mono text-secondary">{indexEarliestDate}</span>)</span>}
</div>
<div className="rounded-btn border border-border bg-base/40 p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-foreground"></div>
<div className="text-[10px] text-muted mt-0.5"> 100</div>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={10000}
value={indexBatchInput}
onChange={e => setIndexBatchInput(e.target.value)}
disabled={updateIndexBatchSize.isPending || !!activeJobId || syncIndexDaily.isPending}
className="w-20 px-2 py-1 rounded-btn bg-elevated border border-border text-xs font-mono text-foreground outline-none focus:border-accent disabled:opacity-40"
/>
<button
onClick={() => {
const size = Math.max(1, Math.min(10000, Number(indexBatchInput) || 100))
setIndexBatchInput(String(size))
updateIndexBatchSize.mutate(size)
}}
disabled={updateIndexBatchSize.isPending || !!activeJobId || syncIndexDaily.isPending}
className="px-2.5 py-1 rounded-btn bg-elevated border border-border text-xs text-secondary hover:text-foreground disabled:opacity-40 transition-colors"
>
{updateIndexBatchSize.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
<div className="text-[10px] text-muted">
: <span className="font-mono text-secondary">{indexDailyBatchSize}</span>
</div>
</div>
<button
onClick={() => syncIndexDaily.mutate()}
disabled={!hasDailyBatchCap || !!activeJobId || syncIndexDaily.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{syncIndexDaily.isPending ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
</>
) : (
<></>
)}
</button>
{!hasDailyBatchCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Starter+ / Pro K
</span>
)}
</div>
</div>
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'minute' && (
<SettingsModal title="分钟 K · 同步设置" onClose={() => setOpenSettings(null)}>
<MinuteSyncConfig caps={caps.data} isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
</SettingsModal>
)}
</AnimatePresence>
{/* 清除数据二次确认弹窗 */}
<AnimatePresence>
{showClearConfirm && (
<div className="fixed inset-0 z-50 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={() => !clearData.isPending && setShowClearConfirm(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-[420px] 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"></h3>
<p className="text-xs text-secondary leading-relaxed">
<span className="text-danger font-medium"></span>
</p>
<ul className="mt-2 text-[11px] text-muted leading-relaxed space-y-0.5">
<li>· K</li>
<li>· Enriched K</li>
<li>· </li>
</ul>
<p className="mt-2 text-[11px] text-danger/90">
</p>
<div className="mt-2 flex items-start gap-1.5 text-[11px] text-warning">
<Info className="h-3.5 w-3.5 shrink-0 mt-px text-warning" />
<span></span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setShowClearConfirm(false)}
disabled={clearData.isPending}
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={() => clearData.mutate()}
disabled={clearData.isPending}
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"
>
{clearData.isPending ? '清除中…' : '清除数据'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
)
}
+357
View File
@@ -0,0 +1,357 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, AlertTriangle, CheckCircle2, XCircle, FlaskConical, Activity } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { resetBadge } from '@/lib/monitorBadge'
// ── 分钟K探测 (迁移自 MinuteDataProbe) ─────────────────
interface ProbeResult {
date: string
rows: number
source: string
ok: boolean
}
function MinuteProbePanel() {
const [symbol, setSymbol] = useState('603261.SH')
const [days, setDays] = useState(10)
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<ProbeResult[]>([])
const [error, setError] = useState<string | null>(null)
const runProbe = async () => {
const sym = symbol.trim().toUpperCase()
if (!sym) return
setLoading(true)
setError(null)
setResults([])
const dates: string[] = []
const today = new Date()
for (let i = 0; i < days; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
dates.push(d.toISOString().slice(0, 10))
}
const out: ProbeResult[] = []
try {
for (const date of dates) {
const r = await api.klineMinute(sym, date)
const rows = r.rows?.length ?? 0
out.push({
date,
rows,
source: r.source ?? (rows > 0 ? 'local' : 'none'),
ok: rows > 0,
})
setResults([...out])
}
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setLoading(false)
}
}
const total = results.length
const hasData = results.filter((r) => r.ok).length
const missing = results.filter((r) => !r.ok)
return (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-foreground">K数据探测</h2>
<p className="mt-1 text-xs text-muted">
<code className="px-1 rounded bg-elevated text-secondary">/api/kline/minute</code>
K数据是否齐全
</p>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-btn bg-elevated p-4">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="603261.SH"
className="w-44 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
onKeyDown={(e) => e.key === 'Enter' && !loading && runProbe()}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
type="number"
min={1}
max={30}
value={days}
onChange={(e) => setDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
className="w-24 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</div>
<button
onClick={runProbe}
disabled={loading || !symbol.trim()}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{loading ? '探测中…' : '开始探测'}
</button>
</div>
{error && (
<div className="flex items-center gap-2 rounded-btn border border-danger/40 bg-danger/10 p-3 text-sm text-danger">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{total > 0 && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-foreground">{total}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-emerald-400">{hasData}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-danger">{missing.length}</div>
</div>
</div>
)}
{results.length > 0 && (
<div className="overflow-hidden rounded-btn border border-border">
<table className="w-full text-sm">
<thead className="bg-elevated text-xs text-muted">
<tr>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-right font-medium">K条数</th>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.date} className="border-t border-border/60">
<td className="px-4 py-2 text-foreground">{r.date}</td>
<td className="px-4 py-2 text-right tabular-nums text-foreground">{r.rows}</td>
<td className="px-4 py-2 text-secondary">
<span className="rounded bg-elevated px-1.5 py-0.5 text-xs">{r.source}</span>
</td>
<td className="px-4 py-2 text-center">
{r.ok ? (
<span className="inline-flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-danger">
<XCircle className="h-4 w-4" />
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{missing.length > 0 && (
<div className="rounded-btn border border-warning/40 bg-warning/10 p-3 text-xs text-foreground">
<div className="mb-1 flex items-center gap-1.5 font-medium text-warning">
<AlertTriangle className="h-4 w-4" />
</div>
<p className="leading-relaxed text-secondary">
<span className="text-foreground">/</span>
<span className="text-foreground"></span> 0
<span className="text-foreground"></span>K有成交量K
</p>
</div>
)}
</div>
)
}
// ── 演示数据生成 ──────────────────────────────────────
function SeedPanel() {
const qc = useQueryClient()
const [count, setCount] = useState(12)
const [recent, setRecent] = useState(true)
const [msg, setMsg] = useState('')
const seedMut = useMutation({
mutationFn: () => api.alertSeed(count, recent),
onSuccess: (data) => {
setMsg(`已生成 ${data.generated} 条触发记录`)
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
setTimeout(() => setMsg(''), 4000)
},
onError: () => {
setMsg('生成失败')
setTimeout(() => setMsg(''), 4000)
},
})
const clearMut = useMutation({
mutationFn: () => api.alertsClear(),
onSuccess: (data) => {
setMsg(`已清空 ${data.cleared} 条触发记录`)
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
resetBadge()
setTimeout(() => setMsg(''), 4000)
},
})
const ruleSeedMut = useMutation({
mutationFn: () => api.monitorRuleSeed(),
onSuccess: (data) => {
setMsg(`已生成 ${data.generated} 条监控规则`)
qc.invalidateQueries({ queryKey: ['monitor-rules'] })
setTimeout(() => setMsg(''), 4000)
},
onError: () => {
setMsg('规则生成失败')
setTimeout(() => setMsg(''), 4000)
},
})
return (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-foreground"></h2>
<p className="mt-1 text-xs text-muted">
,
</p>
</div>
<div className="space-y-3 rounded-btn bg-elevated p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
type="number"
min={1}
max={50}
value={count}
onChange={(e) => setCount(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
className="w-24 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</div>
<label className="flex items-center gap-1.5 pb-1.5">
<input
type="checkbox"
checked={recent}
onChange={(e) => setRecent(e.target.checked)}
className="h-3.5 w-3.5 accent-accent"
/>
<span className="text-xs text-secondary">"刚刚"()</span>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => seedMut.mutate()}
disabled={seedMut.isPending}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{seedMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <FlaskConical className="h-4 w-4" />}
</button>
<button
onClick={() => clearMut.mutate()}
disabled={clearMut.isPending}
className="flex items-center gap-1.5 rounded-btn border border-danger/40 bg-danger/10 px-4 py-1.5 text-sm font-medium text-danger hover:bg-danger/20 disabled:opacity-50 cursor-pointer"
>
{clearMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4" />}
</button>
</div>
</div>
{msg && (
<div className="rounded-btn border border-accent/40 bg-accent/10 p-3 text-sm text-accent">{msg}</div>
)}
{/* 监控规则生成 */}
<div className="space-y-3 rounded-btn bg-elevated p-4">
<div>
<h3 className="text-sm font-medium text-foreground"></h3>
<p className="mt-0.5 text-xs text-muted">
(///),
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => ruleSeedMut.mutate()}
disabled={ruleSeedMut.isPending}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{ruleSeedMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <FlaskConical className="h-4 w-4" />}
</button>
</div>
</div>
<div className="rounded-btn border border-border/40 bg-surface/40 p-4 text-xs leading-relaxed text-muted">
<div className="mb-1 font-medium text-secondary">使</div>
<ul className="list-disc space-y-0.5 pl-4">
<li>,,</li>
<li></li>
<li>///</li>
<li></li>
</ul>
</div>
</div>
)
}
// ── Dev 主页面 ────────────────────────────────────────
export function Dev() {
const [tab, setTab] = useState<'minute' | 'seed'>('seed')
return (
<div className="flex flex-col h-full">
<PageHeader
title="开发者工具"
subtitle="调试与测试 · 不暴露在菜单"
right={
<div className="flex items-center gap-1 rounded-btn bg-elevated p-0.5">
<button
onClick={() => setTab('seed')}
className={cn(
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
tab === 'seed' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
)}
>
<FlaskConical className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setTab('minute')}
className={cn(
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
tab === 'minute' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
)}
>
<Activity className="h-3.5 w-3.5" />K探测
</button>
</div>
}
/>
<div className="flex-1 overflow-auto px-5 py-4">
<div className="mx-auto max-w-3xl space-y-4">
{tab === 'minute' ? <MinuteProbePanel /> : <SeedPanel />}
</div>
</div>
</div>
)
}
+273
View File
@@ -0,0 +1,273 @@
import { useState, useRef } from 'react'
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities } from '@/lib/useSharedQueries'
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
import { fmtBigNum } from '@/lib/format'
const TABLE_LABELS: Record<string, string> = {
metrics: '核心指标',
income: '利润表',
balance_sheet: '资产负债表',
cash_flow: '现金流量表',
}
const TABLE_ICON: Record<string, typeof FileText> = {
metrics: Database,
income: FileText,
balance_sheet: FileText,
cash_flow: FileText,
}
export function Financials() {
const { data: caps } = useCapabilities()
const hasFinancial = caps?.capabilities?.['financial'] != null
const { data: status, isLoading } = useFinancialStatus()
const syncMut = useFinancialSync()
// 同步状态: 服务端 syncing 真值优先, 兜底本地 mutation pending
const syncing = (status?.syncing ?? false) || syncMut.isPending
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
const syncStartedAtRef = useRef<number | null>(null)
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
const syncSingleTableRef = useRef<string | null>(null)
// 选中的个股(模糊搜索结果);null 时显示搜索引导
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
if (!hasFinancial) {
return (
<>
<PageHeader title="财务" subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert" />
<div className="px-8 py-10">
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
<Lock className="h-6 w-6 text-warning" />
</div>
<h3 className="mt-4 text-base font-semibold text-foreground"> Expert </h3>
<p className="mt-2 text-xs leading-relaxed text-secondary">
Expert
</p>
</div>
</div>
</>
)
}
const handleSync = (table: string) => {
// 防重复点击:syncing 中不再触发(后端 run_now 也有 is_syncing 兜底)
if (syncing) return
// 记录开始时间: 全量同步判断所有 4 张表, 单表同步只判断这一张
syncStartedAtRef.current = Date.now()
syncSingleTableRef.current = table === 'all' ? null : table
syncMut.mutate(table, {
onSettled: () => {
syncStartedAtRef.current = null
syncSingleTableRef.current = null
},
})
}
const tables = status?.tables ?? {}
const available = status?.available ?? false
const lastSync = status?.last_sync ?? {}
// 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断
const syncStartedAt = syncStartedAtRef.current
const syncSingleTable = syncSingleTableRef.current
const isFullSync = syncing && syncStartedAt && !syncSingleTable // 全量同步
const isSingleSync = syncing && syncStartedAt && !!syncSingleTable // 单表同步
const TABLE_ORDER = ['metrics', 'income', 'balance_sheet', 'cash_flow'] as const
const tableDoneThisRound = (key: string): boolean => {
if (!syncStartedAt || !syncing) return false
// 单表同步: 只判断这一张表是否完成
if (syncSingleTable && key !== syncSingleTable) return false
const ls = lastSync[key]
if (!ls) return false
return new Date(ls).getTime() >= syncStartedAt
}
// 当前正在同步的表:
// 全量同步 → 第一个未完成的; 单表同步 → 那张表(未完成时)
const currentSyncingTable = syncing && syncStartedAt
? (syncSingleTable
? (tableDoneThisRound(syncSingleTable) ? null : syncSingleTable)
: TABLE_ORDER.find(t => !tableDoneThisRound(t)) ?? null)
: null
const syncedCount = TABLE_ORDER.filter(t => tableDoneThisRound(t)).length
// 卡片三态: 仅全量同步时未轮到的表显示"等待"; 单表同步时其他表保持原样
const isWaitingTable = (key: string): boolean =>
!!isFullSync && !tableDoneThisRound(key) && currentSyncingTable !== key
return (
<>
<PageHeader
title="财务"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert"
right={
<div className="flex items-center gap-2">
{syncing && (
<span className="text-xs text-accent/80 flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" />
{isFullSync
? `已同步 ${syncedCount}/4 张表…`
: isSingleSync
? `同步${TABLE_LABELS[syncSingleTable!] ?? syncSingleTable}`
: '同步中…'}
</span>
)}
<button
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => handleSync('all')}
disabled={syncing}
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
{syncing ? '同步中…' : '全部同步'}
</button>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-7xl">
{syncing && (
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
</div>
)}
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
{!isLoading && available && (
<div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(TABLE_LABELS).map(([key, label]) => {
const info = tables[key]
const TIcon = TABLE_ICON[key] ?? Database
const hasData = (info?.rows ?? 0) > 0
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
const doneThisRound = tableDoneThisRound(key)
const isThisSyncing = currentSyncingTable === key
const isWaiting = isWaitingTable(key)
const lsTime = lastSync[key]
return (
<div
key={key}
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
isThisSyncing
? 'border-accent/40 bg-accent/[0.04]'
: isWaiting
? 'border-border/50 bg-elevated/15'
: hasData
? 'border-border bg-surface'
: 'border-dashed border-border/60 bg-elevated/20'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{doneThisRound ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : isThisSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />
) : isWaiting ? (
<Hourglass className="h-3.5 w-3.5 text-muted/60" />
) : (
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
)}
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
<button
className="text-muted hover:text-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleSync(key)}
disabled={syncing}
title={syncing ? '正在同步…' : `同步${label}`}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
</button>
</div>
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
{fmtBigNum(info?.rows ?? 0)}
<span className="text-[10px] text-muted ml-1 font-normal"></span>
</div>
<div className="text-[11px] text-muted mt-0.5">
{fmtBigNum(info?.symbols ?? 0)}
</div>
<div className="mt-auto pt-2 border-t border-border/40 text-[10px] text-muted flex items-center gap-1">
<Clock className="h-2.5 w-2.5 shrink-0" />
{lsTime
? new Date(lsTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '尚未同步'}
</div>
</div>
)
})}
</div>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted" />
</div>
) : !available ? (
<div className="rounded-card border border-dashed border-border bg-surface px-6 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-muted" />
<div className="mt-3 text-sm text-secondary"></div>
<div className="mt-1 text-xs text-muted">"全部同步"</div>
</div>
) : (
<>
{/* 个股搜索区 */}
<div>
{selected ? (
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
</div>
<button
onClick={() => setSelected(null)}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-secondary hover:text-foreground rounded-btn border border-border hover:bg-elevated transition-colors shrink-0"
title="清除选择"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
// 未选股:醒目居中引导
<div className="flex flex-col items-center gap-3 py-8">
<div className="flex items-center gap-2 text-sm text-secondary">
<Search className="h-4 w-4 text-accent" />
<span></span>
</div>
<div className="w-full max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
</div>
<div className="text-[11px] text-muted"> 600000 / </div>
</div>
)}
</div>
{/* 个股详情 / 空引导 */}
<div className="pb-4">
{selected ? (
<StockFinancialDetail symbol={selected.symbol} name={selected.name} />
) : (
<EmptyState
icon={Search}
title="未选择股票"
hint="在上方搜索框输入股票代码或名称,选择后即可查看该股的核心指标、利润表、资产负债表与现金流量表。"
/>
)}
</div>
</>
)}
</div>
</>
)
}
+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>
)
}
@@ -0,0 +1,822 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Database,
Layers3,
RefreshCw,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, DimensionHeatmap, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
const KEYWORDS = ['industry', '行业', 'sector', '申万', '中信']
const CANDIDATE_FIELDS = ['industry', '行业', 'sector', '申万', '中信', '行业名称', 'industry_name', 'sector_name']
const PAGE_LIMIT = 12000
const MAX_RENDERED_INDUSTRIES = 120
const MAX_RENDERED_STOCKS = 160
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
type IndustryLevel = 1 | 2 | 3
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface IndustryStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
// ===== 配置持久化 =====
function loadConfig(): AnalysisFieldConfig {
return storage.industryAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.industryAnalysisConfig.set(c)
}
// ===== 自动选取最佳数据源 =====
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
let best = '', bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) { bestScore = score; best = c.id }
}
return best
}
// ===== 工具函数 =====
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
// ===== 龙头算法 =====
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
// ===== 行业统计计算 =====
function calcIndustryStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): IndustryStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: IndustryStat, b: IndustryStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
// ===== 行业层级分组 =====
function industryLevelName(key: string, level: IndustryLevel) {
const parts = key.split('-').map(s => s.trim()).filter(Boolean)
return parts[level - 1] || parts[parts.length - 1] || key
}
function groupByIndustryLevel(groups: DimensionGroup[], level: IndustryLevel): DimensionGroup[] {
const map = new Map<string, DimensionGroup>()
for (const group of groups) {
const key = industryLevelName(group.key, level)
const existing = map.get(key)
if (existing) {
existing.stocks.push(...group.stocks)
existing.count = existing.stocks.length
} else {
map.set(key, {
key,
count: group.stocks.length,
stocks: [...group.stocks],
metrics: { ...group.metrics },
})
}
}
return [...map.values()].sort((a, b) => b.count - a.count)
}
// ===== 主页面 =====
export function IndustryAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
const activeConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const industryLevel = fieldConfig.hierarchyLevel ?? 2
const groups = useMemo(() => groupByIndustryLevel(resolved.groups, industryLevel), [resolved.groups, industryLevel])
const stats = useMemo(() => {
return groups
.map(g => calcIndustryStat(g, marketMap))
.filter(s => s.count > 0)
}, [groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeIndustry = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const industryBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
// 热力图用的 quoteMap(兼容 DimensionHeatmap 组件接口)
const heatmapQuoteMap = useMemo(() => {
const map = new Map<string, { symbol: string; pct?: number; change_pct?: number; name?: string; [k: string]: unknown }>()
for (const [k, v] of marketMap) {
map.set(k, {
...v,
change_pct: v.change_pct ?? undefined,
name: v.name ?? undefined,
})
}
return map
}, [marketMap])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
if (!activeConfig) {
return (
<div className="flex h-full flex-col">
<PageHeader title="行业分析" />
<EmptyState icon={Database} title="暂无行业数据" hint={'请先在"数据"页面创建包含行业/板块字段的扩展数据源'} />
</div>
)
}
const industryLevelLabel = `${industryLevel}级行业`
return (
<>
<PageHeader
title="行业分析"
subtitle={`${industryLevelLabel} · ${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(245,158,11,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<HeroPanel leading={leading[0]} falling={falling[0]} activeIndustry={activeIndustry} industryBreadth={industryBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }}
/>
{/* 热力图 */}
{groups.length > 0 && (
<DimensionHeatmap
groups={groups}
quoteMap={heatmapQuoteMap}
selectedKey={selectedKey}
onSelect={k => setSelectedKey(k)}
colorScheme="amber"
/>
)}
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<IndustryRail
stats={filteredStats.slice(0, MAX_RENDERED_INDUSTRIES)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<IndustryFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
) : (
<EmptyState icon={Layers3} title="未匹配到行业数据" hint={resolved.hint || '请检查扩展数据是否包含行业/板块相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
/>
)}
</>
)
}
// ===== HeroPanel =====
function HeroPanel({
leading,
falling,
activeIndustry,
industryBreadth,
}: {
leading?: IndustryStat
falling?: IndustryStat
activeIndustry?: IndustryStat | null
industryBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强行业" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌行业"
value={<><span className="text-bull">{industryBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{industryBreadth.down}</span></>}
hint={<><span className="text-bull"></span><span className="mx-1 text-muted">/</span><span className="text-bear"></span>{industryBreadth.flat ? <span className="text-muted"> · {industryBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeIndustry?.key ?? '—'} hint={activeIndustry ? fmtBigNum(activeIndustry.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
// ===== MarketPulse =====
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
}: {
leading: IndustryStat[]
falling: IndustryStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
}: {
title: string
items: IndustryStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string) => void
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40"></span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
// ===== IndustryRail(左侧列表面板) =====
function IndustryRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: IndustryStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索行业" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-amber-500/15 text-amber-400' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-amber-500/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}</span>
<span className="text-bull">{item.upCount}</span>
<span className="text-bear">{item.downCount}</span>
<span className="ml-auto"> {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
// ===== IndustryFocus(右侧聚焦面板) =====
function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string) => void }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-400"> {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} </span>
<span className={priceColorClass(stat.avgPct)}> {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span> {(stat.upRate * 100).toFixed(0)}%</span>
<span> {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span> {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted"> {MAX_RENDERED_STOCKS} {stat.stocks.length} </div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<span className="text-[11px] text-muted"> / / / / / </span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { api, type MonitorRule, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtPrice, fmtPct } from '@/lib/format'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { boardTag } from '@/components/stock-table/primitives'
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
import { RuleEditor } from '@/components/monitor/RuleEditor'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
const TYPE_LABEL: Record<string, string> = {
signal: '个股信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控',
}
/** 严重级别 → 左侧色条 + 图标 */
const SEVERITY_CONFIG: Record<string, { bar: string; icon: any; iconCls: string }> = {
info: { bar: 'bg-accent/40', icon: Bell, iconCls: 'text-accent' },
warn: { bar: 'bg-warning', icon: TrendingUp, iconCls: 'text-warning' },
critical: { bar: 'bg-danger', icon: Flame, iconCls: 'text-danger' },
}
const SOURCE_BADGE_STYLE: Record<string, string> = {
strategy: 'bg-amber-400/10 text-amber-400 border-amber-400/20',
signal: 'bg-accent/10 text-accent border-accent/20',
price: 'bg-emerald-400/10 text-emerald-400 border-emerald-400/20',
market: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
}
/**
* 渲染策略类消息 — 策略名黄色、新入选绿、移出红、其余白色。
*/
function renderMessage(source: string, message: string) {
if (source !== 'strategy') {
return <span className="text-secondary">{message}</span>
}
const m = message.match(/^(策略「)([^」]+)(」)(新入选|移出)( .*)$/)
if (!m) return <span className="text-foreground">{message}</span>
const [, pre, strategyName, mid, direction, post] = m
return (
<>
<span className="text-foreground/80">{pre}</span>
<span className="text-amber-400 font-medium">{strategyName}</span>
<span className="text-foreground/80">{mid}</span>
<span className={direction === '新入选' ? 'text-emerald-400 font-medium' : 'text-danger font-medium'}>{direction}</span>
<span className="text-foreground/80">{post}</span>
</>
)
}
export function Monitor() {
const qc = useQueryClient()
const [editorOpen, setEditorOpen] = useState(false)
const [editingRule, setEditingRule] = useState<MonitorRule | null>(null)
// 触发记录: 过滤 + 统计 (提升到主组件, 供 header 行使用)
const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market'>('all')
const [confirmClear, setConfirmClear] = useState(false)
const [confirmClearRules, setConfirmClearRules] = useState(false)
const alertsQuery = useQuery({
queryKey: QK.alerts(filter === 'all' ? undefined : filter),
queryFn: () => api.alertsList({ days: 7, limit: 500, source: filter === 'all' ? undefined : filter }),
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const total = alertsQuery.data?.total ?? 0
// 规则个数
const rulesQuery = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList })
const rulesCount = rulesQuery.data?.rules.length ?? 0
// 清除全部规则 (逐条删除)
const clearRulesMut = useMutation({
mutationFn: async () => {
const rules = rulesQuery.data?.rules ?? []
await Promise.all(rules.map(r => api.monitorRuleDelete(r.id)))
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.monitorRules })
setConfirmClearRules(false)
},
})
// 进入监控页: 清零未读徽标 + 记录"进入时刻", 之后新增的记录会闪烁
// 离开监控页: 停止同步, 之后新增才计入未读
const enterTsRef = useRef<number>(Date.now())
useEffect(() => {
enterTsRef.current = Date.now()
markSeen()
return () => leaveMonitorPage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex flex-col h-full">
<PageHeader title="监控中心" subtitle="实时信号与规则管理" />
<div className="flex-1 min-h-0 px-5 py-4">
<div className="mx-auto flex h-full max-w-7xl flex-col gap-4 lg:flex-row">
{/* 左栏: 触发记录 */}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface/40 shadow-lg shadow-black/5">
<div className="flex items-center gap-3 border-b border-border/60 bg-surface/60 px-4 py-2.5">
<SectionHeader icon={BellRing} title="触发记录" />
{/* 过滤标签 */}
<div className="flex flex-wrap items-center gap-0.5">
{(['all', 'strategy', 'signal', 'price', 'market'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-all cursor-pointer',
filter === f ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-elevated/60 hover:text-secondary',
)}
>
{f === 'all' ? '全部' : TYPE_LABEL[f]}
</button>
))}
</div>
{/* 数量 + 清空 */}
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{total}</span>
{total > 0 && (
<button
onClick={() => setConfirmClear(true)}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-muted transition-colors hover:bg-danger/10 hover:text-danger cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3.5">
<AlertsList alertsQuery={alertsQuery} confirmClear={confirmClear} setConfirmClear={setConfirmClear} total={total} enterTs={enterTsRef.current} />
</div>
</section>
{/* 右栏: 监控规则 */}
<section className="flex min-h-0 w-full flex-col overflow-hidden rounded-xl border border-border bg-surface/40 shadow-lg shadow-black/5 lg:w-[400px] lg:shrink-0">
<div className="flex items-center gap-3 border-b border-border/60 bg-surface/60 px-4 py-2.5">
<SectionHeader icon={ListChecks} title="监控规则" />
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{rulesCount}</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={() => { setEditingRule(null); setEditorOpen(true) }}
title="新建规则"
className="inline-flex h-6 w-6 items-center justify-center rounded-lg border border-border/60 bg-surface text-muted transition-all hover:border-accent/40 hover:text-accent hover:shadow-sm cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirmClearRules(true)}
disabled={rulesCount === 0}
title="清除全部规则"
className="inline-flex h-6 w-6 items-center justify-center rounded-lg border border-border/60 bg-surface text-muted transition-all hover:border-danger/40 hover:text-danger disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3.5">
<RulesList
rulesQuery={rulesQuery}
onEdit={(r) => { setEditingRule(r); setEditorOpen(true) }}
/>
</div>
</section>
</div>
</div>
<RuleEditorDialog
open={editorOpen}
rule={editingRule}
onClose={() => { setEditorOpen(false); setEditingRule(null) }}
/>
<ConfirmDialog
open={confirmClearRules}
title="清除全部监控规则?"
message={`将删除全部 ${rulesCount} 条规则,此操作不可撤销。`}
confirmText="清除"
danger
onCancel={() => setConfirmClearRules(false)}
onConfirm={() => clearRulesMut.mutate()}
pending={clearRulesMut.isPending}
/>
</div>
)
}
function SectionHeader({ icon: Icon, title }: { icon: any; title: string }) {
return (
<div className="flex items-center gap-1.5 shrink-0">
<Icon className="h-4 w-4 text-accent" />
<h2 className="text-sm font-semibold text-foreground whitespace-nowrap">{title}</h2>
</div>
)
}
// ── 触发记录列表 ──────────────────────────────────────
function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs }: {
alertsQuery: ReturnType<typeof useQuery>
confirmClear: boolean
setConfirmClear: (v: boolean) => void
total: number
enterTs: number
}) {
const qc = useQueryClient()
const [confirmTs, setConfirmTs] = useState<number | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const clearMut = useMutation({
mutationFn: api.alertsClear,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['alerts'] }); setConfirmClear(false); resetBadge() },
})
const delMut = useMutation({
mutationFn: (ts: number) => api.alertDelete(ts),
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
})
// 点击删除: 第一次进入确认态, 第二次真删, 3 秒后自动复位
const handleClickDelete = (ts: number) => {
if (confirmTs === ts) {
// 第二次点击 → 真删
if (resetTimer.current) clearTimeout(resetTimer.current)
setConfirmTs(null)
delMut.mutate(ts)
} else {
// 第一次点击 → 进入确认态, 3 秒后自动复位
setConfirmTs(ts)
if (resetTimer.current) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setConfirmTs(null), 3000)
}
}
const events = (alertsQuery.data as any)?.alerts ?? []
return (
<div className="space-y-3">
{events.length === 0 ? (
<EmptyState
icon={Bell}
title="暂无触发记录"
hint="监控规则命中后,触发记录会出现在这里。可在右侧配置规则,或在个股详情页加入监控。"
/>
) : (
<div className="space-y-2">
{events
.filter((ev: any) => !(ev.source === 'strategy' && !ev.symbol))
.map((ev: any, i: number) => {
const sev = SEVERITY_CONFIG[ev.severity ?? 'info'] ?? SEVERITY_CONFIG.info
const SevIcon = sev.icon
const isNew = ev.ts > enterTs
return (
<motion.div
key={`${ev.ts}-${i}`}
initial={isNew ? { opacity: 0, y: -8, scale: 0.98 } : { opacity: 0, y: 4 }}
animate={isNew ? {
opacity: [0, 1, 1, 0.85, 1],
scale: [0.98, 1, 1, 1.01, 1],
y: [-8, 0, 0, 0, 0],
} : { opacity: 1, y: 0 }}
transition={isNew ? { duration: 1.2, times: [0, 0.2, 0.5, 0.75, 1] } : { duration: 0.2, delay: Math.min(i * 0.02, 0.2) }}
className={cn(
'group relative flex items-start gap-3 overflow-hidden rounded-lg border bg-surface pl-3.5 pr-3 py-2.5 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md hover:shadow-black/10 hover:-translate-y-px',
isNew ? 'border-accent/60 ring-1 ring-accent/30' : 'border-border/50',
)}
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev.bar)} />
<div className={cn('mt-px shrink-0', sev.iconCls)}>
<SevIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
{ev.source === 'strategy' ? (() => {
const sm = ev.message?.match(/策略「([^」]+)」/)
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
const _pct = ev.change_pct ?? 0
return (
<>
<div className="flex items-center gap-2 flex-wrap">
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
{board && (
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)}
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
</button>
)
})()}
{ev.price != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono', _pct >= 0 ? 'text-danger' : 'text-bear')}>
{_pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPrice(ev.price)}
</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[11px] font-mono font-medium',
_pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(_pct)}
</span>
)}
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE.strategy)}>
{sname}
</span>
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className={cn('text-[11px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[11px] text-foreground/80"></span>
<span className="text-[11px] font-medium text-amber-400">{sname}</span>
</div>
</>
)
})() : (
<>
<div className="flex items-center gap-2 flex-wrap">
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
{board && (
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)}
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
</button>
)
})()}
{ev.price != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono', (ev.change_pct ?? 0) >= 0 ? 'text-danger' : 'text-bear')}>
{(ev.change_pct ?? 0) >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPrice(ev.price)}
</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[11px] font-mono font-medium',
ev.change_pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(ev.change_pct)}
</span>
)}
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE[ev.source] ?? 'bg-elevated text-muted border-border')}>
{(() => {
// 优先用规则名 (如 "策略监控 · 空中加油" → "空中加油"); 退回到 type 标签
const rn = ev.rule_name ?? ''
const dotIdx = rn.indexOf(' · ')
return dotIdx >= 0 ? rn.slice(dotIdx + 3) : (rn || (TYPE_LABEL[ev.source] ?? ev.source))
})()}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{ev.signals.map((s: string, j: number) => (
<span key={j} className="rounded bg-accent/8 px-1.5 py-0.5 text-[9px] text-accent/70">{cnSignal(s)}</span>
))}
</div>
)}
</>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-[10px] text-muted/60 font-mono">
{new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
{confirmTs === ev.ts ? (
// 确认态: 红色实心按钮 (原删除图标位置), 再点确认删除
<button
onClick={() => handleClickDelete(ev.ts)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleClickDelete(ev.ts)}
disabled={delMut.isPending}
title="删除"
className="rounded p-1 text-muted/0 transition-colors group-hover:text-muted/40 hover:!text-danger hover:bg-danger/10 cursor-pointer"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</motion.div>
)
})}
</div>
)}
<ConfirmDialog
open={confirmClear}
title="清空全部触发记录?"
message={`将删除全部 ${total} 条记录,此操作不可撤销。`}
confirmText="清空"
danger
onCancel={() => setConfirmClear(false)}
onConfirm={() => clearMut.mutate()}
pending={clearMut.isPending}
/>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
ts: previewEv.ts,
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
/>
</div>
)
}
// ── 监控规则列表 ──────────────────────────────────────
function RulesList({ rulesQuery, onEdit }: {
rulesQuery: ReturnType<typeof useQuery>
onEdit: (rule: MonitorRule) => void
}) {
const qc = useQueryClient()
const [confirmId, setConfirmId] = useState<string | null>(null)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const rules: MonitorRule[] = (rulesQuery.data as any)?.rules ?? []
// 收集所有规则的股票代码, 批量查名称
const allSymbols = useMemo(() => {
const set = new Set<string>()
for (const r of rules) {
if (r.scope === 'symbols') r.symbols.forEach(s => set.add(s))
}
return Array.from(set)
}, [rules])
const namesQuery = useQuery({
queryKey: ['instrument-names', allSymbols.join(',')],
queryFn: () => api.instrumentNames(allSymbols),
enabled: allSymbols.length > 0,
staleTime: 300000,
})
const symbolNames = namesQuery.data?.names ?? {}
const del = useMutation({
mutationFn: api.monitorRuleDelete,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }),
})
const toggleEnabled = (rule: MonitorRule) => {
api.monitorRuleSave({ ...rule, enabled: !rule.enabled }).then(() =>
qc.invalidateQueries({ queryKey: QK.monitorRules }),
)
}
// 点击删除: 第一次进入确认态, 第二次真删, 3 秒后自动复位
const handleClickDelete = (id: string) => {
if (confirmId === id) {
if (resetTimer.current) clearTimeout(resetTimer.current)
setConfirmId(null)
del.mutate(id)
} else {
setConfirmId(id)
if (resetTimer.current) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setConfirmId(null), 3000)
}
}
return (
<div className="space-y-2.5">
{rules.length === 0 ? (
<EmptyState
icon={RadioTower}
title="暂无监控规则"
hint="点击标题栏「+」新建规则,或在个股详情页点「加监控」快速添加。"
/>
) : (
rules.map(r => {
// 名称截取: "策略监控 · MACD金叉" → "MACD金叉", "个股信号监控 · 300750.SZ" → "个股信号监控"
const dotIdx = r.name.indexOf(' · ')
const displayName = dotIdx >= 0 ? r.name.slice(dotIdx + 3) : r.name
return (
<motion.div
key={r.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={cn(
'group relative overflow-hidden rounded-lg border pl-3.5 pr-2.5 py-2 shadow-sm transition-all duration-200 hover:shadow-md hover:shadow-black/10',
r.enabled
? 'border-border/50 bg-surface hover:border-accent/30'
: 'border-border/30 bg-surface/40 opacity-70 hover:opacity-100',
)}
>
{/* 左侧状态条 */}
<div className={cn('absolute left-0 top-0 h-full w-0.5', r.enabled ? 'bg-accent/50' : 'bg-border')} />
{/* 第一行: 分类标签 + 名称 + 操作按钮 */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold', SOURCE_BADGE_STYLE[r.type] ?? 'bg-elevated text-muted')}>
{TYPE_LABEL[r.type]}
</span>
{/* 个股类型: 直接显示可点击的代码+名称; 其他类型显示规则名 */}
{r.scope === 'symbols' && r.symbols.length > 0 ? (
<button
onClick={() => setPreviewSymbol(r.symbols[0])}
className="inline-flex items-center gap-1 min-w-0 hover:bg-elevated/50 rounded px-0.5 transition-colors cursor-pointer"
title={`查看 ${r.symbols[0]} 日K`}
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{r.symbols[0]}</span>
{symbolNames[r.symbols[0]] && <span className="text-xs text-secondary truncate">{symbolNames[r.symbols[0]]}</span>}
</button>
) : (
<h3 className={cn('text-xs font-medium truncate', r.enabled ? 'text-foreground' : 'text-muted')}>{displayName}</h3>
)}
{!r.enabled && <span className="shrink-0 text-[9px] text-secondary">· </span>}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => toggleEnabled(r)}
title={r.enabled ? '停用' : '启用'}
className={cn(
'p-1 rounded-md transition-all cursor-pointer',
r.enabled ? 'text-accent hover:bg-accent/10' : 'text-muted hover:bg-elevated hover:text-accent',
)}
>
<Zap className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onEdit(r)}
className="p-1 rounded-md text-secondary transition-all hover:bg-accent/10 hover:text-accent cursor-pointer"
title="编辑"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmId === r.id ? (
<button
onClick={() => handleClickDelete(r.id)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[9px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleClickDelete(r.id)}
disabled={del.isPending}
className="p-1 rounded-md text-secondary transition-all hover:bg-danger/10 hover:text-danger cursor-pointer"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* 第二行: 策略类型显示选股池变更监控 */}
{r.type === 'strategy' && r.strategy_id ? (
<div className="mt-0.5 flex items-center gap-2 pl-0.5">
<span className="text-[9px] text-secondary"></span>
</div>
) : r.conditions.length > 0 && (
<div className="mt-0.5 flex items-center gap-1 pl-0.5">
<span className="text-[9px] text-secondary shrink-0"></span>
<span className="min-w-0 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[9px]">
{r.conditions.slice(0, 3).map((c, i) => (
<span key={i} className="inline-flex items-center gap-0.5">
{i > 0 && <span className="text-secondary">{r.logic === 'and' ? '且' : '或'}</span>}
{c.op === 'truth' ? (
<span className="text-accent/80">{cnSignal(c.field)}</span>
) : (
<span className="text-foreground/80 font-mono">{cnSignal(c.field)}{c.op}{c.value}</span>
)}
</span>
))}
{r.conditions.length > 3 && <span className="text-secondary">+{r.conditions.length - 3}</span>}
</span>
</div>
)}
</motion.div>
)
})
)}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
onClose={() => setPreviewSymbol(null)}
/>
</div>
)
}
// ── 规则编辑对话框 ────────────────────────────────────
function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: MonitorRule | null; onClose: () => void }) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.15 }}
className="mt-12 w-full max-w-2xl"
onClick={e => e.stopPropagation()}
>
<RuleEditor
rule={rule}
onClose={onClose}
onSaved={onClose}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// ── 确认对话框 ────────────────────────────────────────
function ConfirmDialog({ open, title, message, confirmText, danger, pending, onCancel, onConfirm }: {
open: boolean
title: string
message: string
confirmText?: string
danger?: boolean
pending?: boolean
onCancel: () => void
onConfirm: () => void
}) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onCancel}
>
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="mt-1.5 text-xs text-muted">{message}</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></button>
<button
onClick={onConfirm}
disabled={pending}
className={cn(
'px-3 py-1.5 rounded-btn text-xs font-medium disabled:opacity-50 cursor-pointer',
danger ? 'bg-danger text-base' : 'bg-accent text-base',
)}
>
{confirmText ?? '确定'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+545
View File
@@ -0,0 +1,545 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
Eye,
EyeOff,
Loader2,
Save,
Check,
CheckCircle2,
AlertCircle,
ArrowRight,
ArrowLeft,
ExternalLink,
Sparkles,
LineChart,
ScanSearch,
Flame,
Zap,
Radar,
ShieldCheck,
BellRing,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { QK } from '@/lib/queryKeys'
import { CAP_LABELS } from '@/lib/capability-labels'
import { Logo } from '@/components/Logo'
// ===== 引导页:4 步向导 =====
// 0. 欢迎 1. 输入 Key(可跳过) 2. 能力探测结果 3. 完成 → 写标记 → 进面板
const STEPS = ['欢迎', '配置 Key', '能力探测', '完成'] as const
const BRAND = '#8B5CF6'
const HIGHLIGHTS = [
{ icon: LineChart, title: '看板与自选', desc: '实时行情、MA/MACD 指标、自定义自选列表', tint: 'text-accent' },
{ icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中', tint: 'text-bull' },
{ icon: Flame, title: '连板梯队', desc: '涨停板梯队、概念行业热度、市场情绪一览', tint: 'text-warning' },
{ icon: Radar, title: '实时监控', desc: '自定义条件 / 策略监控,触发即推送告警', tint: 'text-bear' },
{ icon: ShieldCheck, title: '回测验证', desc: '策略历史回测、因子分析,用数据说话', tint: 'text-accent' },
{ icon: BellRing, title: '本地优先', desc: '数据本地存储,隐私可控,断网仍可查阅', tint: 'text-bull' },
]
export function Onboarding() {
const navigate = useNavigate()
const qc = useQueryClient()
const [step, setStep] = useState(0)
// 完成向导 —— 写后端标记,使守卫放行
const complete = useMutation({
mutationFn: api.completeOnboarding,
onSuccess: (data) => {
// 用接口返回值同步更新缓存,确保跳转时守卫立即看到 onboarding_completed: true
// (避免 invalidate 后台重取未返回时, 守卫用旧缓存 false 误重定向回引导页)
qc.setQueryData(QK.settings, (old: any) =>
old ? { ...old, onboarding_completed: data.onboarding_completed } : old,
)
qc.invalidateQueries({ queryKey: QK.settings })
navigate('/', { replace: true })
},
onError: () => {
// 标记失败不应阻塞用户进入面板,仍放行
navigate('/', { replace: true })
},
})
const finish = () => complete.mutate()
return (
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
{/* 背景光晕 —— 品牌 + 主色渐变 */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
{/* 极淡网格底纹 */}
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
</div>
{/* 顶栏:logo + 进度指示 */}
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<Logo
size={24}
className="shrink-0"
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
/>
<span className="text-sm font-semibold tracking-tight">Stock Panel</span>
</div>
{/* 步骤进度条 —— 胶囊式 */}
<div className="flex items-center gap-1.5">
{STEPS.map((label, i) => (
<div key={label} className="flex items-center gap-1.5">
{i > 0 && <div className="h-px w-3 bg-border" />}
<motion.div
animate={{
width: i === step ? 64 : 24,
backgroundColor: i === step
? 'hsl(var(--accent))'
: i < step
? 'hsl(var(--accent) / 0.6)'
: 'hsl(var(--border))',
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="h-1.5 rounded-full"
/>
</div>
))}
</div>
<div className="w-[88px] text-right">
<span className="text-xs text-muted tabular">
{step + 1} / {STEPS.length}
</span>
</div>
</header>
{/* 步骤内容 */}
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
<div className="w-full max-w-xl">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
>
{step === 0 && <WelcomeStep onNext={() => setStep(1)} onSkip={finish} />}
{step === 1 && (
<KeyStep onNext={() => setStep(2)} onSkip={() => setStep(2)} onBack={() => setStep(0)} />
)}
{step === 2 && <ResultStep onNext={() => setStep(3)} onBack={() => setStep(1)} />}
{step === 3 && <FinishStep onNext={finish} onBack={() => setStep(2)} pending={complete.isPending} />}
</motion.div>
</AnimatePresence>
</div>
</main>
</div>
)
}
// ===== Step 0: 欢迎 =====
function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
return (
<div className="text-center">
{/* 品牌 badge */}
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit rounded-2xl p-4 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<Sparkles className="h-8 w-8" style={{ color: BRAND }} />
</motion.div>
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
使 Stock Panel
</h1>
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
A
,使
</p>
{/* 6 个特性卡片 */}
<div className="mt-8 grid grid-cols-2 sm:grid-cols-3 gap-3 text-left">
{HIGHLIGHTS.map((h, i) => (
<motion.div
key={h.title}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05 * i + 0.1 }}
whileHover={{ y: -2 }}
className="group rounded-card border border-border bg-surface/80 backdrop-blur-sm p-3.5 transition-colors hover:border-accent/30"
>
<h.icon className={`h-5 w-5 ${h.tint} transition-transform group-hover:scale-110`} />
<div className="mt-2 text-sm font-medium text-foreground">{h.title}</div>
<div className="mt-1 text-xs text-muted leading-relaxed">{h.desc}</div>
</motion.div>
))}
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<button
onClick={onNext}
className="inline-flex items-center gap-2 px-6 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 transition-all"
>
<ArrowRight className="h-4 w-4" />
</button>
<button
onClick={onSkip}
className="px-4 h-11 rounded-xl text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
</button>
</div>
</div>
)
}
// ===== Step 1: 输入数据源 Key =====
function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () => void; onBack: () => void }) {
const qc = useQueryClient()
const settings = useSettings()
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
const [saved, setSaved] = useState(false)
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
if (data.ok) {
// 仅当 key 有效(被存储)时才进入下一步看探测结果
setSaved(true)
setTimeout(() => onNext(), 600)
}
// ok=false(key 无效):不进入下一步,错误提示由 save.error / save.data 渲染
},
})
// 已配置 key —— 免费档或付费档都算(只要不是无档 none)
const alreadyHasKey = settings.data?.mode !== 'none' && settings.data?.mode !== undefined
return (
<div>
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-accent/10 p-2">
<ShieldCheck className="h-4 w-4 text-accent" />
</div>
<h2 className="text-xl font-bold text-foreground"> API Key</h2>
</div>
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key 使 Key <span className="font-medium text-foreground"> </span>
使K; Key
</p>
{/* 注册引导 */}
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-4 text-xs text-secondary leading-relaxed">
Key?{' '}
<a
href="https://tickflow.org/auth/register"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline inline-flex items-baseline gap-0.5 font-medium"
>
<ExternalLink className="h-3 w-3 self-center" />
</a>{' '}
,使
</div>
{/* Key 已配置提示 */}
{alreadyHasKey && !save.isPending && (
<div className="mt-4 flex items-start gap-2 rounded-btn border border-bear/30 bg-bear/10 px-3 py-2.5 text-xs text-bear">
<CheckCircle2 className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
Key(<span className="font-mono">{settings.data?.tickflow_api_key_masked}</span>)
, Key
</span>
</div>
)}
{/* 输入 */}
<form
onSubmit={(e) => {
e.preventDefault()
if (keyInput.trim()) save.mutate()
}}
className="mt-4 space-y-2"
>
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={alreadyHasKey ? '粘贴新 Key 替换当前' : '粘贴数据源 API Key'}
value={keyInput}
onChange={(e) => {
setKeyInput(e.target.value)
if (saved) setSaved(false)
}}
className="w-full px-3 py-2.5 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
<button
type="button"
onClick={() => setRevealing((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors"
tabIndex={-1}
aria-label={revealing ? '隐藏' : '显示'}
>
{revealing ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* 保存中提示 */}
{save.isPending && (
<div className="flex items-start gap-1.5 rounded-btn border border-warning/30 bg-warning/10 px-3 py-2 text-[11px] leading-snug text-warning">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span> Key ,</span>
</div>
)}
{save.isError && (
<div className="text-xs text-danger">:{String((save.error as any).message)}</div>
)}
{/* 无效 key —— 探测失败(key 无效/乱填)未存储,提示用户 */}
{save.data && !save.data.ok && (
<div className="flex items-start gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] leading-snug text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
{save.data.reason === 'invalid'
? 'Key 无效或已过期,请检查后重试(未保存该 Key)。'
: save.data.error ?? '保存失败'}
</span>
</div>
)}
</form>
{/* 底部操作 */}
<div className="mt-6 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<button
onClick={onSkip}
disabled={save.isPending}
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors disabled:opacity-50"
>
{alreadyHasKey ? '下一步' : '暂不配置'}
</button>
<button
onClick={() => keyInput.trim() && save.mutate()}
disabled={save.isPending || !keyInput.trim()}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 disabled:opacity-40 transition-all"
>
{save.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : saved ? (
<Check className="h-4 w-4" />
) : (
<Save className="h-4 w-4" />
)}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存并检测'}
</button>
</div>
</div>
</div>
)
}
// ===== Step 2: 能力探测结果 =====
function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void }) {
const settings = useSettings()
const caps = useCapabilities()
// 是否配置成功 —— 免费档(free)或付费档(api_key)都算;无档(none)算未配置
const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
const capList = caps.data ? Object.entries(caps.data.capabilities) : []
return (
<div>
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-accent/10 p-2">
<ScanSearch className="h-4 w-4 text-accent" />
</div>
<h2 className="text-xl font-bold text-foreground"></h2>
</div>
{hasKey ? (
<>
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
Key ,
<span className="text-foreground font-medium"> </span>
Key
</p>
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-5">
<div className="flex items-baseline justify-between">
<span className="text-[10px] uppercase tracking-widest text-muted"></span>
<span className="font-mono text-2xl font-bold tracking-tight text-foreground">
{caps.data?.label ?? settings.data?.tier_label ?? '—'}
</span>
</div>
{caps.isLoading ? (
<div className="mt-4 flex items-center gap-2 text-xs text-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : capList.length > 0 ? (
<div className="mt-4 grid grid-cols-1 gap-1.5">
{capList.slice(0, 8).map(([cap]) => {
const meta = CAP_LABELS[cap]
return (
<div key={cap} className="flex items-center gap-2 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 text-bear shrink-0" />
<span className="text-foreground">{meta?.name ?? cap}</span>
</div>
)
})}
{capList.length > 8 && (
<div className="text-[11px] text-muted pl-5"> {capList.length} </div>
)}
</div>
) : (
<div className="mt-4 text-xs text-muted"></div>
)}
</div>
</>
) : (
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6 text-center">
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
<Zap className="h-6 w-6 text-warning" />
</div>
<div className="mt-3 text-sm font-medium text-foreground"></div>
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
Key,使K数据 Key ,
<span className="text-foreground font-medium"> </span>
</p>
</div>
)}
{/* 底部操作 */}
<div className="mt-6 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
onClick={onNext}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold hover:bg-accent/90 transition-colors"
>
<ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
)
}
// ===== Step 3: 完成 =====
function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: () => void; pending: boolean }) {
const tips = [
{ icon: ScanSearch, text: '在「选股」页用内置策略一键扫描全市场' },
{ icon: BellRing, text: '在「监控」页设置条件或策略告警,盘中实时推送' },
{ icon: ShieldCheck, text: '在「回测」页用历史数据验证策略表现' },
]
return (
<div className="text-center">
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit"
>
<div
className="relative rounded-2xl p-5 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<CheckCircle2 className="h-12 w-12 text-bear" />
{/* 光晕脉冲 */}
<motion.div
animate={{ scale: [1, 1.4], opacity: [0.4, 0] }}
transition={{ duration: 1.8, repeat: Infinity, ease: 'easeOut' }}
className="absolute inset-5 rounded-full bg-bear/30"
/>
</div>
</motion.div>
<h1 className="mt-6 text-2xl font-bold text-foreground">!</h1>
<p className="mt-2.5 text-sm text-secondary leading-relaxed max-w-md mx-auto">
,
<span className="text-foreground font-medium"> </span>
</p>
{/* 快速上手提示 */}
<div className="mt-6 space-y-2 text-left">
{tips.map((t, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 * i + 0.2 }}
className="flex items-center gap-3 rounded-card border border-border bg-surface/80 backdrop-blur-sm px-3.5 py-2.5"
>
<div className="rounded-lg bg-accent/10 p-1.5 shrink-0">
<t.icon className="h-3.5 w-3.5 text-accent" />
</div>
<span className="text-xs text-secondary">{t.text}</span>
</motion.div>
))}
</div>
{/* 底部操作 */}
<div className="mt-8 flex items-center justify-between">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 h-10 rounded-btn text-sm text-secondary hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
onClick={onNext}
disabled={pending}
className="inline-flex items-center gap-2 px-6 h-10 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{pending ? '正在进入…' : '进入面板'}
</button>
</div>
</div>
)
}
+869
View File
@@ -0,0 +1,869 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { useStrategyPool } from '@/lib/useStrategyPool'
import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard'
import { ScreenerTable } from '@/components/screener/ScreenerTable'
import { ScreenerFilter as ScreenerFilterType, defaultFilter, filterActive, countActiveFilters, applyFilter, FilterPanel } from '@/components/screener/ScreenerFilter'
import { StrategySettingsDialog } from '@/components/screener/StrategySettingsDialog'
import { StrategyPoolDialog } from '@/components/screener/StrategyPoolDialog'
import { StrategyBuilderDialog } from '@/components/screener/StrategyBuilderDialog'
import { StrategyStoreDialog } from '@/components/screener/StrategyStoreDialog'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { useTableSort } from '@/components/stock-table/useTableSort'
import { resolveCandleConfig } from '@/lib/list-columns'
import {
SCREENER_BUILTIN_COLUMNS,
SCREENER_COLUMN_GROUPS,
buildExtColumnsParam,
loadScreenerColumnConfig,
saveScreenerColumnConfig,
type ColumnConfig,
} from '@/lib/screener-columns'
export function Screener() {
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
const [result, setResult] = useState<ScreenerResult | null>(null)
const [asOf, setAsOf] = useState<string>('')
const [batchMsg, setBatchMsg] = useState<string>('')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
const [settingsStrategyId, setSettingsStrategyId] = useState<string | null>(null)
const [showPoolDialog, setShowPoolDialog] = useState(false)
const [showBuilder, setShowBuilder] = useState(false)
const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create')
const [showStore, setShowStore] = useState(false)
const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool()
const [cardSize, setCardSize] = useState<CardSize>(loadCardSize)
// 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化)
const [dailyKChartVisible, setDailyKChartVisible] = useState<boolean>(() => storage.screenerCandle.get(true))
const toggleDailyKChart = useCallback(() => {
setDailyKChartVisible(v => {
const next = !v
storage.screenerCandle.set(next)
return next
})
}, [])
const [showAll, setShowAll] = useState(false)
const [showFilter, setShowFilter] = useState(false)
const [filter, setFilter] = useState<ScreenerFilterType>(defaultFilter)
const filterMap = useRef<Map<string, ScreenerFilterType>>(new Map())
const runAllDateRef = useRef<string | null>(null)
// 结果列配置 — 默认内置列,异步合并后端/localStorage 偏好
const [columns, setColumns] = useState<ColumnConfig[]>([...SCREENER_BUILTIN_COLUMNS])
const [customizerOpen, setCustomizerOpen] = useState(false)
const columnsLoaded = useRef(false)
useEffect(() => {
if (columnsLoaded.current) return
columnsLoaded.current = true
loadScreenerColumnConfig().then(setColumns)
}, [])
const handleColumnsChange = useCallback((next: ColumnConfig[]) => {
setColumns(next)
saveScreenerColumnConfig(next)
}, [])
const extColumnsParam = useMemo(() => buildExtColumnsParam(columns), [columns])
// 各策略命中数 (进入页面自动跑)
const [hitCounts, setHitCounts] = useState<Record<string, number>>({})
// 各策略失效数 (今日曾命中 - 当前命中)
const [expiredCounts, setExpiredCounts] = useState<Record<string, number>>({})
// 各策略显示上限 (null = 全部)
const [strategyLimits, setStrategyLimits] = useState<Record<string, number | null>>({})
// 筛选条件变化时同步到 map(供切换策略时读取最新值)
useEffect(() => {
if (activeStrategy) filterMap.current.set(activeStrategy, filter)
}, [filter, activeStrategy])
// 切换策略时恢复该策略之前保存的筛选
const handleStrategySwitch = useCallback((strategyId: string) => {
setFilter(filterMap.current.get(strategyId) ?? { ...defaultFilter })
}, [])
// 对原始结果应用过滤
const filteredRows = result
? applyFilter(result.rows, filter)
: []
const { data: prefs } = usePreferences()
const screenerAutoRun = prefs?.screener_auto_run ?? true
const strategies = useQuery({
queryKey: QK.screenerStrategies,
queryFn: api.screenerStrategies,
})
// 策略结果缓存 — 文件读取,SSE invalidation 自动刷新
const cachedQuery = useQuery({
queryKey: QK.screenerCached(extColumnsParam),
queryFn: () => api.screenerCached(extColumnsParam || undefined),
})
const dataStatus = useDataStatus({ staleTime: 0 })
// 默认日期 = enriched 最新日期(始终跟随最新)
useEffect(() => {
const latest = dataStatus.data?.enriched?.latest_date
if (latest) setAsOf(latest)
}, [dataStatus.data?.enriched?.latest_date])
// 策略 ID → 名称映射
const strategyIdToName = useMemo(() => {
const map: Record<string, string> = {}
for (const p of strategies.data?.presets ?? []) {
map[p.id] = p.name
}
return map
}, [strategies.data])
// 策略 ID → 完整对象映射(避免每张卡片 find 遍历)
const strategyMap = useMemo(() => {
const map = new Map<string, ScreenerStrategy>()
for (const p of strategies.data?.presets ?? []) {
map.set(p.id, p)
}
return map
}, [strategies.data])
const availableStrategyIds = useMemo(() => new Set((strategies.data?.presets ?? []).map(s => s.id)), [strategies.data])
const visiblePool = useMemo(() => pool.filter(id => availableStrategyIds.has(id)), [pool, availableStrategyIds])
// 策略列表加载后,自动清除池中失效的自定义策略(如本地开发残留的、
// 当前后端已不存在的策略 ID),避免"策略池"对话框持续显示失效项。
// availableStrategyIds 初始为空集合时跳过,防止首次渲染误清整个池。
useEffect(() => {
if (availableStrategyIds.size === 0) return
prune(availableStrategyIds)
}, [availableStrategyIds, prune])
// 进入页面自动跑策略池中的策略,获取命中数
const runAll = useMutation({
mutationFn: ({ date, strategyIds }: { date?: string; strategyIds?: string[] } = {}) =>
api.screenerRunAll(date, strategyIds ?? visiblePool, extColumnsParam || undefined),
onSuccess: (data) => {
if (data.as_of) setAsOf(data.as_of)
},
})
const applyRunAllResult = useCallback((strategyId: string, date: string, data = runAll.data) => {
const cached = data?.results?.[strategyId]
if (!cached || cached.as_of !== date) return false
setResult({
as_of: cached.as_of,
strategy: strategyId,
rows: cached.rows,
total: cached.total,
elapsed_ms: 0,
})
setHitCounts(prev => ({ ...prev, [strategyId]: cached.total }))
return true
}, [runAll.data])
// 缓存是否覆盖当前策略池
const cacheCoversPool = useMemo(() => {
if (!cachedQuery.data?.as_of || cachedQuery.data.as_of !== asOf) return false
if (!cachedQuery.data.results) return false
return visiblePool.length > 0 && visiblePool.every(id => id in cachedQuery.data!.results)
}, [cachedQuery.data, asOf, visiblePool])
// 统一数据源: 缓存优先,runAll fallback
const effectiveResults = useMemo(() => {
if (cacheCoversPool) return cachedQuery.data!.results
return runAll.data?.results ?? null
}, [cacheCoversPool, cachedQuery.data, runAll.data])
// 从 effectiveResults 同步 hitCounts + expiredCounts
useEffect(() => {
if (!effectiveResults) return
const counts: Record<string, number> = {}
for (const [id, r] of Object.entries(effectiveResults)) {
counts[id] = r.total
}
setHitCounts(counts)
// 从缓存数据计算失效数 (ever_matched - current)
const everMatched = cachedQuery.data?.today_ever_matched
if (everMatched) {
const expired: Record<string, number> = {}
for (const [id, symbols] of Object.entries(everMatched) as [string, string[]][]) {
const currentRows = effectiveResults[id]?.rows ?? []
const currentSet = new Set(currentRows.map((r: any) => r.symbol))
const expiredCount = symbols.filter((s: string) => !currentSet.has(s)).length
if (expiredCount > 0) expired[id] = expiredCount
}
setExpiredCounts(expired)
}
// 如果有激活策略,同步当前 result(扩展列变化时也会刷新行数据)
if (activeStrategy && effectiveResults[activeStrategy]) {
const r = effectiveResults[activeStrategy]
setResult(prev => {
if (prev?.strategy === activeStrategy && prev.as_of === r.as_of && prev.rows === r.rows && prev.total === r.total) return prev
return {
as_of: r.as_of,
strategy: activeStrategy,
rows: r.rows,
total: r.total,
elapsed_ms: 0,
}
})
}
}, [effectiveResults, cachedQuery.data, activeStrategy])
// symbol → 所属策略列表 (来自 effectiveResults)
const symbolStrategyMap = useMemo(() => {
const map = new Map<string, string[]>()
if (!effectiveResults) return map
for (const [sid, r] of Object.entries(effectiveResults)) {
for (const row of r.rows) {
const arr = map.get(row.symbol)
if (arr) {
arr.push(sid)
} else {
map.set(row.symbol, [sid])
}
}
}
return map
}, [effectiveResults])
// "全部" 模式: 合并所有策略的去重个股
const allRows = useMemo(() => {
if (!effectiveResults) return []
const seen = new Set<string>()
const merged: any[] = []
for (const r of Object.values(effectiveResults)) {
for (const row of r.rows) {
if (!seen.has(row.symbol)) {
seen.add(row.symbol)
merged.push(row)
}
}
}
return merged
}, [effectiveResults])
// 计算失效行: 在 today_ever_rows 中但不在当前 results 中
const expiredRowsMap = useMemo(() => {
const map = new Map<string, any[]>() // strategyId → expired rows
const everRows = cachedQuery.data?.today_ever_rows
if (!everRows || !effectiveResults) return map
for (const [sid, symMap] of Object.entries(everRows) as [string, Record<string, any>][]) {
const currentRows = effectiveResults[sid]?.rows ?? []
const currentSymbols = new Set(currentRows.map((r: any) => r.symbol))
const expired = Object.entries(symMap)
.filter(([sym]) => !currentSymbols.has(sym))
.map(([, row]) => ({ ...row, _expired: true }))
if (expired.length > 0) map.set(sid, expired)
}
return map
}, [cachedQuery.data, effectiveResults])
// 表头排序(受控):用户点击列则按该列;未点时下方按评分默认降序
const { sort, toggle, sortRows } = useTableSort()
// 当前显示的行数据 (全部模式 或 单策略模式) + 失效行
const displayRows = useMemo(() => {
let rows = showAll
? applyFilter(allRows, filter)
: filteredRows
// 排序:用户点了表头则按该列,否则默认评分降序
rows = sort
? sortRows(rows, columns)
: [...rows].sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity))
const limit = !showAll && activeStrategy
? strategyLimits[activeStrategy] ?? null
: null
const mainRows = limit != null ? rows.slice(0, limit) : rows
// 追加当前策略的失效行 (灰色)
if (!showAll && activeStrategy) {
const expired = expiredRowsMap.get(activeStrategy) ?? []
if (expired.length > 0) {
return [...mainRows, ...expired]
}
}
return mainRows
}, [showAll, allRows, filteredRows, filter, activeStrategy, strategyLimits, expiredRowsMap, sort, sortRows, columns])
// 日k列是否启用 → 决定是否加载批量 kline 数据
const candleColumn = useMemo(() =>
columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible),
[columns],
)
const candleColumnEnabled = !!candleColumn
// 日k天数(来自列配置,已钳制边界)
const candleDays = useMemo(() => resolveCandleConfig(candleColumn?.candleConfig).days, [candleColumn])
// 真正请求/渲染蜡烛图:列可见 且 眼睛开关开启
const dailyKVisible = candleColumnEnabled && dailyKChartVisible
// 批量日k数据 (仅当蜡烛图可见时加载,省请求)
const resultSymbolsKey = useMemo(() => displayRows.map((r: any) => r.symbol).join(','), [displayRows])
const klineBatch = useQuery({
queryKey: QK.screenerKlineBatch(`${resultSymbolsKey}|${candleDays}`),
queryFn: () => api.klineDailyBatch(displayRows.map((r: any) => r.symbol), candleDays),
enabled: dailyKVisible && displayRows.length > 0,
staleTime: 5 * 60_000,
})
const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {}
// asOf 确定后 + 策略列表就绪 + 策略池非空 → 自动跑一次 (受系统设置开关控制)
// 缓存命中时秒加载; 未命中时, 仅当 screener_auto_run 开启才自动触发 runAll
useEffect(() => {
if (!asOf || !strategies.data?.presets?.length || runAll.isPending || visiblePool.length === 0) return
const runKey = `${asOf}|${visiblePool.join(',')}|${extColumnsParam}`
if (runAllDateRef.current === runKey) return
// 缓存已覆盖当前策略池 → 秒加载, 不触发 runAll
if (cacheCoversPool) {
runAllDateRef.current = runKey
return
}
// 未覆盖: 受系统开关控制
if (!screenerAutoRun) return
runAllDateRef.current = runKey
runAll.mutate({ date: asOf }, {
onSuccess: (data) => {
if (activeStrategy) applyRunAllResult(activeStrategy, asOf, data)
},
})
}, [asOf, strategies.data, visiblePool, extColumnsParam, cacheCoversPool, screenerAutoRun, activeStrategy, applyRunAllResult])
const qc = useQueryClient()
const run = useMutation({
mutationFn: ({ id, date }: { id: string; date: string }) =>
api.screenerRunPreset(id, undefined, date || undefined, extColumnsParam || undefined),
onSuccess: (data, vars) => {
setResult(data)
// 同步更新卡片上的命中数
setHitCounts(prev => ({ ...prev, [vars.id]: data.total }))
// 单策略重跑后, 后端 _update_cache_strategy 已更新该策略的缓存条目;
// 这里 invalidate screenerCached 让前端缓存同步, 避免点卡片时 handleRun
// 仍读到旧的 effectiveResults (改参数后刷新会回退到旧个数的根因)
qc.invalidateQueries({ queryKey: ['screener-cached'] })
},
})
const handleRun = (s: ScreenerStrategy) => {
handleStrategySwitch(s.id)
setActiveStrategy(s.id)
setShowAll(false)
// 优先从 effectiveResults (缓存 + runAll) 取数据
const r = effectiveResults?.[s.id]
if (r && r.as_of === asOf) {
setResult({
as_of: r.as_of,
strategy: s.id,
rows: r.rows,
total: r.total,
elapsed_ms: 0,
})
setHitCounts(prev => ({ ...prev, [s.id]: r.total }))
return
}
// Fall back to runAll data or single run
if (!applyRunAllResult(s.id, asOf)) {
run.mutate({ id: s.id, date: asOf })
}
}
// 日期变化时,重新跑全部策略命中数 + 当前激活策略
const handleDateChange = (newDate: string) => {
setAsOf(newDate)
runAllDateRef.current = `${newDate}|${visiblePool.join(',')}|${extColumnsParam}`
runAll.mutate({ date: newDate }, {
onSuccess: (data) => {
if (activeStrategy) applyRunAllResult(activeStrategy, newDate, data)
},
})
if (activeStrategy) {
setResult(null)
}
}
const minDate = dataStatus.data?.enriched?.earliest_date ?? ''
const maxDate = dataStatus.data?.enriched?.latest_date ?? ''
const batchAdd = useWatchlistBatchAdd()
// 自选股列表 (用于判断是否在自选中)
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
})
const watchlistSet = useMemo(() => {
const symbols = watchlist.data?.symbols ?? []
return new Set(symbols.map((s: any) => s.symbol))
}, [watchlist.data])
// 单只股票加入/移出自选
const toggleWatchlist = useMutation({
mutationFn: ({ symbol, inList }: { symbol: string; inList: boolean }) =>
inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
// 开发用:重载策略文件
const reloadStrategies = useMutation({
mutationFn: api.strategyReload,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.screenerStrategies })
if (asOf) runAll.mutate({ date: asOf })
},
})
// 策略监控: 查询规则, 建立 strategyId → ruleId 映射 (只看 type=strategy 且 enabled)
const monitorRules = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList })
const strategyMonitorMap = useMemo(() => {
const m = new Map<string, string>()
for (const r of monitorRules.data?.rules ?? []) {
if (r.type === 'strategy' && r.enabled && r.strategy_id) {
m.set(r.strategy_id, r.id)
}
}
return m
}, [monitorRules.data])
const toggleStrategyMonitor = (strategyId: string, strategyName: string) => {
const existingRuleId = strategyMonitorMap.get(strategyId)
if (existingRuleId) {
// 已监控 → 删除规则
api.monitorRuleDelete(existingRuleId).then(() =>
qc.invalidateQueries({ queryKey: QK.monitorRules }),
)
} else {
// 未监控 → 直接创建 type=strategy 规则
api.monitorRuleSave({
id: genRuleId(),
name: `策略监控 · ${strategyName}`,
enabled: true,
type: 'strategy',
scope: 'all',
symbols: [],
sector: null,
strategy_id: strategyId,
direction: 'entry',
conditions: [],
logic: 'or',
cooldown_seconds: 3600,
severity: 'info',
message: '',
}).then(() => qc.invalidateQueries({ queryKey: QK.monitorRules }))
}
}
const handleBatchAdd = () => {
if (!displayRows.length) return
const symbols = displayRows.map((r: any) => r.symbol)
batchAdd.mutate(symbols, {
onSuccess: (data) => {
setBatchMsg(`已添加 ${data.added} 只到自选`)
setTimeout(() => setBatchMsg(''), 3000)
},
onError: () => {
setBatchMsg('添加失败')
setTimeout(() => setBatchMsg(''), 3000)
},
})
}
return (
<>
<PageHeader
title="策略"
subtitle="基于本地 enriched 表 · 毫秒级 SQL"
right={
<div className="flex items-center gap-2">
{/* 开发用:重载策略 */}
<button
onClick={() => reloadStrategies.mutate()}
disabled={reloadStrategies.isPending}
title="重载策略文件(开发用)"
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
border border-border bg-surface text-xs font-medium text-muted
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer
disabled:opacity-50 disabled:cursor-wait"
>
<RefreshCw className={`h-3.5 w-3.5 ${reloadStrategies.isPending ? 'animate-spin' : ''}`} />
</button>
{asOf && (
<DatePicker
value={asOf}
onChange={handleDateChange}
min={minDate}
max={maxDate}
/>
)}
{/* 全部切换 */}
<button
onClick={() => setShowAll(v => { if (!v) setActiveStrategy(null); return !v })}
title="显示全部策略个股"
className={`inline-flex items-center justify-center h-7 w-7 rounded-btn border transition-colors cursor-pointer
${showAll
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-border bg-surface text-muted hover:text-secondary hover:border-accent/40'
}`}
>
<Network className="h-3.5 w-3.5" />
</button>
{/* 卡片尺寸切换 */}
<div className="flex items-center h-7 rounded-btn border border-border overflow-hidden">
{(['hidden', 'mini', 'normal', 'large'] as const).map(sz => (
<button
key={sz}
onClick={() => { setCardSize(sz); storage.screenerCardSize.set(sz) }}
className={`h-full px-2 text-[10px] font-medium transition-colors cursor-pointer
${cardSize === sz
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-secondary hover:bg-elevated'
}`}
>
{sz === 'hidden' ? '隐藏' : sz === 'mini' ? '紧凑' : sz === 'normal' ? '标准' : '详细'}
</button>
))}
</div>
{/* 策略池按钮 */}
<button
onClick={() => setShowPoolDialog(true)}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
border border-border bg-surface text-xs font-medium text-secondary
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer"
>
<Layers className="h-3.5 w-3.5" />
<span className="ml-0.5 min-w-[28px] h-4 flex items-center justify-center rounded-full bg-accent/15 text-accent text-[10px] font-bold">
{visiblePool.length}/{strategies.data?.presets?.length ?? 0}
</span>
</button>
{/* 创建策略 */}
<button
onClick={() => { setBuilderMode('create'); setShowBuilder(true) }}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
text-xs font-medium text-amber-400 border border-amber-400/20 bg-amber-400/5
hover:bg-amber-400/15 transition-colors cursor-pointer"
>
<Sparkles className="h-3.5 w-3.5" />
· AI
</button>
{/* 获取策略(占位,敬请期待) */}
<button
onClick={() => setShowStore(true)}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
border border-border bg-surface text-xs font-medium text-secondary
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer"
>
<Store className="h-3.5 w-3.5" />
</button>
</div>
}
/>
<div className="px-8 py-4 space-y-3">
{/* 策略卡片 */}
{cardSize !== 'hidden' && (
<section>
{strategies.isLoading && <div className="text-sm text-muted"></div>}
{!strategies.isLoading && visiblePool.length === 0 && (
<div className="text-sm text-muted py-4 text-center border border-dashed border-border rounded-btn">
</div>
)}
<div className={cardWrapCls(cardSize)}>
{visiblePool.map(id => {
const s = strategyMap.get(id)
if (!s) return null
return (
<StrategyCard
key={s.id}
name={s.name}
description={s.description}
source={s.source}
active={activeStrategy === s.id}
count={hitCounts[id]}
expiredCount={expiredCounts[id]}
loading={runAll.isPending && hitCounts[id] == null}
cardSize={cardSize}
onRun={() => handleRun(s)}
disabled={run.isPending && activeStrategy === s.id}
onSettings={() => setSettingsStrategyId(s.id)}
monitored={strategyMonitorMap.has(s.id)}
onToggleMonitor={() => toggleStrategyMonitor(s.id, s.name)}
/>
)
})}
</div>
</section>
)}
{/* 结果 */}
<section>
{run.isError && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
{String((run.error as any).message)}
</div>
)}
{(showAll ? allRows.length > 0 : !!result) && (
<motion.div
key={showAll ? `all-${asOf}` : `${result!.as_of}-${result!.strategy}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="space-y-3"
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-foreground flex items-center gap-2">
{!showAll && activeStrategy && (
<span className="text-secondary">{strategyIdToName[activeStrategy] ?? ''}</span>
)}
<TrendingUp className="h-4 w-4 text-accent" />
{showAll ? '全部' : ''} <span className="text-accent num">{displayRows.length}</span>
{filterActive(filter) && displayRows.length !== (showAll ? allRows.length : result!.total) && (
<span className="text-muted text-xs">/ {showAll ? allRows.length : result!.total}</span>
)}
<span className="text-[11px] text-muted font-normal">
· {visiblePool.length}
{!showAll && visiblePool.length > 0 && (
<> · {visiblePool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} </>
)}
</span>
{runAll.isPending && (
<span className="text-[11px] text-muted animate-pulse"></span>
)}
</h2>
<div className="flex items-center gap-3">
{displayRows.length > 0 && (
<>
<button
onClick={() => setShowFilter(v => !v)}
className={`inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
border text-xs font-medium transition-colors duration-150 cursor-pointer
${filterActive(filter)
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-border bg-surface text-secondary hover:border-accent/50'
}`}
>
<Filter className="h-3 w-3" />
{filterActive(filter) && (
<span className="bg-accent text-base rounded-full w-4 h-4 flex items-center justify-center text-[10px] font-bold">
{countActiveFilters(filter)}
</span>
)}
</button>
{filterActive(filter) && (
<button
onClick={() => {
setFilter(defaultFilter)
if (activeStrategy) filterMap.current.delete(activeStrategy)
}}
className="text-xs text-muted hover:text-danger transition-colors"
>
</button>
)}
</>
)}
{displayRows.length > 0 && (
<button
onClick={handleBatchAdd}
disabled={batchAdd.isPending}
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
border border-accent/40 bg-accent/10 text-accent text-xs font-medium
hover:bg-accent/20 disabled:opacity-50 transition-colors duration-150 cursor-pointer"
>
<Star className="h-3 w-3" />
{batchAdd.isPending ? '添加中…' : '批量加自选'}
</button>
)}
<button
onClick={() => setCustomizerOpen(true)}
title="列表配置"
className={`inline-flex items-center justify-center h-7 w-7 rounded-btn border text-xs font-medium transition-colors cursor-pointer
${customizerOpen
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-border bg-surface text-secondary hover:text-accent hover:border-accent/50'
}`}
>
<Settings2 className="h-3 w-3" />
</button>
{batchMsg && (
<span className="text-xs text-accent animate-pulse">{batchMsg}</span>
)}
{!showAll && result && result.elapsed_ms > 0 && (
<div className="flex items-center gap-2 text-xs text-muted">
<Clock className="h-3 w-3" />
<span className="num">{result.elapsed_ms.toFixed(1)} ms</span>
</div>
)}
</div>
</div>
{displayRows.length === 0 ? (
<EmptyState
icon={ScanSearch}
title="今日无命中"
hint="可能数据未跑盘后管道,或策略条件过于严苛。试试 POST /api/pipeline/run。"
/>
) : (
<>
{showFilter && (
<FilterPanel
value={filter}
onChange={setFilter}
onClose={() => setShowFilter(false)}
onReset={() => {
setFilter(defaultFilter)
if (activeStrategy) filterMap.current.delete(activeStrategy)
}}
/>
)}
<ScreenerTable
rows={displayRows}
columns={columns}
strategyIdToName={strategyIdToName}
symbolStrategyMap={symbolStrategyMap}
activeStrategy={activeStrategy}
watchlistSet={watchlistSet}
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
onToggleWatchlist={(symbol, inList) => toggleWatchlist.mutate({ symbol, inList })}
watchlistPending={toggleWatchlist.isPending}
klineData={klineData}
dailyKChartVisible={dailyKChartVisible}
onToggleDailyKChart={toggleDailyKChart}
sort={sort}
onSortToggle={toggle}
/>
</>
)}
</motion.div>
)}
{!showAll && !result && !run.isPending && (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<div className="w-16 h-16 rounded-2xl bg-accent/5 border border-border flex items-center justify-center">
<ScanSearch className="h-7 w-7 text-accent/40" />
</div>
<div className="flex flex-col items-center gap-1.5">
<span className="text-sm text-secondary"></span>
<span className="text-[11px] text-muted"> enriched </span>
</div>
</div>
)}
</section>
</div>
<ListColumnCustomizer
columns={columns}
groups={SCREENER_COLUMN_GROUPS}
onChange={handleColumnsChange}
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
title="自定义策略结果列"
builtinSectionLabel="策略内置列"
extColumnAlign="center"
/>
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={closePreview}
/>
<StrategySettingsDialog
strategyId={settingsStrategyId}
onClose={() => setSettingsStrategyId(null)}
onSaved={(limit) => {
if (settingsStrategyId) {
setStrategyLimits(prev => ({ ...prev, [settingsStrategyId]: limit }))
run.mutate({ id: settingsStrategyId, date: asOf })
}
}}
onAiModify={async () => {
if (!settingsStrategyId) return
try {
const [src, detail] = await Promise.all([
api.strategyGetSource(settingsStrategyId),
api.strategyGet(settingsStrategyId),
])
storage.strategyModify.set({
name: detail.name ?? '',
description: detail.description ?? '',
direction: 'long',
rules: storage.strategyRules.get({})[settingsStrategyId] ?? '',
code: src.code, step: 2, strategyId: settingsStrategyId,
})
setSettingsStrategyId(null)
setBuilderMode('modify')
setShowBuilder(true)
} catch {}
}}
onDeleted={() => {
if (settingsStrategyId) {
removeFromPool(settingsStrategyId)
const rules = storage.strategyRules.get({})
delete rules[settingsStrategyId]; storage.strategyRules.set(rules)
setStrategyLimits(prev => { const next = {...prev}; delete next[settingsStrategyId]; return next })
qc.invalidateQueries({ queryKey: QK.screenerStrategies })
}
}}
/>
{showPoolDialog && (
<StrategyPoolDialog
pool={pool}
onConfirm={(newPool) => {
reorderPool(newPool)
if (asOf) {
runAllDateRef.current = ''
runAll.mutate({ date: asOf, strategyIds: newPool })
}
}}
onClose={() => setShowPoolDialog(false)}
/>
)}
<StrategyBuilderDialog
open={showBuilder}
onClose={() => setShowBuilder(false)}
mode={builderMode}
onSavedId={async id => {
const data = await qc.fetchQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
if (!data.presets.some(s => s.id === id)) {
throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`)
}
addToPool(id)
}}
/>
<StrategyStoreDialog
open={showStore}
onClose={() => setShowStore(false)}
/>
</>
)
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 统一设置页面 — Tab 切换外壳。
*
* 通过 URL query param ?tab=xxx 同步 Tab 状态。
*/
import { useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { BarChart3, Radio, SlidersHorizontal, Sparkles, Settings2, Zap } from 'lucide-react'
import { SettingsAIPanel } from './settings/AI'
import { SettingsMonitoringPanel } from './settings/Monitoring'
import { SettingsExtPagesPanel } from './settings/ExtPages'
import { SettingsMenuSettingsPanel } from './settings/MenuSettings'
import { SettingsSystemPanel } from './settings/System'
import { SettingsCustomSignalsPanel } from './settings/CustomSignals'
import { PageHeader } from '@/components/PageHeader'
import { cn } from '@/lib/cn'
// ===== Tab 定义 =====
const TABS = [
// { key: 'account', label: '数据源', icon: Key, panel: SettingsKeysPanel },
{ key: 'ai', label: 'AI 设置', icon: Sparkles, panel: SettingsAIPanel },
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
{ key: 'signals', label: '信号库', icon: Zap, panel: SettingsCustomSignalsPanel },
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
] as const
type TabKey = (typeof TABS)[number]['key']
export function Settings() {
const [searchParams, setSearchParams] = useSearchParams()
const tabParam = searchParams.get('tab') as TabKey | null
const activeTab = TABS.find((t) => t.key === tabParam) ?? TABS[0]
const highlight = searchParams.get('highlight') ?? ''
return (
<>
<PageHeader
title="设置"
subtitle="管理账户、数据刷新策略和高级功能配置。"
/>
<div className="px-8 py-6">
<div className="flex gap-6 items-stretch">
{/* ===== 竖向 Tab 侧栏(内容垂直居中) ===== */}
<nav className="w-36 shrink-0">
<div className="flex flex-col gap-0.5 justify-center min-h-[60vh] sticky top-6">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setSearchParams({ tab: key }, { replace: true })}
className={cn(
'relative flex items-center gap-2 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth text-left',
activeTab.key === key
? 'bg-accent/10 text-accent font-medium'
: 'text-secondary hover:text-foreground hover:bg-elevated/60',
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span>{label}</span>
</button>
))}
</div>
</nav>
{/* ===== Tab 内容 ===== */}
<motion.div
key={activeTab.key}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
className="min-w-0 flex-1"
>
{activeTab.key === 'monitoring'
? <SettingsMonitoringPanel highlight={highlight} />
: <activeTab.panel />}
</motion.div>
</div>
</div>
</>
)
}
@@ -0,0 +1,10 @@
export function StockAnalysis() {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h2 className="text-lg text-secondary"></h2>
<p className="mt-2 text-sm text-muted">...</p>
</div>
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { Cable } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
// 后续实现计划(本轮为占位):
//
// 一、信号 → 交易 的桥接
// 监控通知产生的买卖信号(StrategyAlert),通过可插拔的输出通道分发到
// 支持外部信号的交易软件。核心是在 alert_handler 层做多通道分发。
//
// 二、支持的交易软件(按接入难度)
// 1. QMT / miniQMT(迅投)—— 个人 A 股实盘首选。
// XtQuant 的 xttrader.order_stock() 下单,信号来源不限(文件/HTTP)。
// 2. 掘金量化(MyQuant)—— 本地终端 + Python SDK,事件驱动接收信号。
// 3. Ptrade(恒生)—— 内置策略引擎,外部信号经 API/文件喂入。
// 4. vnpy(VeighNa)—— 开源框架,自写策略模块接收信号再调 Gateway 下单。
//
// 三、信号输出通道(可插拔)
// alert_handler 分发:
// ├─ SSE → 前端通知(已有)
// ├─ 本地文件(JSON/CSV) → QMT 脚本轮询读取 ← 最简单,优先做
// ├─ Webhook POST → 外部交易脚本
// └─ 直连 xttrader(需本机装 QMT)
//
// 四、信号 → 交易指令 的字段补全
// 现有 StrategyAlert(symbol/type/strategy_id/price)是「信号层」,
// 下单还需补:volume(数量,A股100的倍数)、price_type(市价/限价)、account。
const PLAN: { title: string; desc: string }[] = [
{
title: 'QMT / miniQMT',
desc: '个人 A 股实盘首选。XtQuant 的 xttrader 下单,信号经文件或 HTTP 喂入即可。国内个人量化实盘事实标准。',
},
{
title: '掘金量化 (MyQuant)',
desc: '本地终端 + Python SDK,事件驱动接收外部信号下单,本土化程度高。',
},
{
title: 'Ptrade (恒生)',
desc: '内置 Python 策略引擎,外部信号经 API/文件喂入,灵活性低于 QMT。',
},
{
title: 'vnpy (VeighNa)',
desc: '开源交易框架,Gateway 丰富(期货/股票/加密货币),需自建执行端,搭建成本较高。',
},
{
title: '信号输出通道',
desc: 'alert_handler 多通道分发:本地文件(最简,优先)、Webhook POST、直连 xttrader。与具体交易软件解耦。',
},
]
export function Trading() {
return (
<div className="flex flex-col h-full">
<PageHeader title="交易" subtitle="信号自动下单桥接 · 开发中" />
<div className="flex-1 overflow-auto px-5 py-6">
<div className="max-w-3xl mx-auto">
<EmptyState
icon={Cable}
title="交易桥接开发中"
hint="本页面将把监控产生的买卖信号,自动推送给支持外部信号的交易软件(QMT/掘金/Ptrade 等)执行下单。当前为占位页面,下方为后续实现规划。"
/>
<section className="mt-6 rounded-card border border-border bg-surface p-5">
<h3 className="text-sm font-semibold text-foreground"></h3>
<ul className="mt-3 space-y-3">
{PLAN.map((item) => (
<li key={item.title} className="flex gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
<div>
<p className="text-sm font-medium text-foreground">{item.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-secondary">{item.desc}</p>
</div>
</li>
))}
</ul>
</section>
</div>
</div>
</div>
)
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Shield, Lock, ArrowRight, AlertCircle, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { Logo } from '@/components/Logo'
const BRAND = '#8B5CF6'
export function Verify() {
const navigate = useNavigate()
const [uuid, setUuid] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['access-auth-status'],
queryFn: () => api.accessAuthStatus(),
})
// 已验证 或 未启用门控,直接进首页
useEffect(() => {
if (status && (!status.enabled || status.verified)) {
navigate('/', { replace: true })
}
}, [status, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const value = uuid.trim()
if (!value) {
setError('请输入访问密钥')
return
}
setLoading(true)
setError('')
try {
const res = await api.verifyAccessUuid(value)
if (res.valid && res.token) {
localStorage.setItem('access_token', res.token)
localStorage.setItem('access_role', res.role || 'user')
if (res.role === 'admin') {
navigate('/admin/uuids', { replace: true })
} else {
navigate('/', { replace: true })
}
} else {
setError('访问密钥无效,请检查后重试')
}
} catch (err: any) {
setError(err?.message || '验证失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
{/* 背景光晕 */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
</div>
{/* 顶栏 */}
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<Logo
size={24}
className="shrink-0"
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
/>
<span className="text-sm font-semibold tracking-tight">Stock Panel</span>
</div>
</header>
{/* 验证卡片 */}
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
{statusLoading ? (
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
</div>
) : (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md"
>
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
<div className="flex flex-col items-center text-center">
<div
className="rounded-2xl p-4 border border-border"
style={{ background: `linear-gradient(135deg, ${BRAND}22, transparent)` }}
>
<Shield className="h-8 w-8" style={{ color: BRAND }} />
</div>
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight">
访
</h1>
<p className="mt-2 text-sm text-secondary leading-relaxed">
访,访 UUID
</p>
</div>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
<Lock className="h-4 w-4" />
</div>
<input
type="text"
autoComplete="off"
placeholder="输入管理员令牌或访问 UUID"
value={uuid}
onChange={(e) => {
setUuid(e.target.value)
if (error) setError('')
}}
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 px-5 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
{loading ? '验证中…' : '进入系统'}
</button>
</form>
</div>
</motion.div>
)}
</main>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,447 @@
import { useState, useMemo } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Play, BarChart3, Clock } from 'lucide-react'
import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
import { fmtPct, priceColorClass } from '@/lib/format'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
import { FactorICChart } from './charts/FactorICChart'
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
const monthsAgo = (months: number) => {
const date = new Date()
date.setMonth(date.getMonth() - months)
return formatDate(date)
}
const TODAY = formatDate(new Date())
const THREE_MONTHS_AGO = monthsAgo(3)
const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
function StatCard({ label, value, highlight }: {
label: string
value: string | null | undefined
highlight?: 'bull' | 'bear' | 'neutral'
}) {
const colorCls = highlight === 'bull'
? 'text-bull' : highlight === 'bear' ? 'text-bear' : ''
return (
<div>
<div className="text-[11px] text-muted">{label}</div>
<div className={`mt-1 text-lg font-mono font-semibold tracking-tight num ${colorCls}`}>
{value ?? '—'}
</div>
</div>
)
}
function LoadingPanel({ symbolsText }: { symbolsText: string }) {
return (
<div className="space-y-4">
<div className="rounded-card border border-accent/25 bg-accent/[0.04] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="mt-1 text-xs text-muted">{symbolsText} · IC线</div>
</div>
<div className="h-8 w-8 rounded-full border-2 border-accent/25 border-t-accent animate-spin" />
</div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full w-1/2 rounded-full bg-accent/70 animate-pulse" />
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => (
<div key={item} className="rounded-btn border border-border bg-surface p-3">
<div className="h-2 w-10 rounded bg-accent/30 animate-pulse" />
<div className="mt-3 text-xs text-secondary">{item}</div>
</div>
))}
</div>
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-secondary"></div>
<div className="text-[11px] text-muted"></div>
</div>
<div className="mt-4 h-[260px] rounded-btn border border-border bg-base/60 p-4">
<div className="flex h-full items-end gap-2 opacity-70">
{[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => (
<div key={i} className="flex-1 rounded-t bg-accent/20 animate-pulse" style={{ height: `${h}%` }} />
))}
</div>
</div>
</div>
</div>
)
}
export function FactorBacktest() {
const [factorName, setFactorName] = useState('momentum_20d')
const [symbols, setSymbols] = useState('')
const [start, setStart] = useState(THREE_MONTHS_AGO)
const [end, setEnd] = useState(TODAY)
const [nGroups, setNGroups] = useState(5)
const [weight, setWeight] = useState<'equal' | 'factor_weight'>('equal')
const [fees, setFees] = useState('2')
const [result, setResult] = useState<FactorBacktestResult | null>(null)
const columns = useQuery({
queryKey: ['backtest-factor-columns'],
queryFn: api.factorColumns,
})
// 按 group 分类的因子
const factorGroups = useMemo(() => {
const cols = columns.data?.columns ?? []
const groups: Record<string, FactorColumn[]> = {}
for (const c of cols) {
;(groups[c.group] ??= []).push(c)
}
return groups
}, [columns.data])
// 当前因子描述
const factorDesc = useMemo(() => {
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
}, [columns.data, factorName])
const run = useMutation({
mutationFn: () =>
api.factorRun({
factor_name: factorName,
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
start: start || null,
end: end || undefined,
n_groups: nGroups,
rebalance: 'daily',
weight,
fees_pct: Number(fees) / 10000,
}),
onSuccess: (data) => {
if (data.error) {
setResult(data)
} else {
setResult(data)
}
},
})
const applyRange = (months: number) => {
setStart(monthsAgo(months))
setEnd(formatDate(new Date()))
}
const applyAllRange = () => {
setStart('')
setEnd(formatDate(new Date()))
}
const rangeKey = end === TODAY && start === THREE_MONTHS_AGO
? '3m'
: end === TODAY && start === monthsAgo(6)
? '6m'
: end === TODAY && start === monthsAgo(12)
? '1y'
: end === TODAY && start === ''
? 'all'
: 'custom'
const rangeTitle = rangeKey === '3m'
? '近 3 个月'
: rangeKey === '6m'
? '近 6 个月'
: rangeKey === '1y'
? '近 1 年'
: rangeKey === 'all'
? '全部历史'
: '自定义区间'
const rangeButtonCls = (key: string) => `rounded-btn px-2 py-1 text-[11px] font-medium transition-colors ${rangeKey === key
? 'bg-accent/15 text-accent'
: 'text-muted hover:bg-elevated/70 hover:text-secondary'
}`
return (
<div className="h-full min-h-0 overflow-hidden rounded-card border border-border bg-surface/80 grid grid-cols-1 xl:grid-cols-[18rem_minmax(0,1fr)]">
{/* 配置面板 */}
<section className="space-y-3 border-b xl:border-b-0 xl:border-r border-border bg-base/25 px-3 py-3 xl:overflow-y-auto">
<div className="border-b border-border/70 pb-2">
<div className="text-xs font-semibold text-foreground"></div>
<div className="mt-0.5 text-[10px] leading-4 text-muted"> 3 </div>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select
value={factorName}
onChange={e => setFactorName(e.target.value)}
className={INPUT_CLS}
>
{Object.entries(factorGroups).map(([group, cols]) => (
<optgroup key={group} label={group}>
{cols.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</optgroup>
))}
</select>
{factorDesc && (
<p className="mt-1 text-[11px] text-muted">{factorDesc}</p>
)}
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">
(=)
</label>
<input
type="text"
value={symbols}
onChange={e => setSymbols(e.target.value)}
placeholder="留空则使用全市场,建议最近3个月"
className={`w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs font-mono
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`}
/>
</div>
<div className="rounded-btn border border-border bg-surface p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-foreground"></div>
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
{rangeTitle}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-secondary block mb-1"></label>
<DatePicker
value={start}
onChange={setStart}
max={end || undefined}
placeholder="全部历史"
className="w-full"
buttonClassName="w-full justify-start"
align="left"
/>
</div>
<div>
<label className="text-[11px] text-secondary block mb-1"></label>
<DatePicker
value={end}
onChange={setEnd}
min={start || undefined}
className="w-full"
buttonClassName="w-full justify-start"
/>
</div>
</div>
<div className="mt-2 flex rounded-input bg-base/60 p-0.5">
<button type="button" onClick={() => applyRange(3)} className={`${rangeButtonCls('3m')} flex-1`}>3</button>
<button type="button" onClick={() => applyRange(6)} className={`${rangeButtonCls('6m')} flex-1`}>6</button>
<button type="button" onClick={() => applyRange(12)} className={`${rangeButtonCls('1y')} flex-1`}>1</button>
<button type="button" onClick={applyAllRange} className={`${rangeButtonCls('all')} flex-1`}></button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={nGroups} onChange={e => setNGroups(Number(e.target.value))} className={INPUT_CLS}>
<option value={3}>3</option>
<option value={5}>5</option>
<option value={10}>10</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={weight} onChange={e => setWeight(e.target.value as any)} className={INPUT_CLS}>
<option value="equal"></option>
<option value="factor_weight"></option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<input type="number" value={fees} onChange={e => setFees(e.target.value)}
className={INPUT_CLS} />
</div>
</div>
<button
onClick={() => run.mutate()}
disabled={run.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
bg-accent text-sm font-medium hover:bg-accent/90
transition-colors duration-150 ease-smooth disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
{run.isPending ? '分析中…' : '开始因子分析'}
</button>
</section>
{/* 结果面板 */}
<section className="min-w-0 space-y-3 bg-base/15 px-3 py-3 xl:overflow-y-auto">
{result?.error && !result.ic_mean && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
{result.error}
</div>
)}
{run.isError && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
{String((run.error as any).message)}
</div>
)}
{!result && !run.isPending && (
<EmptyState
icon={BarChart3}
title="选择因子并开始分析"
hint="因子回测分析因子的预测能力 ( IC/IR ) 和分层收益差异。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
/>
)}
{run.isPending && result && (
<div className="rounded-card border border-accent/25 bg-accent/[0.04] px-4 py-3 text-xs text-secondary">
</div>
)}
{run.isPending && !result && (
<LoadingPanel symbolsText={symbols ? `${symbols.split(',').length} 只标的` : '全市场 · 当前区间'} />
)}
{result && result.ic_mean != null && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="space-y-4"
>
{/* IC/IR 指标 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground"></h3>
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted">
Rank IC ·
</span>
{result.elapsed_ms > 0 && (
<span className="flex items-center gap-1 text-[11px] text-muted">
<Clock className="h-3 w-3" />
<span className="num">{result.elapsed_ms.toFixed(0)} ms</span>
</span>
)}
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<StatCard
label="IC 均值"
value={result.ic_mean != null ? fmtPct(result.ic_mean) : null}
highlight={result.ic_mean != null
? result.ic_mean > 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
: undefined}
/>
<StatCard label="IC 标准差" value={result.ic_std != null ? fmtPct(result.ic_std) : null} />
<StatCard
label="ICIR"
value={result.ir != null ? result.ir.toFixed(2) : null}
highlight={result.ir != null
? Math.abs(result.ir) > 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
: undefined}
/>
<StatCard label="IC 胜率" value={result.ic_win_rate != null ? fmtPct(result.ic_win_rate) : null} />
</div>
</div>
{/* IC 时序图 */}
{result.ic_series.length > 0 && (
<div className="rounded-card border border-border overflow-hidden">
<div className="bg-elevated px-4 py-2">
<span className="text-xs font-medium text-secondary">IC </span>
</div>
<div className="p-2">
<FactorICChart result={result} />
</div>
</div>
)}
{/* 分层净值 */}
{result.group_nav.length > 0 && (
<div className="rounded-card border border-border overflow-hidden">
<div className="bg-elevated px-4 py-2">
<span className="text-xs font-medium text-secondary">线</span>
</div>
<div className="p-2">
<FactorGroupNavChart result={result} />
</div>
</div>
)}
{/* 分层统计表 */}
{result.group_stats.length > 0 && (
<div className="rounded-card border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-elevated">
<tr className="text-left text-secondary">
<th className="px-4 py-2.5 font-medium"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{result.group_stats.map((g: GroupStat) => (
<tr key={g.group} className="border-t border-border hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 text-sm font-medium">{g.label}</td>
<td className={`px-4 py-2 text-right num ${priceColorClass(g.total_return)}`}>
{fmtPct(g.total_return)}
</td>
<td className={`px-4 py-2 text-right num ${priceColorClass(g.annual_return)}`}>
{fmtPct(g.annual_return)}
</td>
<td className="px-4 py-2 text-right num text-bear">{fmtPct(g.max_drawdown)}</td>
<td className="px-4 py-2 text-right num">{g.sharpe?.toFixed(2)}</td>
<td className="px-4 py-2 text-right num">{fmtPct(g.win_rate)}</td>
</tr>
))}
{/* 多空行 */}
{result.long_short_stats?.total_return != null && (
<tr className="border-t-2 border-accent/30 bg-accent/[0.03]">
<td className="px-4 py-2 text-sm font-medium text-accent">
({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''})
</td>
<td className={`px-4 py-2 text-right num font-medium ${priceColorClass(result.long_short_stats.total_return)}`}>
{fmtPct(result.long_short_stats.total_return as number)}
</td>
<td className="px-4 py-2 text-right num"></td>
<td className="px-4 py-2 text-right num text-bear">
{fmtPct(result.long_short_stats.max_drawdown as number)}
</td>
<td className="px-4 py-2 text-right num"></td>
<td className="px-4 py-2 text-right num"></td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* 数据概要 */}
<div className="flex items-center gap-4 text-[11px] text-muted">
<span>{result.n_symbols} </span>
<span>{result.n_dates} </span>
<span>run_id: {result.run_id}</span>
</div>
</motion.div>
)}
</section>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/chartTheme'
const GROUP_COLORS = [
'#6366f1', // Q1 indigo
'#8b5cf6', // Q2 violet
'#f59e0b', // Q3 amber
'#f97316', // Q4 orange
'#ef4444', // Q5 red
'#ec4899', // Q6
'#14b8a6', // Q7
'#06b6d4', // Q8
'#84cc16', // Q9
'#a855f7', // Q10
]
interface Props {
result: FactorBacktestResult
}
export function FactorGroupNavChart({ result }: Props) {
const chartTheme = useChartTheme()
const option = useMemo(() => {
if (!result.group_nav.length) return null
const dates = result.group_nav.map(r => (r.date as string).slice(0, 10))
const groupCols = Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
// 多空净值
const lsNav = result.long_short_nav
const hasLS = lsNav && lsNav.length > 0
const series = groupCols.map((col, i) => ({
name: col,
type: 'line',
data: result.group_nav.map(r => r[col]),
symbol: 'none',
lineStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length], width: 1.5 } as any,
itemStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length] } as any,
}))
if (hasLS) {
series.push({
name: '多空',
type: 'line',
data: lsNav.map(r => r.value),
symbol: 'none',
lineStyle: { color: '#fbbf24', width: 2, type: 'dashed' },
itemStyle: { color: '#fbbf24' },
})
}
return {
animation: false,
legend: {
show: false,
},
grid: { left: 56, right: 16, top: 12, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltipBg,
borderColor: chartTheme.tooltipBorder,
textStyle: { color: chartTheme.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:${chartTheme.tooltipTitle};margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:8px;height:3px;border-radius:1px;background:${p.color};display:inline-block"></span>
${p.seriesName}
</span>
<span style="font-family:monospace">${(p.value as number).toFixed(4)}</span>
</div>`
}
return html
},
},
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: chartTheme.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
scale: true,
axisLabel: { color: chartTheme.text, fontSize: 10 },
splitLine: { lineStyle: { color: chartTheme.splitLine } },
axisLine: { show: false },
},
series,
} as any
}, [result.group_nav, result.long_short_nav, result.run_id])
const chartRef = useECharts(option, [result.run_id, chartTheme])
// 图例
const groupCols = result.group_nav.length > 0
? Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
: []
return (
<div>
<div className="flex items-center gap-3 px-4 pb-2">
{groupCols.map((col, i) => (
<span key={col} className="flex items-center gap-1 text-[10px] text-secondary">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: GROUP_COLORS[i % GROUP_COLORS.length] }} />
{col}
</span>
))}
{result.long_short_nav?.length > 0 && (
<span className="flex items-center gap-1 text-[10px] text-secondary">
<span className="w-2 h-0.5 rounded bg-yellow-400" style={{ borderTop: '2px dashed #fbbf24' }} />
</span>
)}
</div>
<div ref={chartRef} className="h-[280px]" />
</div>
)
}
@@ -0,0 +1,90 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/chartTheme'
interface Props {
result: FactorBacktestResult
}
export function FactorICChart({ result }: Props) {
const chartTheme = useChartTheme()
const option = useMemo(() => {
if (!result.ic_series.length) return null
const dates = result.ic_series.map(r => r.date.slice(0, 10))
const values = result.ic_series.map(r => r.ic)
// 12期移动平均
const maWindow = 12
const ma: (number | null)[] = values.map((_, i) => {
if (i < maWindow - 1) return null
const slice = values.slice(i - maWindow + 1, i + 1)
return slice.reduce((a, b) => a + b, 0) / slice.length
})
return {
animation: false,
grid: { left: 50, right: 16, top: 16, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltipBg,
borderColor: chartTheme.tooltipBorder,
textStyle: { color: chartTheme.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:${chartTheme.tooltipTitle};margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
<span style="color:${p.color}">${p.seriesName}</span>
<span style="font-family:monospace">${(p.value * 100).toFixed(2)}%</span>
</div>`
}
return html
},
},
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: chartTheme.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: { color: chartTheme.text, fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
splitLine: { lineStyle: { color: chartTheme.splitLine } },
axisLine: { show: false },
},
series: [
{
name: 'IC',
type: 'bar',
data: values.map(v => ({
value: v,
itemStyle: {
color: v >= 0
? 'rgba(240,68,56,0.6)'
: 'rgba(18,183,106,0.6)',
},
})),
barMaxWidth: 6,
},
{
name: `MA${maWindow}`,
type: 'line',
data: ma,
smooth: true,
symbol: 'none',
lineStyle: { color: '#f59e0b', width: 1.5 },
z: 10,
},
],
} as any
}, [result.ic_series])
const chartRef = useECharts(option, [result.run_id, chartTheme])
return <div ref={chartRef} className="h-[200px]" />
}
@@ -0,0 +1,65 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { EChartsOption } from 'echarts'
import { useChartTheme } from '@/lib/chartTheme'
interface DistBin {
range: string
count: number
ratio: number
}
/**
* 收益分布直方图 — 全量模拟专用的候选标的收益分布。
* 柱子颜色按收益正负区分(正红负绿),零轴居中。
*/
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
const chartTheme = useChartTheme()
const option = useMemo<EChartsOption>(() => {
const cats = distribution.map(d => d.range)
const vals = distribution.map(d => d.count)
// 判断每档是正还是负(按 range 字符串首字符 +/~)
const colors = distribution.map(d => {
const lo = parseFloat(d.range)
// 中心档(跨 0) 用中性色
if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return '#a1a1aa'
return lo >= 0 ? '#ef4444' : '#22c55e'
})
return {
grid: { left: 48, right: 16, top: 24, bottom: 56 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const p = Array.isArray(params) ? params[0] : params
const bin = distribution[p.dataIndex]
if (!bin) return ''
return `${bin.range}<br/>数量: ${bin.count}<br/>占比: ${(bin.ratio * 100).toFixed(1)}%`
},
},
xAxis: {
type: 'category',
data: cats,
axisLabel: { color: chartTheme.text, fontSize: 10, rotate: 45, interval: 1 },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
},
yAxis: {
type: 'value',
axisLabel: { color: chartTheme.text, fontSize: 10 },
splitLine: { lineStyle: { color: chartTheme.splitLine } },
},
series: [
{
type: 'bar',
data: vals.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } })),
barWidth: '90%',
},
],
}
}, [distribution])
const chartRef = useECharts(option, [distribution, chartTheme])
return <div ref={chartRef} className="h-48 w-full" />
}
@@ -0,0 +1,211 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { StrategyBacktestResult } from '@/lib/api'
import { useChartTheme } from '@/lib/chartTheme'
interface Props {
result: StrategyBacktestResult
}
export function StrategyNavChart({ result }: Props) {
const chartTheme = useChartTheme()
const option = useMemo(() => {
if (!result.equity_curve.length) return null
const moneyFmt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const valueFmt = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const axisMoneyFmt = (v: number) => {
if (Math.abs(v) >= 100_000_000) return `${(v / 100_000_000).toFixed(1)}亿`
if (Math.abs(v) >= 10_000) return `${(v / 10_000).toFixed(0)}`
return moneyFmt.format(v)
}
const dates = result.equity_curve.map(r => r.date.slice(0, 10))
const navValues = result.equity_curve.map(r => r.value)
const benchmarkByDate = new Map((result.benchmark_curve ?? []).map(r => [r.date.slice(0, 10), r.close ?? r.value]))
const benchmarkValues = dates.map(d => benchmarkByDate.get(d) ?? null)
const hasBenchmark = benchmarkValues.some(v => v != null)
const ddValues = result.drawdown_curve.map(r => r.value * 100)
return {
animation: false,
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: { backgroundColor: chartTheme.tooltipBg, color: chartTheme.tooltipText },
},
grid: [
{ left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
{ left: 64, right: hasBenchmark ? 64 : 16, top: '68%', bottom: 46 },
],
xAxis: [
{
type: 'category', data: dates, gridIndex: 0,
axisLabel: { show: false }, axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
},
{
type: 'category', data: dates, gridIndex: 1,
axisLabel: { color: chartTheme.text, fontSize: 10, interval: Math.floor(dates.length / 6) },
axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: chartTheme.axisLine } },
},
],
yAxis: [
{
type: 'value', gridIndex: 0,
scale: true,
name: hasBenchmark ? '上证点位' : '策略资金',
nameTextStyle: { color: chartTheme.text, fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
color: chartTheme.text,
fontSize: 10,
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
},
splitLine: { lineStyle: { color: chartTheme.splitLine } },
axisLine: { show: false },
},
{
type: 'value', gridIndex: 0,
position: 'right',
scale: true,
name: hasBenchmark ? '策略资金' : '',
nameTextStyle: { color: chartTheme.text, fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
show: hasBenchmark,
color: chartTheme.text,
fontSize: 10,
formatter: axisMoneyFmt,
},
splitLine: { show: false },
axisLine: { show: false },
},
{
type: 'value', gridIndex: 1,
position: 'right',
max: 0,
axisLabel: {
color: chartTheme.text, fontSize: 10,
formatter: (v: number) => `${v.toFixed(1)}%`,
},
splitLine: { lineStyle: { color: chartTheme.splitLine } },
axisLine: { show: false },
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
filterMode: 'filter',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
},
{
type: 'slider',
xAxisIndex: [0, 1],
filterMode: 'filter',
height: 16,
bottom: 10,
borderColor: chartTheme.axisLine,
backgroundColor: chartTheme.dataZoomBg,
fillerColor: chartTheme.dataZoomFiller,
handleStyle: { color: chartTheme.dataZoomHandle, borderColor: chartTheme.axisLine },
textStyle: { color: chartTheme.text, fontSize: 10 },
brushSelect: false,
},
],
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltipBg,
borderColor: chartTheme.tooltipBorder,
textStyle: { color: chartTheme.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:${chartTheme.tooltipTitle};margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
const isDrawdown = p.seriesName === '回撤'
const isBenchmark = p.seriesName === '同期上证指数'
html += `<div style="display:flex;justify-content:space-between;gap:16px">
<span style="color:${p.color}">${p.seriesName}</span>
<span style="font-family:monospace">${
isDrawdown
? `${(p.value as number).toFixed(2)}%`
: isBenchmark
? `${valueFmt.format(p.value as number)}`
: moneyFmt.format(p.value as number)
}</span>
</div>`
}
return html
},
},
series: [
{
name: '净值',
type: 'line',
xAxisIndex: 0,
yAxisIndex: hasBenchmark ? 1 : 0,
data: navValues,
symbol: 'none',
lineStyle: { color: '#3b82f6', width: 2.2 },
areaStyle: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(59,130,246,0.15)' },
{ offset: 1, color: 'rgba(59,130,246,0.01)' },
],
} as any,
},
},
...(hasBenchmark ? [{
name: '同期上证指数',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 0,
data: benchmarkValues,
symbol: 'none',
connectNulls: true,
lineStyle: { color: 'rgba(148,163,184,0.45)', width: 1, type: 'dashed' },
}] : []),
{
name: '回撤',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 2,
data: ddValues,
symbol: 'none',
lineStyle: { color: 'rgba(240,68,56,0.6)', width: 1 },
areaStyle: { color: 'rgba(240,68,56,0.12)' },
},
],
} as any
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
const chartRef = useECharts(option, [result.run_id, chartTheme])
return (
<div>
<div className="flex flex-wrap items-center gap-4 px-4 pb-2">
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
<span className="w-3 h-0.5 rounded bg-accent" />
</span>
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
<span className="w-3 h-0.5 rounded bg-red-400/60" />
</span>
{(result.benchmark_curve?.length ?? 0) > 0 && (
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
<span className="w-3 h-0.5 rounded border-t border-dashed border-slate-400/60" />
</span>
)}
<span className="ml-auto text-[10px] text-muted"> · </span>
</div>
<div ref={chartRef} className="h-[282px]" />
</div>
)
}
@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
/**
* ECharts 实例管理 Hook — 自动初始化/resize/销毁。
* 返回 ref 绑定到容器 div,和 setOption 方法。
*/
export function useECharts(
option: EChartsOption | null,
deps: any[] = [],
) {
const chartRef = useRef<HTMLDivElement>(null)
const instanceRef = useRef<ECharts | null>(null)
// 初始化 / 销毁
useEffect(() => {
if (!chartRef.current) return
instanceRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
const handleResize = () => instanceRef.current?.resize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
instanceRef.current?.dispose()
instanceRef.current = null
}
}, [])
// 更新 option
useEffect(() => {
if (!instanceRef.current || !option) return
instanceRef.current.setOption(option, { notMerge: true })
}, [option, ...deps])
return chartRef
}
@@ -0,0 +1,170 @@
import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Clock, X } from 'lucide-react'
import { StockPanel } from '@/components/StockPanel'
import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import type { StrategyBacktestTrade } from '@/lib/api'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
interface Props {
trade: StrategyBacktestTrade | null
onClose: () => void
}
function addDays(date: string, days: number): string {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
function fmtMoney(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
const n = Number(v)
const abs = Math.abs(n)
if (abs >= 100_000_000) return `${(n / 100_000_000).toFixed(2)}亿`
if (abs >= 10_000) return `${(n / 10_000).toFixed(2)}`
return n.toFixed(0)
}
function fmtSignedMoney(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
const prefix = Number(v) > 0 ? '+' : ''
return `${prefix}${fmtMoney(v)}`
}
export function TradeKlineModal({ trade, onClose }: Props) {
const [showIntraday, setShowIntraday] = useState(false)
useEffect(() => {
if (!trade) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [trade, onClose])
useEffect(() => {
if (trade) setShowIntraday(false)
}, [trade])
const dateRange = useMemo(() => {
if (!trade) return null
return {
start: addDays(String(trade.entry_date).slice(0, 10), -45),
end: addDays(String(trade.exit_date).slice(0, 10), 20),
}
}, [trade])
const ranges = useMemo<ChartRange[]>(() => {
if (!trade) return []
return [{
start: String(trade.entry_date).slice(0, 10),
end: String(trade.exit_date).slice(0, 10),
label: '持仓区间',
color: 'rgba(59,130,246,0.07)',
}]
}, [trade])
const priceLines = useMemo<ChartPriceLine[]>(() => {
if (!trade) return []
const start = String(trade.entry_date).slice(0, 10)
const end = String(trade.exit_date).slice(0, 10)
return [
{
value: Number(trade.entry_price),
label: `买入价 ${fmtPrice(trade.entry_price)}`,
color: '#C74040',
start,
end,
},
{
value: Number(trade.exit_price),
label: `卖出价 ${fmtPrice(trade.exit_price)}`,
color: '#2D9B65',
start,
end,
},
]
}, [trade])
return (
<AnimatePresence>
{trade && dateRange && (
<div className="fixed inset-0 z-[70] 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={onClose}
/>
<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 flex max-h-[94vh] w-[92vw] max-w-[1120px] flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
>
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-foreground">{trade.symbol}</span>
<span className="truncate text-sm text-foreground">{trade.name || '交易回放'}</span>
<span className="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"></span>
</div>
<div className="mt-1 text-[11px] text-muted">
{String(trade.entry_date).slice(0, 10)} {String(trade.exit_date).slice(0, 10)} · {trade.duration ?? '—'}
</div>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
<div className="text-right">
<div className="text-muted"> / </div>
<div className="num text-foreground">{fmtPrice(trade.entry_price)} / {fmtPrice(trade.exit_price)}</div>
</div>
<div className="text-right">
<div className="text-muted"></div>
<div className={`num font-semibold ${priceColorClass(trade.pnl_amount ?? trade.pnl_pct)}`}>
{fmtSignedMoney(trade.pnl_amount)} / {fmtPct(trade.pnl_pct)}
</div>
</div>
<button
onClick={() => setShowIntraday((v) => !v)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
showIntraday
? 'border border-accent/30 bg-accent/15 text-accent'
: 'border border-border bg-elevated text-secondary hover:border-accent/30'
}`}
>
<Clock className="h-3 w-3" />
</button>
<button
onClick={onClose}
className="rounded-btn p-1 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<StockPanel
symbol={trade.symbol}
height={520}
dateRange={dateRange}
ranges={ranges}
priceLines={priceLines}
showLimitMarkers={false}
showMarkerToggle={false}
showIntraday={showIntraday}
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
/>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
+197
View File
@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield } from 'lucide-react'
import { useSettings } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
const PRESETS: { label: string; url: string; model: string; website: string; websiteLabel: string; description: string }[] = [
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1', model: 'deepseek-chat', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
]
export function SettingsAIPanel() {
const qc = useQueryClient()
const settings = useSettings()
const s = settings.data
const [provider, setProvider] = useState('openai_compat')
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
const [tokenBudget, setTokenBudget] = useState(5_000_000)
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
// 测试
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
useEffect(() => {
if (!s) return
setProvider(s.ai_provider ?? 'openai_compat')
setBaseUrl(s.ai_base_url ?? '')
setModel(s.ai_model ?? '')
setTokenBudget(s.ai_daily_token_budget ?? 500_000)
}, [s])
const save = useMutation({
mutationFn: () => api.saveAiSettings({
provider, base_url: baseUrl, api_key: apiKey || undefined, model, daily_token_budget: tokenBudget,
}),
onSuccess: () => {
setSaved(true); setApiKey(''); qc.invalidateQueries({ queryKey: QK.settings })
setTimeout(() => setSaved(false), 2000)
},
})
const handleTest = async () => {
setTesting(true); setTestResult(null)
try {
// 先保存当前配置(不保存 Key 仅用于测试时临时存)
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget })
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}${r.usage ? ` · 消耗 ${r.usage.prompt + r.usage.completion} tokens` : ''}` : (r.error ?? '未知错误') })
} catch (e: any) {
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
} finally { setTesting(false) }
}
const handlePreset = (p: typeof PRESETS[number]) => {
setBaseUrl(p.url); setModel(p.model)
}
const configured = s?.has_ai_key
const selectedPreset = PRESETS.find(p => p.url === baseUrl)
return (
<div className="max-w-2xl space-y-5">
{/* ===== 状态横幅 ===== */}
<div className={`rounded-2xl border px-5 py-4 flex items-center gap-4 ${configured ? 'border-emerald-400/20 bg-emerald-400/[0.04]' : 'border-amber-400/20 bg-amber-400/[0.04]'}`}>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${configured ? 'bg-emerald-400/10 text-emerald-400' : 'bg-amber-400/10 text-amber-400'}`}>
{configured ? <Wifi className="h-5 w-5" /> : <WifiOff className="h-5 w-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground">{configured ? 'AI 已连接' : 'AI 未配置'}</div>
<div className="text-xs text-muted mt-0.5">
{configured ? `${s?.ai_model} · ${s?.ai_api_key_masked}` : '配置 API Key 后即可使用 AI 策略定制'}
</div>
</div>
{configured && (
<button onClick={handleTest} disabled={testing}
className="h-8 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-foreground hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
{testing ? '测试中' : '测试'}
</button>
)}
</div>
{/* 测试结果 */}
{testResult && (
<div className={`rounded-xl border px-4 py-3 text-xs flex items-center gap-2.5 ${testResult.ok ? 'border-emerald-400/20 bg-emerald-400/[0.04] text-emerald-400' : 'border-danger/20 bg-danger/[0.04] text-danger'}`}>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${testResult.ok ? 'bg-emerald-400' : 'bg-danger'}`} />
{testResult.msg}
</div>
)}
{/* ===== 快速预设 ===== */}
<div className="space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
<div className="flex flex-wrap items-start gap-x-4 gap-y-3">
{PRESETS.map(p => (
<button key={p.label} onClick={() => handlePreset(p)}
className={`rounded-lg border px-3 py-2 text-left transition-all ${baseUrl === p.url ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-surface text-secondary hover:border-accent/30'}`}>
<div className="flex items-center gap-1.5 text-xs font-medium">
<span>{p.label}</span>
</div>
</button>
))}
</div>
{selectedPreset && (
<div className="rounded-lg border border-border/30 bg-surface/30 px-3 py-2 text-[10px] leading-relaxed">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-secondary">{selectedPreset.description}</span>
</div>
<a href={selectedPreset.website} target="_blank" rel="noreferrer"
className="mt-1 inline-flex text-muted hover:text-accent transition-colors">
{selectedPreset.websiteLabel}
</a>
</div>
)}
</div>
{/* ===== 自定义配置卡片 ===== */}
<div className="rounded-2xl border border-border/30 bg-surface/30 overflow-hidden">
<div className="px-5 py-3 border-b border-border/20">
<span className="text-xs font-medium text-foreground/70"></span>
</div>
<div className="px-5 py-4 space-y-4">
{/* API 地址 + 模型 同行 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider">API </label>
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)}
placeholder="https://code.alysc.top"
className="w-full h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"></label>
<input type="text" value={model} onChange={e => setModel(e.target.value)}
placeholder="gpt-5.5"
className="w-full h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
</div>
{/* API Key */}
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider">API Key</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<input
type={showKey ? 'text' : 'password'}
value={apiKey} onChange={e => setApiKey(e.target.value)}
placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'}
className="w-full h-8 px-2.5 pr-8 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
<button onClick={() => setShowKey(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted">
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<button onClick={handleTest} disabled={testing || !apiKey}
className="h-8 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 disabled:opacity-40 transition-all flex items-center gap-1.5 shrink-0">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
</button>
</div>
</div>
{/* Token 预算 */}
<div className="space-y-1.5">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"> Token </label>
<div className="flex items-center gap-3">
<input type="number" value={tokenBudget} onChange={e => setTokenBudget(Math.max(10000, Number(e.target.value) || 0))}
min={10000} step={100000}
className="w-44 h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
<span className="text-[10px] text-muted"> AI </span>
</div>
</div>
</div>
</div>
{/* ===== 安全提示 ===== */}
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/[0.04] px-5 py-3.5 flex items-start gap-3">
<Shield className="h-4 w-4 text-amber-400/70 mt-0.5 shrink-0" />
<div className="text-[10px] text-amber-400/70 leading-relaxed">
API Key
</div>
</div>
{/* ===== 保存 ===== */}
<button onClick={() => save.mutate()} disabled={save.isPending || !baseUrl || !model}
className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
</button>
</div>
)
}
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, Zap, Settings2, Lock } from 'lucide-react'
import { api, type CustomSignal } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { BUILTIN_SIGNAL_DEFINITIONS, type SignalKind } from '@/lib/signals'
import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog'
type SignalSection = 'builtin' | 'custom'
const KIND_LABEL: Record<SignalKind, string> = { entry: '买入', exit: '卖出', both: '买卖通用' }
const KIND_CLASS: Record<SignalKind, string> = {
entry: 'bg-accent/10 text-accent',
exit: 'bg-warning/10 text-warning',
both: 'bg-muted/10 text-muted',
}
export function SettingsCustomSignalsPanel() {
const qc = useQueryClient()
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions })
const [activeSection, setActiveSection] = useState<SignalSection>('builtin')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CustomSignal | null>(null)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const resetDeleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const fields = options.data?.fields ?? []
const signals = list.data?.signals ?? []
const enabledCustomSignals = signals.filter(sig => sig.enabled).length
const tabs = [
{ key: 'builtin' as const, label: '内置信号', count: BUILTIN_SIGNAL_DEFINITIONS.length, hint: '系统提供,只读' },
{ key: 'custom' as const, label: '自定义信号', count: signals.length, hint: `${enabledCustomSignals} 个已启用` },
]
useEffect(() => () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
}, [])
const clearDeleteConfirm = () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = null
setConfirmingDeleteId(null)
}
const openNew = () => {
setEditing(null)
clearDeleteConfirm()
setActiveSection('custom')
setShowForm(true)
}
const openEdit = (sig: CustomSignal) => {
setEditing(sig)
clearDeleteConfirm()
setActiveSection('custom')
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const del = useMutation({
mutationFn: api.customSignalDelete,
onSuccess: () => {
clearDeleteConfirm()
qc.invalidateQueries({ queryKey: QK.customSignals })
},
})
const toggleEnabled = (sig: CustomSignal) => {
api.customSignalSave({ ...sig, enabled: !sig.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.customSignals }))
}
const handleDeleteClick = (sig: CustomSignal) => {
if (confirmingDeleteId === sig.id) {
clearDeleteConfirm()
del.mutate(sig.id)
return
}
setConfirmingDeleteId(sig.id)
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = setTimeout(() => setConfirmingDeleteId(null), 3000)
}
return (
<div className="max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(234,179,8,0.12),transparent_38%)]">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[11px] uppercase tracking-[0.2em] text-amber-400/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
+ + 使
</p>
</div>
<button
onClick={openNew}
className="inline-flex items-center justify-center gap-1.5 rounded-btn bg-amber-500/90 px-3 py-1.5 text-xs font-medium text-base hover:bg-amber-500 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-3">
<StatCard label="内置信号" value={BUILTIN_SIGNAL_DEFINITIONS.length} hint="系统提供,只读" />
<StatCard label="自定义信号" value={signals.length} hint="用户创建,可编辑" />
<StatCard label="已启用自定义" value={enabledCustomSignals} hint="会注入 csg_* 列" />
</div>
<div className="mt-5 rounded-card border border-border bg-base/60 p-1.5">
<div className="grid grid-cols-1 gap-1.5 md:grid-cols-2">
{tabs.map(tab => {
const active = activeSection === tab.key
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveSection(tab.key)}
className={`rounded-btn px-4 py-3 text-left transition-colors ${active ? 'bg-amber-500/15 text-amber-300 shadow-sm' : 'text-secondary hover:bg-elevated hover:text-foreground'}`}
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{tab.label}</span>
<span className={`rounded px-2 py-0.5 text-[11px] ${active ? 'bg-amber-400/15 text-amber-300' : 'bg-elevated text-muted'}`}>{tab.count}</span>
</div>
<div className="mt-1 text-[11px] text-muted">{tab.hint}</div>
</button>
)
})}
</div>
</div>
</section>
{activeSection === 'builtin' && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<div className="flex items-center gap-2">
<Lock className="h-3.5 w-3.5 text-muted" />
<h3 className="text-sm font-medium text-foreground"></h3>
<span className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-muted"></span>
</div>
<p className="mt-1 text-xs text-muted"> enriched </p>
</div>
<div className="text-[11px] text-muted">ID <span className="font-mono text-foreground/70">signal_</span></div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{BUILTIN_SIGNAL_DEFINITIONS.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground truncate">{sig.name}</h4>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
</div>
<p className="mt-1 text-[11px] text-muted font-mono truncate">{sig.id}</p>
</div>
<span className="shrink-0 rounded border border-border bg-elevated px-1.5 py-0.5 text-[10px] text-muted">{sig.category}</span>
</div>
<p className="mt-3 text-xs leading-5 text-secondary">{sig.description}</p>
</div>
))}
</div>
</section>
)}
{activeSection === 'custom' && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-amber-400" />
<h3 className="text-sm font-medium text-foreground"></h3>
<span className="rounded bg-amber-400/10 px-1.5 py-0.5 text-[10px] text-amber-400"></span>
</div>
<p className="mt-1 text-xs text-muted">/ csg_* 使</p>
</div>
<button
onClick={openNew}
className="inline-flex items-center justify-center gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-400/10 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{signals.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground truncate">{sig.name}</h3>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted"></span>}
</div>
<p className="mt-1 text-[11px] text-muted font-mono truncate">csg_{sig.id}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`p-1 rounded cursor-pointer ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
<Zap className="h-3.5 w-3.5" />
</button>
<button onClick={() => openEdit(sig)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 cursor-pointer" title="编辑">
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmingDeleteId === sig.id ? (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer disabled:opacity-50"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer disabled:opacity-50"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1">
{sig.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
<span className="text-muted/50 w-6 text-right">{i === 0 ? '当' : '且'}</span>
<span className="font-mono text-foreground/80">{fieldLabel(c.left, fields)}</span>
<span className="font-mono text-muted">{c.op}</span>
<span className="font-mono text-foreground/80">{rightDisplay(c.right, fields)}</span>
</div>
))}
</div>
</div>
))}
{signals.length === 0 && (
<div className="rounded-card border border-border bg-base px-5 py-10 text-center text-sm text-muted md:col-span-2">
</div>
)}
</div>
</section>
)}
<CustomSignalDialog open={showForm} signal={editing} onClose={closeForm} />
</div>
)
}
function StatCard({ label, value, hint }: { label: string; value: number; hint: string }) {
return (
<div className="rounded-card border border-border/80 bg-base/70 px-4 py-3">
<div className="text-[11px] text-muted">{label}</div>
<div className="mt-1 text-2xl font-semibold text-foreground">{value}</div>
<div className="mt-0.5 text-[11px] text-muted">{hint}</div>
</div>
)
}
function fieldLabel(key: string, fields: { key: string; label: string }[]): string {
return fields.find(f => f.key === key)?.label ?? key
}
function rightDisplay(right: string, fields: { key: string; label: string }[]): string {
if (right.startsWith('field:')) return fieldLabel(right.slice(6), fields)
return right
}
@@ -0,0 +1,291 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ExternalLink, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
import { api, type AnalysisColumn, type AnalysisMenu, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
function dtypeToColumnType(dtype: string): AnalysisColumn['type'] {
return dtype === 'int' || dtype === 'float' ? 'number' : 'string'
}
function buildColumn(field: ExtDataField): AnalysisColumn {
return {
field: field.name,
label: field.label || field.name,
type: dtypeToColumnType(field.dtype),
precision: field.dtype === 'float' ? 2 : null,
sortable: field.dtype === 'int' || field.dtype === 'float',
visible: true,
}
}
function firstMatchingField(config: ExtDataConfig | undefined, keywords: string[]) {
if (!config) return ''
for (const keyword of keywords) {
const lower = keyword.toLowerCase()
const matched = config.fields.find(f => f.name.toLowerCase().includes(lower) || f.label.toLowerCase().includes(lower))
if (matched) return matched.name
}
return config.fields.find(f => !['symbol', 'code'].includes(f.name) && f.dtype === 'string')?.name ?? ''
}
export function SettingsExtPagesPanel() {
const qc = useQueryClient()
const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
const extData = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const configs = extData.data?.items ?? []
const menuItems = menus.data?.items ?? []
const [showForm, setShowForm] = useState(false)
const [editingMenu, setEditingMenu] = useState<AnalysisMenu | null>(null)
const [id, setId] = useState('')
const [label, setLabel] = useState('')
const [dataSource, setDataSource] = useState('')
const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table'>('dimension_rank')
const [dimensionField, setDimensionField] = useState('')
const [rankField, setRankField] = useState('')
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [error, setError] = useState('')
const activeConfig = configs.find(c => c.id === dataSource) ?? configs[0]
const fields = activeConfig?.fields ?? []
const numericFields = useMemo(() => fields.filter(f => f.dtype === 'int' || f.dtype === 'float'), [fields])
const resetForm = () => {
const cfg = configs[0]
setEditingMenu(null)
setId('')
setLabel('')
setDataSource(cfg?.id ?? '')
setTemplate('dimension_rank')
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField('')
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
setError('')
}
const editMenu = (menu: AnalysisMenu) => {
const cfg = configs.find(c => c.id === menu.data_source)
setEditingMenu(menu)
setId(menu.id)
setLabel(menu.label)
setDataSource(menu.data_source)
setTemplate(menu.template)
setDimensionField(menu.dimension_field ?? firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField(menu.rank_field ?? '')
setSelectedColumns(menu.detail_columns.map(c => c.field))
setError('')
setShowForm(true)
}
const save = useMutation({
mutationFn: () => {
const cfg = activeConfig
if (!cfg) throw new Error('请选择扩展数据源')
if (!id.trim()) throw new Error('请输入菜单标识')
if (!label.trim()) throw new Error('请输入菜单名称')
if (template === 'dimension_rank' && !dimensionField) throw new Error('请选择分组字段')
if (template === 'ranking' && !rankField) throw new Error('请选择排名字段')
const detailColumns = selectedColumns
.map(name => cfg.fields.find(f => f.name === name))
.filter(Boolean)
.map(f => buildColumn(f as ExtDataField))
const groupColumns: AnalysisColumn[] = template === 'dimension_rank'
? [
{ field: '__dimension', label: cfg.fields.find(f => f.name === dimensionField)?.label || '分组', type: 'string', visible: true },
{ field: '__count', label: '股票数', type: 'number', sortable: true, visible: true },
...detailColumns.filter(c => c.type === 'number').slice(0, 2).map(c => ({ ...c, label: `平均${c.label || c.field}`, aggregate: 'avg' as const })),
]
: []
return api.analysisMenuSave(id.trim(), {
label: label.trim(),
icon: template === 'dimension_rank' ? 'tags' : 'chart',
data_source: cfg.id,
template,
dimension_field: template === 'dimension_rank' ? dimensionField : null,
rank_field: template === 'ranking' ? rankField : null,
group_columns: groupColumns,
detail_columns: detailColumns,
default_sort: template === 'ranking' && rankField ? { field: rankField, order: 'desc' } : null,
visible: editingMenu?.visible ?? true,
order: editingMenu?.order ?? menuItems.length + 100,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.analysisMenus })
setShowForm(false)
resetForm()
},
onError: (err) => setError(String((err as any)?.message ?? err)),
})
const del = useMutation({
mutationFn: api.analysisMenuDelete,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }),
})
return (
<div className="max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(139,92,246,0.14),transparent_38%)]">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[11px] uppercase tracking-[0.2em] text-accent/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
访
</p>
</div>
<button
onClick={() => { resetForm(); setShowForm(true) }}
className="inline-flex items-center justify-center gap-1.5 rounded-btn bg-accent/90 px-3 py-1.5 text-xs font-medium text-base hover:bg-accent transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</section>
{showForm && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{editingMenu ? '编辑扩展页面' : '新建扩展页面'}</h3>
<p className="mt-1 text-[11px] text-muted"></p>
</div>
<button onClick={() => { setShowForm(false); setError('') }} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input
value={id}
disabled={!!editingMenu}
onChange={e => setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="如 concept_hot"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-60"
/>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="如 概念热度" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select
value={dataSource || activeConfig?.id || ''}
onChange={e => {
const cfg = configs.find(c => c.id === e.target.value)
setDataSource(e.target.value)
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
setRankField('')
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
}}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{configs.map(cfg => <option key={cfg.id} value={cfg.id}>{cfg.label}</option>)}
</select>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={template} onChange={e => setTemplate(e.target.value as any)} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="dimension_rank"></option>
<option value="ranking"></option>
<option value="table"></option>
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={dimensionField} onChange={e => setDimensionField(e.target.value)} disabled={template !== 'dimension_rank'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{fields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={rankField} onChange={e => setRankField(e.target.value)} disabled={template !== 'ranking'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
<option value=""></option>
{numericFields.map(f => <option key={f.name} value={f.name}>{f.label || f.name}</option>)}
</select>
</label>
</div>
<div>
<div className="text-[11px] text-muted mb-2"></div>
<div className="flex flex-wrap gap-2">
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
const active = selectedColumns.includes(f.name)
return (
<button
key={f.name}
onClick={() => setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
>
{f.label || f.name}
</button>
)
})}
</div>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={() => { setShowForm(false); setError('') }} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</section>
)}
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{menuItems.map(menu => (
<div key={menu.id} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground">{menu.label}</h3>
{menu.builtin && <span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"></span>}
{!menu.visible && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted"></span>}
</div>
<p className="mt-1 text-[11px] text-muted font-mono">{menu.id}</p>
</div>
<div className="flex items-center gap-1">
<button onClick={() => editMenu(menu)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
{!menu.builtin && (
<button onClick={() => del.mutate(menu.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1 text-[11px] text-secondary">
<div><span className="font-mono text-muted">{menu.data_source}</span></div>
<div>{menu.template}</div>
{menu.dimension_field && <div>{menu.dimension_field}</div>}
<div>{menu.detail_columns.length} </div>
</div>
<Link to={`/analysis/${menu.id}`} className="mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-btn border border-border bg-elevated px-3 py-1.5 text-xs text-foreground hover:bg-border/30 transition-colors">
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</div>
))}
{menuItems.length === 0 && (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted md:col-span-2 xl:col-span-3"></div>
)}
</section>
</div>
)
}
+435
View File
@@ -0,0 +1,435 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import {
Key,
Eye,
EyeOff,
Trash2,
CheckCircle2,
AlertCircle,
RefreshCw,
Activity,
ExternalLink,
Loader2,
Save,
Check,
HelpCircle,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { QK } from '@/lib/queryKeys'
import { CAP_LABELS, tierTextStyle, tierStyle, tierBaseName, ALL_TIERS, TierTag } from '@/lib/capability-labels'
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
export function SettingsKeysPanel() {
const qc = useQueryClient()
const settings = useSettings()
const caps = useCapabilities()
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
const [saved, setSaved] = useState(false)
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
onSuccess: (data) => {
setKeyInput('')
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
if (data.ok) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
// ok=false 由 save.data 在下方渲染提示(reason=invalid),无需额外处理
},
})
const clear = useMutation({
mutationFn: () => api.clearTickflowKey(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
},
})
const redetect = useMutation({
mutationFn: api.redetectCapabilities,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.settings })
qc.invalidateQueries({ queryKey: QK.capabilities })
},
})
const mode = settings.data?.mode
const masked = settings.data?.tickflow_api_key_masked
const capCount = caps.data ? Object.keys(caps.data.capabilities).length : 0
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.3fr] gap-6 max-w-5xl">
{/* ========== 左列: Key 配置 ========== */}
<div className="space-y-6">
<Card icon={Key} title="数据源 API Key">
<p className="text-sm text-secondary leading-relaxed mb-4">
{' '}
<a
href="https://tickflow.org/auth/register"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline inline-flex items-baseline gap-0.5"
>
<ExternalLink className="h-3 w-3 self-center" />
</a>{' '}
API Key ,,
</p>
{/* 当前状态 */}
<div className="flex items-center justify-between mb-4">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-widest text-muted"></div>
<div className="mt-1 flex items-center gap-2 min-w-0">
{mode === 'api_key' ? (
<>
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
<span className="text-sm font-medium shrink-0"></span>
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
</>
) : mode === 'free' ? (
<>
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
<span className="text-sm font-medium shrink-0"> Key</span>
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
</>
) : (
<>
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
<span className="text-sm font-medium text-muted"> · Free </span>
</>
)}
</div>
</div>
{(mode === 'api_key' || mode === 'free') && (
<button
onClick={() => setConfirmClear(true)}
disabled={clear.isPending}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated text-secondary hover:text-danger text-xs transition-colors duration-150 ease-smooth disabled:opacity-50 shrink-0"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
{/* 输入 */}
<form
onSubmit={(e) => {
e.preventDefault()
if (keyInput.trim()) save.mutate()
}}
className="space-y-2"
>
<div className="relative">
<input
type={revealing ? 'text' : 'password'}
placeholder={mode === 'none' ? '粘贴数据源 API Key' : '粘贴新 Key 替换当前'}
value={keyInput}
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth"
/>
<button
type="button"
onClick={() => setRevealing((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors duration-150 ease-smooth"
tabIndex={-1}
aria-label={revealing ? '隐藏' : '显示'}
>
{revealing ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<button
type="submit"
disabled={save.isPending || (!keyInput.trim() && !saved)}
className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all"
>
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存并检测'}
</button>
{/* 检测中提示 —— 成功/失败后自动消失 */}
{save.isPending && (
<div className="flex items-start gap-1.5 rounded-btn border border-warning/30 bg-warning/10 px-3 py-2 text-[11px] leading-snug text-warning">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
·
<button
type="button"
onClick={() => { save.reset(); redetect.mutate() }}
disabled={redetect.isPending}
className="font-semibold underline underline-offset-2 hover:text-warning/80 disabled:opacity-50"
>
{redetect.isPending ? '重新检测中…' : '重新检测'}
</button>
</span>
</div>
)}
</form>
{save.isError && (
<div className="mt-3 text-xs text-danger">
:{String((save.error as any).message)}
</div>
)}
{/* 无效 key —— 先探后存:探测失败(key 无效/乱填)时不存储,提示用户 */}
{save.data && !save.data.ok && (
<div className="mt-3 text-xs text-danger flex items-center gap-1.5">
<AlertCircle className="h-3 w-3 shrink-0" />
{save.data.reason === 'invalid'
? 'Key 无效或已过期,请检查后重试(未保存该 Key)'
: save.data.error ?? '保存失败'}
</div>
)}
{save.data?.ok && (
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3" />
{save.data.tier_label}
{save.data.mode === 'free' && '(免费档 · 历史日K)'}
</div>
)}
</Card>
</div>
{/* ========== 右列: 档位 + 能力 ========== */}
<div className="space-y-6">
<Card
icon={Activity}
title="订阅档位"
right={
<button
onClick={() => redetect.mutate()}
disabled={redetect.isPending}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated hover:bg-elevated/80 text-xs text-secondary transition-colors duration-150 ease-smooth disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${redetect.isPending ? 'animate-spin' : ''}`} />
</button>
}
>
{caps.data ? (
<>
<div className="flex items-center gap-1.5">
<div className="font-mono text-3xl font-bold tracking-tight" style={tierTextStyle(caps.data.label)}>
{caps.data.label}
</div>
<TierHelpPopover currentLabel={caps.data.label} />
</div>
<div className="mt-1 text-xs text-muted">
API Key · "代表性 capability"
</div>
{settings.data?.missing_caps && settings.data.missing_caps.length > 0 && (
<div className="mt-3 rounded-btn border border-warning/40 bg-warning/5 px-3 py-2 text-xs">
<div className="font-medium text-warning mb-1">
({settings.data.missing_caps.length} )
</div>
<div className="text-secondary space-y-0.5">
{settings.data.missing_caps.map((c) => (
<div key={c} className="font-mono">
{CAP_LABELS[c]?.name ?? c}
</div>
))}
</div>
</div>
)}
</>
) : (
<div className="text-sm text-muted"></div>
)}
</Card>
<Card icon={CheckCircle2} title="可用功能" badge={`${capCount}`}>
{caps.data && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="-mx-5 -mb-5"
>
<div className="border-t border-border">
{Object.entries(caps.data.capabilities).map(([cap, lim]) => {
const meta = CAP_LABELS[cap]
return (
<div
key={cap}
className="px-5 py-3 border-b border-border last:border-b-0 flex items-baseline justify-between gap-4"
>
<div className="min-w-0">
<div className="text-sm text-foreground truncate">
{meta?.name ?? cap}
</div>
{meta?.hint && (
<div className="mt-0.5 text-[11px] text-muted truncate">
{meta.hint}
</div>
)}
</div>
<div className="text-right shrink-0 text-xs">
<div className="font-mono text-foreground">
{lim.rpm ? `${lim.rpm}/min` : lim.subscribe ? `${lim.subscribe} 订阅` : '—'}
</div>
{lim.batch && (
<div className="font-mono text-muted">{lim.batch} /</div>
)}
</div>
</div>
)
})}
</div>
</motion.div>
)}
{settings.data?.probe_log && settings.data.probe_log.length > 0 && (
<details className="mt-4 -mx-5 -mb-5 border-t border-border">
<summary className="cursor-pointer px-5 py-3 text-xs text-muted hover:text-secondary transition-colors duration-150 ease-smooth select-none">
</summary>
<div className="px-5 pb-4 font-mono text-[11px] space-y-0.5 text-secondary">
{settings.data.probe_log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</details>
)}
</Card>
</div>
</div>
{/* 确认清除 Key 弹窗 */}
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setConfirmClear(false)}
/>
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2"> API Key</h3>
<p className="text-xs text-secondary mb-5">
退(K), Key
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmClear(false)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
>
</button>
<button
onClick={() => { setConfirmClear(false); clear.mutate() }}
disabled={clear.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50"
>
{clear.isPending ? '清除中...' : '确认清除'}
</button>
</div>
</div>
</div>
)}
</>
)
}
// ===== 通用卡片 =====
// ===== 档位说明弹窗 =====
function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
const [open, setOpen] = useState(false)
const currentBase = tierBaseName(currentLabel)
return (
<div className="relative inline-flex items-center">
<HelpCircle
className="h-4 w-4 text-muted/60 cursor-help hover:text-muted transition-colors"
onClick={() => setOpen(v => !v)}
/>
<AnimatePresence>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 mt-1 z-50 w-72 bg-surface border border-border rounded-lg shadow-xl p-3.5 text-[11px] leading-relaxed"
onClick={e => e.stopPropagation()}
>
{/* 档位 tag 横排 */}
<div className="flex items-center gap-1.5 mb-3">
{ALL_TIERS.map(t => (
<div key={t} className={`flex flex-col items-center gap-1 ${t === currentBase ? '' : 'opacity-60'}`}>
<TierTag label={t} />
</div>
))}
</div>
{/* 每档说明 */}
<div className="space-y-1 mb-3 pb-3 border-b border-border">
{ALL_TIERS.map(t => {
const s = tierStyle(t)
return (
<div key={t} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={s.dotStyle} />
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? '无' : t}</span>
<span className="text-secondary">{s.desc}</span>
</div>
)
})}
</div>
{/* 检测说明 */}
<div className="text-secondary space-y-1.5">
<div className="font-medium text-foreground"></div>
<p> Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为( Key);K但无复权因子则判为Free; Starter/Pro/Expert</p>
<p className="text-muted">(K), Key,</p>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
badge?: string
right?: React.ReactNode
children: React.ReactNode
}
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
return (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Icon className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{badge && (
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
{badge}
</span>
)}
</div>
{right}
</div>
{children}
</section>
)
}
@@ -0,0 +1,310 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
interface NavEntry {
id: string
label: string
type: 'builtin' | 'analysis'
visible: boolean
}
const BUILTIN_PAGES: NavEntry[] = [
{ id: '/', label: '看板', type: 'builtin', visible: true },
{ id: '/watchlist', label: '自选', type: 'builtin', visible: true },
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
{ id: '/backtest', label: '回测', type: 'builtin', visible: true },
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
{ id: '/financials', label: '财务', type: 'builtin', visible: true },
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
{ id: '/data', label: '数据', type: 'builtin', visible: true },
]
// ── Sortable row ──
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
entry: NavEntry
hidden: boolean
onToggleHidden: (id: string) => void
badgeEnabled?: boolean
onToggleBadge?: (id: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: entry.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 10 : undefined,
}
return (
<div
ref={setNodeRef}
style={style}
className={`grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
} ${hidden ? 'opacity-50' : ''}`}
>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors"
>
<GripVertical className="h-4 w-4" />
</div>
<div className="min-w-0 flex items-center gap-2">
<span className={`truncate text-sm font-medium ${!hidden ? 'text-foreground' : 'text-muted line-through'}`}>
{entry.label}
</span>
{hidden && (
<span className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-muted shrink-0"></span>
)}
<span className="truncate text-[11px] text-muted font-mono">{entry.id}</span>
</div>
<div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] ${
entry.type === 'analysis' ? 'bg-accent/10 text-accent' : 'bg-elevated text-muted'
}`}>
{entry.type === 'builtin' ? '内置' : '扩展'}
</span>
</div>
<div className="flex justify-center">
<button
onClick={() => onToggleHidden(entry.id)}
className={`rounded p-1 transition-colors ${
hidden
? 'text-muted hover:text-accent hover:bg-accent/10'
: 'text-accent hover:bg-accent/10'
}`}
title={hidden ? '显示' : '隐藏'}
>
{hidden ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="flex justify-center">
{entry.type === 'builtin' ? (
<Link
to={entry.id}
className="rounded p-1 text-muted hover:text-accent hover:bg-accent/10 transition-colors"
title="打开页面"
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
) : (
<Link
to={`/settings?tab=ext-pages`}
className="rounded p-1 text-muted hover:text-accent hover:bg-accent/10 transition-colors"
title="编辑扩展页面"
>
<Settings className="h-3.5 w-3.5" />
</Link>
)}
</div>
{/* 第 6 列: 徽标开关 (仅监控中心) */}
<div className="flex justify-center">
{onToggleBadge && (
<button
onClick={() => onToggleBadge(entry.id)}
className={`rounded p-1 transition-colors ${
badgeEnabled
? 'text-accent hover:bg-accent/10'
: 'text-muted hover:text-accent hover:bg-accent/10'
}`}
title={badgeEnabled ? '关闭数字提示' : '开启数字提示'}
>
<Bell className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)
}
// ── Main panel ──
export function SettingsMenuSettingsPanel() {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
const analysisEntries: NavEntry[] = (menus.data?.items ?? []).map(m => ({
id: m.id,
label: m.label,
type: 'analysis' as const,
visible: m.visible,
}))
const allEntries = useMemo(() => {
const saved = prefs?.nav_order ?? []
const entryMap = new Map<string, NavEntry>()
for (const e of BUILTIN_PAGES) entryMap.set(e.id, e)
for (const e of analysisEntries) entryMap.set(e.id, e)
if (saved.length === 0) return [...BUILTIN_PAGES, ...analysisEntries]
const ordered: NavEntry[] = []
const seen = new Set<string>()
for (const id of saved) {
const entry = entryMap.get(id)
if (entry) {
ordered.push(entry)
seen.add(id)
}
}
for (const e of [...BUILTIN_PAGES, ...analysisEntries]) {
if (!seen.has(e.id)) ordered.push(e)
}
return ordered
}, [prefs?.nav_order, analysisEntries])
const hiddenSet = useMemo(() => new Set(prefs?.nav_hidden ?? []), [prefs?.nav_hidden])
// Local order state for optimistic drag updates
const [localOrder, setLocalOrder] = useState<string[] | null>(null)
const orderedEntries = useMemo(() => {
const order = localOrder ?? prefs?.nav_order ?? []
if (!order.length) return allEntries
const byId = new Map(allEntries.map(e => [e.id, e]))
const result: NavEntry[] = []
const seen = new Set<string>()
for (const id of order) {
const e = byId.get(id)
if (e) { result.push(e); seen.add(id) }
}
for (const e of allEntries) {
if (!seen.has(e.id)) result.push(e)
}
return result
}, [localOrder, prefs?.nav_order, allEntries])
const saveNavOrder = useMutation({
mutationFn: (order: string[]) => api.saveNavOrder(order),
onSuccess: () => {
setLocalOrder(null)
qc.invalidateQueries({ queryKey: QK.preferences })
},
})
const saveNavHidden = useMutation({
mutationFn: (hidden: string[]) => api.saveNavHidden(hidden),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const ids = orderedEntries.map(e => e.id)
const oldIdx = ids.indexOf(active.id as string)
const newIdx = ids.indexOf(over.id as string)
const reordered = arrayMove(ids, oldIdx, newIdx)
setLocalOrder(reordered)
saveNavOrder.mutate(reordered)
}
const toggleHidden = (id: string) => {
const next = new Set(hiddenSet)
if (next.has(id)) next.delete(id)
else next.add(id)
saveNavHidden.mutate([...next])
}
// 监控中心徽标开关 (localStorage)
const [badgeEnabled, setBadgeEnabled] = useState(() => {
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
})
const toggleBadge = (id: string) => {
if (id !== '/monitor') return
const next = !badgeEnabled
setBadgeEnabled(next)
try { localStorage.setItem('monitor_badge_enabled', next ? '1' : '0') } catch { /* ignore */ }
}
return (
<div className="max-w-5xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.12),transparent_38%)]">
<div className="text-[11px] uppercase tracking-[0.2em] text-accent/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
</p>
</section>
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
<div />
<div></div>
<div></div>
<div className="text-center"></div>
<div className="text-center"></div>
<div className="text-center"></div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedEntries.map(e => e.id)}
strategy={verticalListSortingStrategy}
>
{orderedEntries.map((entry) => (
<SortableItem
key={entry.id}
entry={entry}
hidden={hiddenSet.has(entry.id)}
onToggleHidden={toggleHidden}
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
/>
))}
</SortableContext>
</DndContext>
{menus.isLoading && (
<div className="px-5 py-10 text-center text-sm text-muted">...</div>
)}
</section>
</div>
)
}
@@ -0,0 +1,383 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useQueryClient, useMutation } from '@tanstack/react-query'
import {
Activity,
Shield,
Wifi,
BarChart3,
Flame,
Zap,
Bell,
} from 'lucide-react'
import {
usePreferences,
useQuoteStatus,
useQuoteInterval,
useCapabilities,
} from '@/lib/useSharedQueries'
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
// 页面 → 显示名
const PAGE_LABELS: Record<string, string> = {
'overview-market': '看板',
watchlist: '自选页',
'limit-ladder': '连板梯队',
}
const SIDEBAR_INDEX_OPTIONS = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
]
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const { data: caps } = useCapabilities()
const { data: quoteStatus } = useQuoteStatus()
const { data: intervalData } = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const toggleQuote = useToggleRealtimeQuotes()
// none/free 档(无实时行情权限)→ rank < starter(1)
const isFreeTier = tierRank(caps?.label ?? '') < 1
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
const systemNotify = prefs?.system_notify_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const interval = intervalData?.interval ?? 10
const minInterval = intervalData?.min_interval ?? 5
const maxInterval = intervalData?.max_interval ?? 60
const save = useCallback(async (cfg: Record<string, unknown>) => {
try {
await api.updateRealtimeMonitorConfig(cfg)
qc.invalidateQueries({ queryKey: QK.preferences })
} catch (e) {
// 忽略 — Toast 已在 request 层处理
}
}, [qc])
const handleToggleQuote = useCallback(async (enabled: boolean) => {
await toggleQuote.mutateAsync(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
}, [toggleQuote, qc])
const toggleSidebarIndex = useCallback((symbol: string, visible: boolean) => {
const selected = new Set(sidebarIndexSymbols)
if (visible) selected.add(symbol)
else selected.delete(symbol)
const next = SIDEBAR_INDEX_OPTIONS
.map(item => item.symbol)
.filter(s => selected.has(s))
save({ sidebar_index_symbols: next })
}, [save, sidebarIndexSymbols])
const toggleIndicesPin = useCallback((pinned: boolean) => {
api.updateIndicesNavPinned(pinned).then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
}, [qc])
const toggleLimitLadderMonitor = useCallback(async (enabled: boolean) => {
await api.updateLimitLadderMonitor(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const toggleSystemNotify = useCallback(async (enabled: boolean) => {
await api.updateSystemNotify(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
toast(data.msg, data.ok ? 'success' : 'error')
// 修正后连板梯队数据变了, 刷新相关缓存
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
},
onError: () => toast('修正请求失败', 'error'),
})
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
const [flash, setFlash] = useState(false)
const flashedRef = useRef(false)
useEffect(() => {
if (highlight === 'depth-fix' && !flashedRef.current) {
flashedRef.current = true
// 延迟一帧确保 DOM 已渲染, 再触发闪烁
requestAnimationFrame(() => {
setFlash(true)
const t = setTimeout(() => setFlash(false), 2000)
return () => clearTimeout(t)
})
}
}, [highlight])
// Free 档位 — 显示升级提示
if (isFreeTier) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-gradient-to-br from-purple-500/20 to-blue-500/20 mb-5">
<Activity className="h-7 w-7 text-purple-400" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2"></h2>
<p className="text-sm text-secondary max-w-md mb-6">
Starter
</p>
<a
href="/settings?tab=account"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-btn
bg-accent text-white text-sm font-medium
hover:bg-accent/90 transition-colors"
>
API Key
</a>
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1fr] gap-6 max-w-5xl">
{/* ========== 左列 ========== */}
<div className="space-y-6">
{/* 行情状态 — 开关 + 间隔 */}
<Card icon={Activity} title="行情轮询">
<ToggleRow
label="实时行情"
desc={isRunning && isTrading ? '运行中' : isRunning ? '运行中 (非交易时段)' : '已关闭'}
checked={realtimeEnabled}
onChange={handleToggleQuote}
/>
<div className="mt-3 pt-3 border-t border-border">
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted"></div>
</div>
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
{interval < 1 ? interval.toFixed(1) : interval.toFixed(0)}s
</span>
</div>
<div className="flex items-center gap-3 mt-2">
<input
type="range"
min={minInterval}
max={maxInterval}
step={minInterval < 1 ? 0.1 : minInterval < 3 ? 0.5 : 1}
value={interval}
onChange={(e) => updateInterval.mutate(parseFloat(e.target.value))}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] text-muted shrink-0">
{minInterval}s {maxInterval}s
</span>
</div>
</div>
</Card>
{/* 页面刷新 */}
<Card icon={Wifi} title="页面实时刷新">
<p className="text-xs text-secondary mb-4">
SSE
</p>
<div className="space-y-2">
{Object.entries(PAGE_LABELS).map(([key, label]) => (
<ToggleRow
key={key}
label={label}
desc={`SSE 推送时刷新 ${label} 数据`}
checked={refreshPages[key] !== false}
onChange={(v) => save({ sse_refresh_pages: { ...refreshPages, [key]: v } })}
/>
))}
</div>
</Card>
<Card icon={BarChart3} title="左侧菜单指数">
<p className="text-xs text-secondary mb-4">
</p>
<div className="space-y-2">
{SIDEBAR_INDEX_OPTIONS.map(item => (
<ToggleRow
key={item.symbol}
label={item.name}
desc={item.symbol}
checked={sidebarIndexSymbols.includes(item.symbol)}
onChange={(v) => toggleSidebarIndex(item.symbol, v)}
/>
))}
</div>
<div className="mt-3 pt-3 border-t border-border">
<ToggleRow
label="固定显示"
desc={indicesPinned ? '指数卡片常驻显示(即使实时行情关闭)' : '跟随实时行情开关(仅实时开时显示)'}
checked={indicesPinned}
onChange={toggleIndicesPin}
/>
</div>
</Card>
</div>
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 策略监控已迁移至监控中心 */}
<Card icon={Shield} title="策略监控">
<p className="text-xs text-secondary mb-3">
,
</p>
<a
href="#/monitor"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 transition-colors"
>
</a>
<div className="mt-3 pt-3 border-t border-border">
<ToggleRow
icon={Bell}
label="系统通知"
desc="监控告警同时推送到操作系统通知中心(窗口最小化或后台也能收到)"
checked={systemNotify}
onChange={toggleSystemNotify}
/>
</div>
</Card>
{/* 连板梯队降级修正 */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
>
<Card
icon={Flame}
title="连板梯队降级修正"
badge={!hasDepth ? '需 Pro+' : undefined}
right={hasDepth ? (
<button
onClick={() => runFix.mutate()}
disabled={runFix.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
bg-accent/15 text-accent hover:bg-accent/25 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
<Zap className="h-3 w-3" />
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
) : undefined}
>
{hasDepth ? (
<>
<p className="text-xs text-secondary mb-4">
/,(=)
,
</p>
<ToggleRow
label="启用真假板修正"
desc="开启后盘中自动拉取五档盘口修正真假板"
checked={limitLadderMonitor}
onChange={toggleLimitLadderMonitor}
/>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[10px] uppercase tracking-widest text-muted mb-3">
</div>
<DepthConfigContent disabled={!limitLadderMonitor} />
</div>
</>
) : (
<DepthConfigContent disabled />
)}
</Card>
</div>
</div>
</div>
)
}
// ===== ToggleRow =====
function ToggleRow({
label,
desc,
checked,
onChange,
icon: Icon,
}: {
label: string
desc: string
checked: boolean
onChange: (v: boolean) => void
icon?: React.ComponentType<{ className?: string }>
}) {
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0 flex items-start gap-2">
{Icon && <Icon className="h-3.5 w-3.5 text-secondary shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className="text-sm text-foreground">{label}</div>
<div className="text-[11px] text-muted truncate">{desc}</div>
</div>
</div>
<button
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
)
}
// ===== 通用卡片 =====
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
badge?: string
right?: React.ReactNode
children: React.ReactNode
}
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
return (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Icon className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{badge && (
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
{badge}
</span>
)}
</div>
{right}
</div>
{children}
</section>
)
}
@@ -0,0 +1,309 @@
/**
* 系统设置面板 — 全局行为开关。
*
* 独立于实时监控, 放置影响整体应用行为的开关项。
*/
import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info, Sun } from 'lucide-react'
import { useTheme, type Theme } from '@/components/ThemeProvider'
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { refreshAlertToastConfig } from '@/components/AlertToast'
import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound'
export function SettingsSystemPanel() {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const { data: versionData } = useVersion()
const [saving, setSaving] = useState(false)
const screenerAutoRun = prefs?.screener_auto_run ?? true
const [clearing, setClearing] = useState(false)
const [toastEnabled, setToastEnabled] = useState(() => {
try { return localStorage.getItem('alert_toast_enabled') !== '0' } catch { return true }
})
const [toastMax, setToastMax] = useState(() => {
try {
const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10)
return v >= 1 && v <= 5 ? v : 3
} catch { return 3 }
})
const [soundEnabled, setSoundEnabled] = useState(() => {
try { return localStorage.getItem('alert_sound_enabled') !== '0' } catch { return true }
})
const [soundType, setSoundType] = useState(() => {
try { return localStorage.getItem('alert_sound') || 'ding' } catch { return 'ding' }
})
const save = useCallback(async (cfg: Record<string, unknown>) => {
setSaving(true)
try {
await api.updateRealtimeMonitorConfig(cfg)
qc.invalidateQueries({ queryKey: QK.preferences })
} finally {
setSaving(false)
}
}, [qc])
// 刷新前端缓存: 清除 react-query 缓存 + 强制重载 (绕过浏览器缓存)
// 不动 localStorage (用户列配置/策略池等偏好保留), 也不影响后端的本地股票数据
const handleClearCache = useCallback(() => {
setClearing(true)
qc.clear()
// 加时间戳参数强制浏览器重新下载所有静态资源
setTimeout(() => {
window.location.href = window.location.pathname + '?_t=' + Date.now()
}, 300)
}, [qc])
return (
<>
<PageHeader
title="系统设置"
subtitle="全局行为开关"
/>
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center gap-2 mb-4">
<Sun className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"></div>
</div>
<ThemeSelect />
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Settings2 className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<ToggleRow
label="进入策略页自动运行策略"
desc="开启后进入策略页自动跑所有策略获取命中数; 关闭则需手动点击"
checked={screenerAutoRun}
disabled={saving}
onChange={(v) => save({ screener_auto_run: v })}
/>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Bell className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<ToggleRow
label="开启监控通知弹窗"
desc="收到监控告警时在右下角弹出通知卡片"
checked={toastEnabled}
disabled={saving}
onChange={(v) => {
localStorage.setItem('alert_toast_enabled', v ? '1' : '0')
setToastEnabled(v)
refreshAlertToastConfig()
}}
/>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"> (1-5), </div>
</div>
<select
value={toastMax}
disabled={!toastEnabled}
onChange={(e) => {
const v = Number(e.target.value)
localStorage.setItem('alert_toast_max', String(v))
setToastMax(v)
refreshAlertToastConfig()
}}
className="w-16 h-8 px-1.5 rounded-btn border border-border bg-base text-xs text-foreground disabled:opacity-50"
>
{[1, 2, 3, 4, 5].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<ToggleRow
label="通知声效"
desc="收到监控告警时播放提示音"
checked={soundEnabled}
disabled={!toastEnabled}
onChange={(v) => {
localStorage.setItem('alert_sound_enabled', v ? '1' : '0')
setSoundEnabled(v)
if (v) previewSound(soundType)
}}
/>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0 flex items-center gap-1.5">
<Volume2 className="h-3.5 w-3.5 text-muted" />
<div>
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"></div>
</div>
</div>
<div className="flex items-center gap-1.5">
<select
value={soundType}
disabled={!toastEnabled || !soundEnabled}
onChange={(e) => {
const v = e.target.value
localStorage.setItem('alert_sound', v)
setSoundType(v)
if (v !== 'none') previewSound(v)
}}
className="w-20 h-8 px-1.5 rounded-btn border border-border bg-base text-xs text-foreground disabled:opacity-50"
>
{SOUND_OPTIONS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
<button
onClick={() => previewSound(soundType)}
disabled={!toastEnabled || !soundEnabled || soundType === 'none'}
className="px-2 h-8 rounded-btn border border-border bg-base text-xs text-secondary hover:text-foreground hover:border-accent/30 disabled:opacity-50 transition-colors cursor-pointer"
>
</button>
</div>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Trash2 className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate">
()
</div>
</div>
<button
onClick={handleClearCache}
disabled={clearing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
bg-elevated text-secondary hover:text-foreground transition-colors
disabled:opacity-50 shrink-0"
>
{clearing ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{clearing ? '清理中…' : '清理并刷新'}
</button>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Info className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"></div>
</div>
<span className="font-mono text-xs text-secondary shrink-0">
{versionData?.version ?? '—'}
</span>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"> GitHub Releases </div>
</div>
<a
href="https://github.com/shy3130/stock-panel/releases/latest"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
bg-elevated text-secondary hover:text-foreground transition-colors shrink-0"
>
<RefreshCw className="h-3.5 w-3.5" />
</a>
</div>
</section>
</>
)
}
// ===== ThemeSelect =====
function ThemeSelect() {
const { theme, setTheme } = useTheme()
const options: { value: Theme; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
]
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as Theme)}
className="h-8 px-2 rounded-btn border border-border bg-base text-xs text-foreground shrink-0"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
// ===== ToggleRow =====
function ToggleRow({
label,
desc,
checked,
disabled,
onChange,
}: {
label: string
desc: string
checked: boolean
disabled?: boolean
onChange: (v: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground">{label}</div>
<div className="text-[11px] text-muted truncate">{desc}</div>
</div>
<button
onClick={() => onChange(!checked)}
disabled={disabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 disabled:opacity-50 ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
)
}