将项目文件整理到 refer 目录
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user