复制 local 代码到 serve
This commit is contained in:
@@ -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,195 @@
|
||||
/**
|
||||
* 访问认证页 — 复用同一组件处理「首次设密码」和「登录」两种状态。
|
||||
*
|
||||
* 根据后端 /api/auth/status 的 configured 字段决定显示:
|
||||
* - configured=false → 显示「设置访问密码」(首次)
|
||||
* - configured=true → 显示「登录」
|
||||
*
|
||||
* 安全:
|
||||
* - 设密码接口后端限本机/内网; 公网用户设密码会被 403 拒绝, 页面据此提示。
|
||||
* - 登录失败由后端限流(5次锁5分钟), 429 时前端显示等待提示。
|
||||
*/
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert, Sparkles } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Auth() {
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
// 取认证状态(是否已设密码)
|
||||
const [status, setStatus] = useState<{ configured: boolean } | null>(null)
|
||||
useEffect(() => {
|
||||
api.authStatus().then(s => {
|
||||
setStatus(s)
|
||||
// 已登录的话直接进面板(避免登录页死循环)
|
||||
if (s.authenticated) navigate('/', { replace: true })
|
||||
}).catch(() => setStatus({ configured: false }))
|
||||
}, [navigate])
|
||||
|
||||
const isSetup = !status?.configured // configured=false → 设密码模式
|
||||
|
||||
// 登录 / 设密码 共用一个 mutation(按 isSetup 调不同接口)
|
||||
const submitMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isSetup) {
|
||||
return api.authSetup(password)
|
||||
}
|
||||
return api.authLogin(password)
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 成功: 跳回原页面(或首页)
|
||||
const redirect = new URLSearchParams(window.location.search).get('redirect') || '/'
|
||||
navigate(redirect, { replace: true })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.message || (isSetup ? '设置失败' : '登录失败')
|
||||
// 设密码/登录失败必须显示: 401(密码错)/403(公网设密码被拒)/429(限流) 都要提示
|
||||
setLocalError(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLocalError('')
|
||||
if (isSetup) {
|
||||
if (password.length < 6) { setLocalError('密码至少 6 位'); return }
|
||||
if (password !== confirmPassword) { setLocalError('两次密码不一致'); return }
|
||||
}
|
||||
submitMut.mutate()
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-base">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-base px-4">
|
||||
{/* 背景辉光(与 Onboarding 风格一致) */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(139,92,246,0.15),transparent_40%),radial-gradient(circle_at_70%_80%,rgba(59,130,246,0.12),transparent_40%)]" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-full max-w-sm"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<Logo className="h-10 w-10" />
|
||||
<h1 className="text-lg font-semibold text-foreground">TickFlow Stock Panel</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
|
||||
{/* 标题区: 图标 + 文案随模式切换 */}
|
||||
<div className="mb-5 flex items-center gap-2.5">
|
||||
<div className={cn(
|
||||
'grid h-9 w-9 place-items-center rounded-lg',
|
||||
isSetup ? 'bg-accent/15 text-accent' : 'bg-purple-500/15 text-purple-400',
|
||||
)}>
|
||||
{isSetup ? <ShieldCheck className="h-5 w-5" /> : <Lock className="h-5 w-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{isSetup ? '设置访问密码' : '登录访问'}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{isSetup ? '首次使用, 请为面板设置访问密码' : '请输入访问密码以继续'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* 密码输入 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="访问密码"
|
||||
autoFocus
|
||||
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwd(s => !s)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPwd ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 确认密码(仅设密码模式) */}
|
||||
{isSetup && (
|
||||
<input
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{(localError || submitMut.error) && (
|
||||
<div className="flex items-start gap-1.5 rounded-btn bg-danger/10 px-3 py-2 text-[11px] text-danger">
|
||||
<ShieldAlert className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
<span>{localError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitMut.isPending || !password}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-btn bg-accent text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{submitMut.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />处理中…</>
|
||||
) : (
|
||||
<>{isSetup ? '设置并进入' : '登录'}</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 提示: 设密码模式告知本机限制 */}
|
||||
{isSetup && (
|
||||
<div className="mt-3 space-y-1.5 text-[10px] leading-relaxed text-muted/70">
|
||||
<p>
|
||||
出于安全考虑, 首次设置密码需在服务器本机或内网访问时操作。公网环境下仅可登录。
|
||||
</p>
|
||||
<p>
|
||||
详细配置说明见{' '}
|
||||
<a
|
||||
href="https://github.com/shy3130/tickflow-stock-panel/blob/main/docs/deploy-password.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent underline-offset-2 hover:underline"
|
||||
>
|
||||
访问密码部署文档
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-muted/60">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
自托管量化工作台 · 数据完全掌握在自己手里
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 同一个名字 "TickFlow Stock Panel" 在 4 种风格语言里的呈现
|
||||
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
|
||||
const VARIANTS: Variant[] = [
|
||||
{
|
||||
id: 'pulsar',
|
||||
name: 'TickFlow 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: 'TickFlow 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: 'TickFlow 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: 'TickFlow 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="名字保持 TickFlow 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,810 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Activity,
|
||||
Crown,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
Settings2,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
|
||||
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 [showRps, setShowRps] = useState(false)
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
// 内置概念预设 (ext_gn_ths) 手动获取数据
|
||||
const PRESET_CONCEPT_ID = 'ext_gn_ths'
|
||||
const queryClient = useQueryClient()
|
||||
const fetchMutation = useMutation({
|
||||
mutationFn: () => api.extDataPresetFetch(PRESET_CONCEPT_ID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QK.extData })
|
||||
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_CONCEPT_ID, undefined, PAGE_LIMIT) })
|
||||
},
|
||||
})
|
||||
// 是否处于「内置概念预设存在但无数据」状态 → 显示获取按钮
|
||||
const needsConceptFetch =
|
||||
!!activeConfig && activeConfig.id === PRESET_CONCEPT_ID &&
|
||||
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
|
||||
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<PresetFetchState
|
||||
title="暂无概念数据"
|
||||
hint="从同花顺获取概念分类数据后即可使用概念分析"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="概念分析"
|
||||
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
|
||||
<button
|
||||
onClick={() => setShowRps(true)}
|
||||
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
|
||||
title="概念涨幅轮动矩阵"
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" />涨幅RPS轮动分析
|
||||
</button>
|
||||
<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>
|
||||
) : needsConceptFetch ? (
|
||||
<PresetFetchState
|
||||
title="未获取概念数据"
|
||||
hint="内置概念数据源已就绪,点击下方按钮从同花顺获取概念分类数据"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<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('') }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} />}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,896 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } 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 { SettingsModal } from '@/components/data/SettingsModal'
|
||||
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
|
||||
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 size = 240
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const maxR = 78
|
||||
const color = scoreColor(score)
|
||||
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="rgba(15,23,42,0.92)" />
|
||||
<stop offset="68%" stopColor="rgba(15,23,42,0.70)" />
|
||||
<stop offset="100%" stopColor="rgba(15,23,42,0)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{gridPolygons.map(g => (
|
||||
<polygon
|
||||
key={g.level}
|
||||
points={g.points}
|
||||
fill={g.idx % 2 === 0 ? 'rgba(30,41,59,0.26)' : 'rgba(15,23,42,0.16)'}
|
||||
stroke={g.level === 1 ? 'rgba(148,163,184,0.22)' : 'rgba(148,163,184,0.12)'}
|
||||
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="rgba(148,163,184,0.08)" />)}
|
||||
<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="rgba(15,23,42,0.9)" 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 qc = useQueryClient()
|
||||
const [selectedDate, setSelectedDate] = useState<string | undefined>()
|
||||
const [manualFetching, setManualFetching] = useState(false)
|
||||
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
|
||||
const [showWelcomeModal, setShowWelcomeModal] = 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
|
||||
|
||||
// ===== 盘后管道触发(看板内一键获取数据) =====
|
||||
const [fetchJobId, setFetchJobId] = useState<string | null>(null)
|
||||
const fetchStatus = useQuery({
|
||||
queryKey: QK.pipelineJob(fetchJobId ?? ''),
|
||||
queryFn: () => api.pipelineJob(fetchJobId!),
|
||||
enabled: !!fetchJobId,
|
||||
refetchInterval: (q: any) => {
|
||||
const j = q.state.data
|
||||
return j && (j.status === 'succeeded' || j.status === 'failed') ? false : 1_000
|
||||
},
|
||||
})
|
||||
const startFetch = useMutation({
|
||||
mutationFn: api.pipelineRun,
|
||||
onSuccess: ({ job_id }) => setFetchJobId(job_id),
|
||||
})
|
||||
const isFetching = startFetch.isPending
|
||||
|| fetchStatus.data?.status === 'running'
|
||||
|| fetchStatus.data?.status === 'pending'
|
||||
const fetchFailed = fetchStatus.data?.status === 'failed'
|
||||
const fetchSucceeded = fetchStatus.data?.status === 'succeeded'
|
||||
|
||||
// 首次使用且无数据 → 自动弹一次引导弹窗(同会话只弹一次)
|
||||
useEffect(() => {
|
||||
if (!hasNoData) return
|
||||
if (settings.data?.onboarding_completed === false) return // 还在引导流程中,不重复弹
|
||||
if (sessionStorage.getItem('tf_welcome_shown')) return
|
||||
sessionStorage.setItem('tf_welcome_shown', '1')
|
||||
setShowWelcomeModal(true)
|
||||
}, [hasNoData, settings.data?.onboarding_completed])
|
||||
|
||||
// 同步完成后刷新看板数据
|
||||
useEffect(() => {
|
||||
if (fetchSucceeded) {
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
qc.invalidateQueries({ queryKey: QK.overviewMarket(undefined) })
|
||||
}
|
||||
}, [fetchSucceeded, qc])
|
||||
|
||||
// 组件重新挂载时(从其他页面切回)恢复正在运行的同步任务进度。
|
||||
// 原因: fetchJobId 是组件内状态, 切走页面时组件卸载、状态丢失, 切回后进度卡片消失。
|
||||
// 修复: 挂载时若无本地数据且未跟踪任何 job, 查一次后端是否有 active job, 有则接管。
|
||||
const resumeTriedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (resumeTriedRef.current) return
|
||||
if (!hasNoData) return
|
||||
if (fetchJobId) return
|
||||
resumeTriedRef.current = true
|
||||
api.pipelineJobs(1).then(({ active_id }) => {
|
||||
if (active_id) setFetchJobId(active_id)
|
||||
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
|
||||
}, [hasNoData, fetchJobId])
|
||||
|
||||
// 手动刷新: 显示旋转动画; 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
|
||||
// 实时模式: none / watchlist / full_market。
|
||||
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
|
||||
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-base p-3">
|
||||
{/* 无本地数据常驻引导卡片 —— 一键触发盘后管道获取数据(无 Key 也可) */}
|
||||
{hasNoData && (
|
||||
<FetchDataCard
|
||||
isFetching={isFetching}
|
||||
isStarting={startFetch.isPending}
|
||||
fetchFailed={fetchFailed}
|
||||
stage={fetchStatus.data?.stage}
|
||||
fetchPct={fetchStatus.data?.progress}
|
||||
onStart={() => startFetch.mutate()}
|
||||
isNoKey={isNoKey}
|
||||
/>
|
||||
)}
|
||||
{/* 首次使用自动弹窗(同会话仅一次) */}
|
||||
<AnimatePresence>
|
||||
{showWelcomeModal && (
|
||||
<WelcomeFetchModal
|
||||
isNoKey={isNoKey}
|
||||
onClose={() => setShowWelcomeModal(false)}
|
||||
onStart={() => {
|
||||
startFetch.mutate()
|
||||
setShowWelcomeModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<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>
|
||||
|
||||
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
|
||||
{quoteMode === 'watchlist' && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-[11px] leading-relaxed">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0 flex-1 text-secondary">
|
||||
当前为「自选实时」模式,看板展示的大盘数据为<strong className="text-foreground">盘后快照</strong>(最新有数据日),并非盘中实时;
|
||||
仅自选股({data.quote_status?.watchlist_symbol_count ?? 0} 只)支持实时监控。
|
||||
<span className="ml-1 text-accent">全市场实时需 Starter+</span>
|
||||
</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} · 放量占比 ${fmtPrice(data.activity.high_vol_ratio, 1)}%`} 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={`${fmtPrice(data.activity.high_vol_ratio, 1)}%`} 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 dark:text-yellow-500">{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>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 Key 也可) =====
|
||||
function FetchDataCard({
|
||||
isFetching, isStarting, fetchFailed, stage, fetchPct, onStart, isNoKey,
|
||||
}: {
|
||||
isFetching: boolean
|
||||
isStarting: boolean
|
||||
fetchFailed: boolean
|
||||
stage?: string
|
||||
fetchPct?: number
|
||||
onStart: () => void
|
||||
isNoKey: boolean
|
||||
}) {
|
||||
const stageText = stage ? (STAGE_LABELS[stage] ?? stage) : '正在同步行情数据…'
|
||||
return (
|
||||
<div className="mb-3 rounded-card border border-border bg-surface/85 p-3.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-accent/10 p-2 shrink-0">
|
||||
<Database className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">当前暂无数据</div>
|
||||
<p className="mt-1 text-xs text-secondary leading-relaxed">
|
||||
首次使用需获取行情数据后才能查看看板。系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟,期间可继续浏览其他页面。
|
||||
</p>
|
||||
{isNoKey && (
|
||||
<p className="mt-1 text-[11px] text-warning/80 leading-relaxed">
|
||||
ⓘ 无需 API Key,当前为 None 档即可获取历史日K,可制定策略+回测。配置免费 Key 可解锁实时行情监控能力。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isFetching ? (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-[11px] text-muted mb-1.5">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{isStarting ? '正在启动同步任务…' : stageText}
|
||||
</span>
|
||||
<span className="font-mono tabular">
|
||||
{typeof fetchPct === 'number' ? `${Math.round(fetchPct)}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-accent"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(2, Math.min(100, fetchPct ?? 0))}%` }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : fetchFailed ? (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-xs text-danger">同步失败,请重试</span>
|
||||
<button
|
||||
onClick={onStart}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />重新获取
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={onStart}
|
||||
className="inline-flex items-center gap-1.5 px-4 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />立即获取数据
|
||||
</button>
|
||||
<Link
|
||||
to="/data"
|
||||
className="inline-flex items-center gap-0.5 text-xs text-secondary hover:text-accent transition-colors"
|
||||
>
|
||||
前往数据页
|
||||
<ArrowUpRight className="h-3 w-3 self-center" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 首次使用自动弹窗: 询问用户后触发盘后管道 =====
|
||||
function WelcomeFetchModal({
|
||||
isNoKey, onClose, onStart,
|
||||
}: {
|
||||
isNoKey: boolean
|
||||
onClose: () => void
|
||||
onStart: () => void
|
||||
}) {
|
||||
return (
|
||||
<SettingsModal title="欢迎首次使用 · 获取行情数据" onClose={onClose}>
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mx-auto w-fit rounded-2xl bg-accent/10 p-3.5"
|
||||
>
|
||||
<Sparkles className="h-7 w-7 text-accent" />
|
||||
</motion.div>
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">首次使用,需先获取行情数据</h3>
|
||||
<p className="mt-2 text-xs text-secondary leading-relaxed">
|
||||
系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟。
|
||||
同步期间可继续浏览其他页面,完成后看板自动刷新。
|
||||
</p>
|
||||
{isNoKey && (
|
||||
<div className="mt-3 rounded-btn bg-elevated/60 px-3 py-2 text-[11px] text-muted leading-relaxed">
|
||||
ⓘ 当前无需 API Key,None 档即可获取历史日K数据。
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-center gap-2.5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
稍后再说
|
||||
</button>
|
||||
<button
|
||||
onClick={onStart}
|
||||
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 transition-all"
|
||||
>
|
||||
<Play className="h-4 w-4" />开始获取
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsModal>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,494 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, Search, AlertTriangle, CheckCircle2, XCircle, FlaskConical, Activity, Bell } 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数据是否齐全。本地无数据时会自动走 TickFlow 实时拉取。
|
||||
</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,
|
||||
则是 TickFlow 数据源未提供该日分钟数据。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 封单监控模拟触发 ──────────────────────────────────
|
||||
function LadderTestPanel() {
|
||||
const [result, setResult] = useState<Awaited<ReturnType<typeof api.monitorRuleTestLadder>> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [pushMsg, setPushMsg] = useState('')
|
||||
|
||||
const testMut = useMutation({
|
||||
mutationFn: () => api.monitorRuleTestLadder(),
|
||||
onSuccess: (data) => { setResult(data); setError('') },
|
||||
onError: (e: any) => { setError(e?.message ?? String(e)); setResult(null) },
|
||||
})
|
||||
|
||||
const triggerMut = useMutation({
|
||||
mutationFn: () => api.monitorRuleTriggerLadder(),
|
||||
onSuccess: (data) => {
|
||||
setPushMsg(`✅ 已真实触发 ${data.triggered} 条预警 (落盘 + 飞书 + SSE)`)
|
||||
setTimeout(() => setPushMsg(''), 6000)
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setPushMsg(`❌ 触发失败: ${e?.message ?? String(e)}`)
|
||||
setTimeout(() => setPushMsg(''), 6000)
|
||||
},
|
||||
})
|
||||
|
||||
const fmtVal = (v: number | null | undefined, metric: string) => {
|
||||
if (v == null) return '—'
|
||||
if (metric === 'sealed_amount') return `${(v / 1e8).toFixed(4)} 亿`
|
||||
return `${v.toLocaleString()} 手`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">封单监控模拟触发</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
用当前 depth 封单数据 + 最新日 enriched, 评估所有 ladder 规则。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-btn bg-elevated p-4">
|
||||
<button
|
||||
onClick={() => testMut.mutate()}
|
||||
disabled={testMut.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"
|
||||
>
|
||||
{testMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
|
||||
模拟触发
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('将真实推送飞书 + 写入监控中心 + 触发 SSE 通知。确认?')) {
|
||||
triggerMut.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={triggerMut.isPending}
|
||||
className="flex items-center gap-1.5 rounded-btn border border-amber-400/40 bg-amber-400/10 px-4 py-1.5 text-sm font-medium text-amber-400 hover:bg-amber-400/20 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{triggerMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
|
||||
真实触发预警
|
||||
</button>
|
||||
{result && (
|
||||
<span className="text-xs text-muted">
|
||||
日期 {result.as_of} · 封单股 {result.sealed_count} · 触发 {result.triggered.length} · 未触发 {result.not_triggered.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-btn border border-amber-400/30 bg-amber-400/5 p-3 text-xs text-muted">
|
||||
<span className="font-medium text-amber-400">模拟触发</span>:纯条件判断,不落盘不推送。
|
||||
<span className="font-medium text-amber-400 ml-2">真实触发</span>:走完整链路(落盘 alerts.jsonl + 推送飞书 + SSE 通知),会在监控中心和飞书看到预警。
|
||||
</div>
|
||||
|
||||
{pushMsg && (
|
||||
<div className="rounded-btn border border-accent/40 bg-accent/10 p-3 text-sm text-accent">{pushMsg}</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>
|
||||
)}
|
||||
|
||||
{result && result.triggered.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-emerald-400">✅ 会触发 ({result.triggered.length})</h3>
|
||||
{result.triggered.map((ev) => (
|
||||
<div key={ev.rule_id} className="rounded-btn border border-emerald-400/30 bg-emerald-400/5 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-foreground">{ev.symbol}</span>
|
||||
{ev.name && <span className="text-secondary">{ev.name}</span>}
|
||||
<span className="ml-auto rounded bg-emerald-400/15 px-1.5 py-0.5 text-xs text-emerald-400">{ev.type}</span>
|
||||
</div>
|
||||
<div className="text-xs text-secondary">{ev.message}</div>
|
||||
<div className="mt-1 text-xs text-muted tabular-nums">
|
||||
当前封单: {fmtVal(ev.sealed_metric === 'sealed_amount' ? ev.current_sealed_amount : ev.current_sealed_vol, ev.sealed_metric)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.not_triggered.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted">⚪ 未触发 ({result.not_triggered.length})</h3>
|
||||
{result.not_triggered.map((r) => (
|
||||
<div key={r.rule_id} className="rounded-btn border border-border bg-surface/40 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-foreground">{r.symbol}</span>
|
||||
<span className="text-secondary text-xs">{r.rule_name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted tabular-nums">
|
||||
阈值 {fmtVal(r.threshold, r.metric)} · 当前 {fmtVal(r.current_value, r.metric)} · {r.reason}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.triggered.length === 0 && result.not_triggered.length === 0 && (
|
||||
<div className="rounded-btn border border-border bg-surface/40 p-4 text-center text-sm text-muted">
|
||||
无 ladder 监控规则,请先在连板梯队页设置封单监控
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Dev 主页面 ────────────────────────────────────────
|
||||
export function Dev() {
|
||||
const [tab, setTab] = useState<'minute' | 'seed' | 'ladder'>('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('ladder')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
|
||||
tab === 'ladder' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
|
||||
)}
|
||||
>
|
||||
<Bell 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 /> : tab === 'ladder' ? <LadderTestPanel /> : <SeedPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink } 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 { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
|
||||
import { LastStockChip } from '@/components/LastStockChip'
|
||||
import { useLastStock } from '@/lib/useLastStock'
|
||||
import { fmtBigNum } from '@/lib/format'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
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()
|
||||
// 同步进行中 = 服务端真值(status.syncing)或本地乐观态(请求已发出待确认)。
|
||||
// 乐观窗口:点击后到 invalidate 触发的 refetch 返回之间,status.syncing 暂为 false,
|
||||
// 用 syncMut.isPending 覆盖,让按钮立即置灰、避免重复点击。
|
||||
// 后端 trigger() 返回时 syncing 已为 true,refetch 到达后 status.syncing 接管。
|
||||
const syncing = (status?.syncing ?? false) || syncMut.isPending
|
||||
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
|
||||
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
|
||||
const [syncStartedAt, setSyncStartedAt] = useState<number | null>(null)
|
||||
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
|
||||
const [syncSingleTable, setSyncSingleTable] = useState<string | null>(null)
|
||||
// 同步自然结束(服务端 syncing 由 true→false):清空本次同步记录。
|
||||
// 这是可靠的收尾时机 —— 不依赖 mutation 的 onSettled(它现在瞬间触发,会误清)。
|
||||
useEffect(() => {
|
||||
if (!syncing && syncStartedAt !== null) {
|
||||
setSyncStartedAt(null)
|
||||
setSyncSingleTable(null)
|
||||
}
|
||||
}, [syncing, syncStartedAt])
|
||||
// 选中的个股(模糊搜索结果);null 时显示搜索引导
|
||||
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
|
||||
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
|
||||
const pick = (symbol: string, name: string) => {
|
||||
setSelected({ symbol, name })
|
||||
rememberStock(symbol, name)
|
||||
}
|
||||
|
||||
if (!hasFinancial) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · 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>
|
||||
{/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */}
|
||||
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
||||
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
|
||||
关于数据源
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-secondary">
|
||||
当前财务数据源需付费,后续会接入免费数据源。如你常用某个免费财务数据源,欢迎在 Issues 中多多推荐哈 ~
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/shy3130/tickflow-stock-panel/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-accent hover:underline"
|
||||
>
|
||||
前往 Issues 推荐
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSync = (table: string) => {
|
||||
// 防重复点击:syncing 中不再触发(后端 trigger 也有 _is_syncing 兜底)
|
||||
if (syncing) return
|
||||
// 记录开始时间: 全量同步判断所有 4 张表, 单表同步只判断这一张
|
||||
setSyncStartedAt(Date.now())
|
||||
setSyncSingleTable(table === 'all' ? null : table)
|
||||
syncMut.mutate(table, {
|
||||
onSuccess: (r) => {
|
||||
// 后端 trigger 立即返回 started 状态;若被防并发跳过(已有同步在进行),
|
||||
// 给用户明确反馈,并清空本次误设的记录。
|
||||
if (!r.synced?.started) {
|
||||
if (r.synced?.reason === 'already running') {
|
||||
toast('财务数据正在同步中,请稍候', 'success')
|
||||
} else if (r.synced?.reason === 'no FINANCIAL capability') {
|
||||
// 能力未就绪:通常发生在升级/刷新 Key 后调度器状态未同步 —— 提示用户检查 Key
|
||||
toast('财务数据能力未就绪,请检查 API Key 或刷新页面后重试', 'error')
|
||||
} else {
|
||||
toast(`同步未能开始${r.synced?.reason ? `:${r.synced.reason}` : ''}`, 'error')
|
||||
}
|
||||
setSyncStartedAt(null)
|
||||
setSyncSingleTable(null)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// 请求失败:清空本次记录(request 已弹错误 toast)
|
||||
setSyncStartedAt(null)
|
||||
setSyncSingleTable(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const tables = status?.tables ?? {}
|
||||
const available = status?.available ?? false
|
||||
const lastSync = status?.last_sync ?? {}
|
||||
// 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断
|
||||
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="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<LastStockChip stock={lastStock} onSelect={pick} />
|
||||
{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" />
|
||||
正在从 TickFlow 拉取财务数据,请稍候…
|
||||
</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">点击右上角"全部同步"从 TickFlow 拉取</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 个股搜索区 */}
|
||||
<div>
|
||||
{selected ? (
|
||||
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 max-w-xl">
|
||||
<StockFinancialSearch onSelect={pick} />
|
||||
</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={pick} />
|
||||
</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>
|
||||
|
||||
{/* AI 历史分析报告 */}
|
||||
{available && <ReportHistoryPanel />}
|
||||
</>
|
||||
)}
|
||||
</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,867 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Activity,
|
||||
Crown,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, 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 ?? []
|
||||
// 用户配置的 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,
|
||||
})
|
||||
|
||||
// 内置行业预设 (ext_hy_ths) 手动获取数据
|
||||
const PRESET_INDUSTRY_ID = 'ext_hy_ths'
|
||||
const queryClient = useQueryClient()
|
||||
const fetchMutation = useMutation({
|
||||
mutationFn: () => api.extDataPresetFetch(PRESET_INDUSTRY_ID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QK.extData })
|
||||
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_INDUSTRY_ID, undefined, PAGE_LIMIT) })
|
||||
},
|
||||
})
|
||||
// 是否处于「内置行业预设存在但无数据」状态 → 显示获取按钮
|
||||
const needsIndustryFetch =
|
||||
!!activeConfig && activeConfig.id === PRESET_INDUSTRY_ID &&
|
||||
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
|
||||
|
||||
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="行业分析"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<PresetFetchState
|
||||
title="暂无行业数据"
|
||||
hint="从同花顺获取行业分类数据后即可使用行业分析"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
) : needsIndustryFetch ? (
|
||||
<PresetFetchState
|
||||
title="未获取行业数据"
|
||||
hint="内置行业数据源已就绪,点击下方按钮从同花顺获取行业分类数据"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<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,722 @@
|
||||
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, type MonitorCondition } 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>
|
||||
{/* 详情行: 命中条件 (signal/price/market) + 当前价 / 或默认消息 */}
|
||||
{(ev.conditions && ev.conditions.length > 0) ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px]">
|
||||
<span className="text-muted">命中</span>
|
||||
{ev.conditions.map((c: MonitorCondition, ci: number) => (
|
||||
<span key={ci} className="inline-flex items-center gap-0.5">
|
||||
{ci > 0 && <span className="text-secondary">{ev.logic === 'or' ? '或' : '且'}</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>
|
||||
))}
|
||||
{ev.price != null && (
|
||||
<>
|
||||
<span className="text-muted">·</span>
|
||||
<span className="text-muted">现价</span>
|
||||
<span className="font-mono text-foreground/90">{fmtPrice(ev.price)}</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,623 @@
|
||||
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,
|
||||
TrendingUp,
|
||||
FileText,
|
||||
Landmark,
|
||||
Database,
|
||||
} 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: '市场全景看板、涨跌分布、情绪雷达,自定义自选列表', tint: 'text-accent' },
|
||||
{ icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中标的', tint: 'text-bull' },
|
||||
{ icon: TrendingUp, title: '个股分析', desc: 'AI 四维分析个股,关键价位、技术形态一目了然', tint: 'text-warning' },
|
||||
{ icon: Flame, title: '连板梯队', desc: '涨停梯队、封板强度、炸板监控,情绪温度计', tint: 'text-warning' },
|
||||
{ icon: Landmark, title: '概念行业', desc: '概念板块、行业维度的资金流向与热度排名', tint: 'text-accent' },
|
||||
{ icon: FileText, title: '财务分析', desc: 'AI 解读财报,利润、资负、现金流、核心指标', tint: 'text-bear' },
|
||||
{ icon: ShieldCheck, title: '回测验证', desc: '策略历史回测、因子分析,用数据验证逻辑', tint: 'text-accent' },
|
||||
{ icon: Radar, title: '实时监控', desc: '自定义条件 / 策略监控,盘中触发即推送告警', tint: 'text-bear' },
|
||||
{ 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">TickFlow 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">
|
||||
欢迎使用 TickFlow Stock Panel
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
|
||||
一个本地化的 A 股量化分析面板 —— 行情、选股、回测、监控、财务一体化。
|
||||
花一分钟配置,即可开始使用。
|
||||
</p>
|
||||
|
||||
{/* 特性卡片 —— 3×3 网格,横向布局压缩高度 */}
|
||||
<div className="mt-8 grid grid-cols-2 sm:grid-cols-3 gap-2.5 text-left">
|
||||
{HIGHLIGHTS.map((h, i) => (
|
||||
<motion.div
|
||||
key={h.title}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.04 * i + 0.1 }}
|
||||
whileHover={{ y: -2 }}
|
||||
className="group flex items-start gap-2.5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-2.5 transition-colors hover:border-accent/30"
|
||||
>
|
||||
<div className="rounded-lg bg-elevated/50 p-1.5 shrink-0">
|
||||
<h.icon className={`h-4 w-4 ${h.tint} transition-transform group-hover:scale-110`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-foreground">{h.title}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted leading-snug line-clamp-2">{h.desc}</div>
|
||||
</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: 输入 TickFlow 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">配置 TickFlow API Key</h2>
|
||||
</div>
|
||||
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
|
||||
本项目基于 TickFlow 这款稳定的数据源为基座进行开发,正在适配其他第三方数据源。
|
||||
如果有任何建议或意见,欢迎发送邮件至{' '}
|
||||
<a
|
||||
href="mailto:415333856@qq.com"
|
||||
className="text-accent hover:underline font-medium"
|
||||
>
|
||||
415333856@qq.com
|
||||
</a>
|
||||
。
|
||||
</p>
|
||||
|
||||
{/* 档位对比说明 —— None 档 vs Free 档 */}
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{/* None 档 —— 不配置时默认 */}
|
||||
<div className="rounded-card border border-accent/20 bg-accent/[0.04] p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-bold font-mono bg-accent/15 text-accent/70">None</span>
|
||||
<span className="text-xs font-medium text-foreground">不配置(默认)</span>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1 text-[11px] text-muted leading-relaxed">
|
||||
<li>· 仅历史日K数据,无实时行情</li>
|
||||
<li>· 数据有延迟,盘后约 1-2 小时更新当天</li>
|
||||
<li>· 可用于策略回测、盘后分析</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* Free 档 —— 免费注册即可获取 */}
|
||||
<div className="rounded-card border border-accent/35 bg-accent/[0.08] p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-bold font-mono bg-accent/15 text-accent">Free</span>
|
||||
<span className="text-xs font-medium text-foreground">注册免费获取</span>
|
||||
<span className="inline-flex items-center rounded-full bg-accent px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm shadow-accent/30">推荐</span>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1 text-[11px] text-secondary leading-relaxed">
|
||||
<li>· 无需付费,注册即享</li>
|
||||
<li>· 历史日K + 限定范围内的实时数据</li>
|
||||
<li>· 可指定个股进行实时监控</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 获取 Key 的说明 —— 黄框卡片 */}
|
||||
<div className="mt-4 flex items-start gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2.5 text-xs text-foreground leading-relaxed">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-warning mt-px" />
|
||||
<span>
|
||||
Key 可在{' '}
|
||||
<a
|
||||
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-warning hover:underline inline-flex items-baseline gap-0.5 font-medium"
|
||||
>
|
||||
tickflow.org
|
||||
<ExternalLink className="h-3 w-3 self-center" />
|
||||
</a>
|
||||
获取。
|
||||
<span className="block mt-1.5 text-foreground/70">
|
||||
当前数据源基于 TickFlow 基座,其他第三方数据源正在开发适配中。
|
||||
</span>
|
||||
</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 替换当前' : '粘贴 TickFlow 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">将以 None 档继续</div>
|
||||
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
|
||||
当前未配置有效 Key,仍可使用看板、选股、回测等功能 —— 进入看板后可直接获取近 1 年历史日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 settings = useSettings()
|
||||
// 是否已配置 Key(free 或 api_key 都算,None 档算未配置)
|
||||
const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
|
||||
|
||||
// 首要行动:获取数据(不管配没配 Key, 新用户都需要先拉数据)
|
||||
// 快速上手入口(精简为核心功能)
|
||||
const tips = [
|
||||
{ icon: TrendingUp, text: '「个股分析」:输入代码,AI 四维分析 + 关键价位' },
|
||||
{ icon: ScanSearch, 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">
|
||||
{hasKey
|
||||
? 'Key 已生效,进入面板后系统会自动引导你获取行情数据,完成后即可使用全部功能。'
|
||||
: '当前为 None 档,进入面板后系统会自动引导你获取历史日K数据(无需 Key),即可开始体验。'}
|
||||
</p>
|
||||
|
||||
{/* 首要行动:获取数据 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
className="mt-5 flex items-start gap-2.5 rounded-card border border-accent/30 bg-accent/[0.06] px-4 py-3 text-left"
|
||||
>
|
||||
<div className="rounded-lg bg-accent/15 p-1.5 shrink-0 mt-px">
|
||||
<Database className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">下一步:获取行情数据</div>
|
||||
<p className="mt-1 text-xs text-secondary leading-relaxed">
|
||||
进入面板后,看板会自动引导你拉取近 1 年全 A 股日K(约 5500 只,预计 1-3 分钟)。同步期间可浏览其他页面。
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 快速上手入口 */}
|
||||
<div className="mt-4 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.3 }}
|
||||
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,827 @@
|
||||
/**
|
||||
* AI 大盘复盘页 —— 以流式 LLM 复盘报告为主体的盘后复盘工作台。
|
||||
*
|
||||
* 设计定位:极简专注型。不复刻 Dashboard 的看板(KPI/雷达/板块排名),
|
||||
* 仅保留一行「市场摘要条」作为报告上下文参照;AI 报告 + 历史归档是页面主体。
|
||||
* - 摘要数据:GET /api/overview/market
|
||||
* - 报告流式:POST /api/market-recap/analyze
|
||||
*/
|
||||
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 {
|
||||
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
|
||||
Database, Wand2, Copy, Download, Clock, X, Check,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fmtBigNum } from '@/lib/format'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { useReviewState } from '@/lib/useReviewStore'
|
||||
import {
|
||||
startReviewGeneration, resetReview, isReviewGenerating,
|
||||
type ReviewPhase,
|
||||
} from '@/lib/reviewStore'
|
||||
|
||||
// ================================================================
|
||||
// 涨跌幅格式化(注意单位差异)
|
||||
// overview 的 indices.change_pct / breadth.up_pct / seal_rate / *_pct / emotion.score
|
||||
// 都是【已是百分比值】(如 1.2 表示 1.2%),直接 toFixed 即可,不要 *100。
|
||||
// ================================================================
|
||||
function fmtPctAlready(v: number | null | undefined, digits = 2, withSign = false): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
const sign = withSign && v > 0 ? '+' : ''
|
||||
return `${sign}${v.toFixed(digits)}%`
|
||||
}
|
||||
function pctClass(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v) || v === 0) return 'text-muted'
|
||||
return v > 0 ? 'text-bull' : 'text-bear'
|
||||
}
|
||||
// A 股惯例: 强势=红, 弱式=绿(对齐 Dashboard scoreColor)
|
||||
function scoreColor(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v)) return '#71717A'
|
||||
if (v >= 70) return '#F04438'
|
||||
if (v >= 55) return '#FB923C'
|
||||
if (v >= 45) return '#F59E0B'
|
||||
if (v >= 30) return '#84CC16'
|
||||
return '#12B76A'
|
||||
}
|
||||
|
||||
// 归档时刻格式化:ISO → "MM-DD HH:mm"(用于历史列表显示复盘时间)
|
||||
function fmtArchivedAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${mi}`
|
||||
}
|
||||
|
||||
// Phase 类型复用 store 的定义(单一来源)
|
||||
|
||||
export function Review() {
|
||||
const qc = useQueryClient()
|
||||
// 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState)
|
||||
const asOf: string | undefined = undefined
|
||||
const [focus, setFocus] = useState('')
|
||||
// 生成状态走全局 store:切走页面流不中断,回来可恢复
|
||||
const { phase, content, error, meta } = useReviewState()
|
||||
const [viewing, setViewing] = useState<AiReviewReport | null>(null) // 查看历史报告
|
||||
const reportEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 看板数据(与总览页同源)
|
||||
const marketQuery = useQuery<OverviewMarket>({
|
||||
queryKey: QK.overviewMarket(asOf),
|
||||
queryFn: () => api.overviewMarket(asOf),
|
||||
staleTime: 5_000,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
// 历史报告
|
||||
const historyQuery = useQuery<{ reports: AiReviewReport[] }>({
|
||||
queryKey: QK.reviewReports,
|
||||
queryFn: () => api.reviewReportsList(),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => api.reviewReportDelete(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
||||
toast('已删除', 'success')
|
||||
},
|
||||
onError: () => { /* request() 已 toast */ },
|
||||
})
|
||||
|
||||
// ===== 定时复盘 =====
|
||||
const [showSchedule, setShowSchedule] = useState(false)
|
||||
const prefs = usePreferences()
|
||||
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
|
||||
const feishuConfigured = !!(prefs.data?.feishu_webhook_url)
|
||||
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
|
||||
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
|
||||
const reviewPushChannels = prefs.data?.review_push_channels ?? []
|
||||
// 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗)
|
||||
const [draft, setDraft] = useState(reviewSched)
|
||||
const openSchedule = useCallback(() => {
|
||||
setDraft(reviewSched) // 每次打开同步最新服务端值
|
||||
setShowSchedule(true)
|
||||
}, [reviewSched])
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: ({ enabled, hour, minute }: { enabled: boolean; hour: number; minute: number }) =>
|
||||
api.updateReviewSchedule(enabled, hour, minute),
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
setShowSchedule(false)
|
||||
toast(vars.enabled ? '已开启定时复盘' : '已关闭定时复盘', 'success')
|
||||
},
|
||||
onError: () => { /* request() 已 toast */ },
|
||||
})
|
||||
// 推送渠道(多选): 独立常驻, 即时生效(勾选渠道即开关, 改了立刻提交)
|
||||
const pushMut = useMutation({
|
||||
mutationFn: (channels: string[]) => api.updateReviewPush(channels),
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
toast(vars.length === 0 ? '已关闭复盘推送' : '已更新复盘推送渠道', 'success')
|
||||
},
|
||||
onError: () => { /* request() 已 toast */ },
|
||||
})
|
||||
const togglePushChannel = useCallback((ch: string) => {
|
||||
const next = reviewPushChannels.includes(ch)
|
||||
? reviewPushChannels.filter(c => c !== ch)
|
||||
: [...reviewPushChannels, ch]
|
||||
pushMut.mutate(next)
|
||||
}, [reviewPushChannels, pushMut])
|
||||
|
||||
// 自动滚动到报告底部(streaming 时)
|
||||
useEffect(() => {
|
||||
if (phase === 'streaming') {
|
||||
reportEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}
|
||||
}, [content, phase])
|
||||
|
||||
// 当进入生成中(streaming)时, 清掉「查看历史」状态, 让主区域显示流内容。
|
||||
// 手动 generate 已自带 setViewing(null), 这里主要补定时 SSE 流的场景:
|
||||
// 用户若正看着历史报告, 定时触发生成时也要切回主区域显示流式内容。
|
||||
useEffect(() => {
|
||||
if (phase === 'streaming' && viewing) {
|
||||
setViewing(null)
|
||||
}
|
||||
}, [phase, viewing])
|
||||
|
||||
// 自动归档(生成完成后台静默保存)—— 通过回调注入 store,避免 store 直接依赖 qc/marketQuery
|
||||
const onGenerationDone = useCallback(async (fullContent: string, doneMeta: { as_of?: string; summary?: string; emotion_score?: number; emotion_label?: string } | null) => {
|
||||
const reportAsOf = doneMeta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10)
|
||||
try {
|
||||
await api.reviewReportSave({
|
||||
as_of: reportAsOf,
|
||||
focus,
|
||||
content: fullContent,
|
||||
summary: doneMeta?.summary,
|
||||
emotion_score: doneMeta?.emotion_score ?? null,
|
||||
emotion_label: doneMeta?.emotion_label ?? '',
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
||||
} catch { /* 静默 */ }
|
||||
}, [focus, asOf, marketQuery.data, qc])
|
||||
|
||||
// 主流程:生成复盘(委托给全局 store,流在后台独立运行)
|
||||
const generate = useCallback(() => {
|
||||
if (isReviewGenerating()) return
|
||||
setViewing(null)
|
||||
resetReview()
|
||||
startReviewGeneration(asOf, focus, (full, doneMeta) => {
|
||||
onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ })
|
||||
})
|
||||
}, [asOf, focus, onGenerationDone])
|
||||
|
||||
// 复制全文到剪贴板(viewing 优先,与主区域显示一致)
|
||||
const copyContent = useCallback(async () => {
|
||||
const text = viewing?.content ?? content
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast('已复制到剪贴板', 'success')
|
||||
} catch {
|
||||
toast('复制失败,请手动选择文本', 'error')
|
||||
}
|
||||
}, [content, viewing])
|
||||
|
||||
// 下载为 .md 文件(viewing 优先)
|
||||
const downloadContent = useCallback(() => {
|
||||
const text = viewing?.content ?? content
|
||||
if (!text) return
|
||||
const reportDate = viewing?.as_of ?? meta?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10)
|
||||
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `复盘_${reportDate}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [content, viewing, meta, asOf])
|
||||
|
||||
// 查看历史报告(不中断后台生成:仅临时把 viewing 覆盖到主区域,
|
||||
// 生成中的流仍在 store 里继续跑,点"生成中"项即可切回)
|
||||
const viewReport = useCallback((r: AiReviewReport) => {
|
||||
setViewing(r)
|
||||
}, [])
|
||||
|
||||
const isGenerating = phase === 'loading' || phase === 'streaming'
|
||||
const displayDate = viewing?.as_of ?? meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? '最新'
|
||||
const data = marketQuery.data
|
||||
// 主区域显示的内容:viewing(查看历史)优先于 store 的生成 content,
|
||||
// 这样点历史报告不会覆盖后台生成中的流。
|
||||
const displayContent = viewing?.content ?? content
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="AI 复盘"
|
||||
titleExtra={<Sparkles className="h-4 w-4 text-accent" />}
|
||||
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { marketQuery.refetch() }}
|
||||
disabled={marketQuery.isFetching}
|
||||
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"
|
||||
title="刷新市场数据"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={openSchedule}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-btn border px-2 py-1 text-[11px] transition-colors',
|
||||
reviewSched.enabled
|
||||
? 'border-accent/40 bg-accent/10 text-accent hover:bg-accent/20'
|
||||
: 'border-border bg-elevated text-secondary hover:text-foreground',
|
||||
)}
|
||||
title={reviewSched.enabled ? `定时复盘已开启 · 每日 ${String(reviewSched.hour).padStart(2,'0')}:${String(reviewSched.minute).padStart(2,'0')}` : '定时复盘'}
|
||||
>
|
||||
<Clock className="h-3 w-3" />定时
|
||||
</button>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isGenerating}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-btn px-3.5 py-1.5 text-xs font-medium transition-all',
|
||||
isGenerating
|
||||
? 'border border-accent/40 bg-accent/10 text-accent cursor-not-allowed'
|
||||
: 'bg-accent text-white shadow-sm shadow-accent/25 hover:bg-accent/90 hover:shadow hover:shadow-accent/30',
|
||||
)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<><RefreshCw className="h-3.5 w-3.5 animate-spin" />生成中…</>
|
||||
) : (
|
||||
<><Sparkles className="h-3.5 w-3.5" />生成复盘</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-full bg-[radial-gradient(circle_at_15%_-5%,rgba(59,130,246,0.10),transparent_30%),radial-gradient(circle_at_85%_5%,rgba(139,92,246,0.08),transparent_30%)] px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto max-w-[1280px] space-y-3">
|
||||
|
||||
{marketQuery.isLoading && !data ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> 加载市场数据…
|
||||
</div>
|
||||
</div>
|
||||
) : !data || !data.as_of ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-card border border-border bg-surface/80 px-6 py-16">
|
||||
<div className="relative">
|
||||
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
|
||||
<Database className="h-6 w-6 text-accent" strokeWidth={1.8} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-foreground">暂无市场数据</div>
|
||||
<p className="mt-1 text-xs text-muted">复盘需要日 K 与指数,请先前往「数据」页同步</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/data"
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all hover:bg-accent/90 hover:shadow"
|
||||
>
|
||||
<Database className="h-3.5 w-3.5" />前往数据页同步
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ===== 市场摘要条(轻量上下文,非重复看板)===== */}
|
||||
<MarketSummaryBar data={data} />
|
||||
|
||||
{/* ===== 关注点输入 ===== */}
|
||||
<div className="flex items-center gap-2 rounded-card border border-border bg-surface/80 px-3.5 py-2.5 transition-colors focus-within:border-accent/40">
|
||||
<Wand2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<input
|
||||
value={focus}
|
||||
onChange={(e) => setFocus(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !isGenerating) generate() }}
|
||||
placeholder="可选:补充复盘关注点,如「明日是否加仓半导体」「量能是否持续」"
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted/60"
|
||||
/>
|
||||
{focus && (
|
||||
<button onClick={() => setFocus('')} className="text-xs text-muted transition-colors hover:text-foreground">清除</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 报告 + 历史 双栏(报告为主体)===== */}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_18rem]">
|
||||
<ReportPanel
|
||||
phase={phase}
|
||||
content={displayContent}
|
||||
error={error}
|
||||
isGenerating={isGenerating}
|
||||
viewing={viewing}
|
||||
onCopy={copyContent}
|
||||
onDownload={downloadContent}
|
||||
onRegenerate={generate}
|
||||
reportEndRef={reportEndRef}
|
||||
/>
|
||||
<HistoryPanel
|
||||
reports={historyQuery.data?.reports ?? []}
|
||||
loading={historyQuery.isLoading}
|
||||
viewingId={viewing?.id ?? null}
|
||||
generating={isGenerating}
|
||||
onView={viewReport}
|
||||
onBackToGenerating={() => setViewing(null)}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 定时复盘设置弹窗 ===== */}
|
||||
<AnimatePresence>
|
||||
{showSchedule && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={() => setShowSchedule(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
className="w-full max-w-md rounded-card border border-border bg-surface p-5 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-medium text-foreground">定时复盘</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSchedule(false)}
|
||||
className="rounded p-1 text-muted transition-colors hover:bg-elevated hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-[11px] leading-relaxed text-muted">
|
||||
开启后,每个交易日到点自动生成大盘复盘报告并归档,静默执行。
|
||||
下次打开本页即可在历史列表看到新报告;也可选推送到飞书。
|
||||
</p>
|
||||
|
||||
{/* 开关(只改本地草稿, 不提交) */}
|
||||
<label className="flex items-center justify-between rounded-btn bg-elevated/40 px-3 py-2.5">
|
||||
<span className="text-xs text-foreground">启用定时复盘</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.enabled}
|
||||
onClick={() => setDraft(d => ({ ...d, enabled: !d.enabled }))}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
|
||||
draft.enabled ? 'bg-accent' : 'bg-border',
|
||||
)}
|
||||
>
|
||||
<span className={cn('inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform', draft.enabled ? 'translate-x-[18px]' : 'translate-x-1')} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{/* 时间设置(仅开启时可编辑, 本地草稿) */}
|
||||
{draft.enabled && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-btn bg-elevated/40 px-3 py-2.5">
|
||||
<span className="text-[11px] text-muted">每日</span>
|
||||
<input
|
||||
type="number" min={0} max={23} value={draft.hour}
|
||||
onChange={e => setDraft(d => ({ ...d, hour: Math.max(0, Math.min(23, Number(e.target.value))) }))}
|
||||
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<span className="text-xs text-muted">:</span>
|
||||
<input
|
||||
type="number" min={0} max={59} value={draft.minute}
|
||||
onChange={e => setDraft(d => ({ ...d, minute: Math.max(0, Math.min(59, Number(e.target.value))) }))}
|
||||
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<span className="text-[10px] text-muted/70">不早于 15:00 · 工作日执行</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 推送渠道(多选, 独立常驻, 与定时无关, 即时生效) */}
|
||||
<div className="mt-3 rounded-btn bg-elevated/40 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-foreground">生成后推送完整报告</span>
|
||||
<span className="text-[10px] text-muted/70">{reviewPushChannels.length === 0 ? '未开启' : `${reviewPushChannels.length} 个渠道`}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{/* 飞书(可用, 多选) */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={pushMut.isPending}
|
||||
onClick={() => togglePushChannel('feishu')}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
|
||||
reviewPushChannels.includes('feishu')
|
||||
? 'border-accent/40 bg-accent/10'
|
||||
: 'border-border/60 bg-base/40 hover:bg-base/60',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('feishu') ? 'border-accent bg-accent text-white' : 'border-border')}>
|
||||
{reviewPushChannels.includes('feishu') && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground">飞书</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
<span className={cn('ml-auto text-[9px]', feishuConfigured ? 'text-emerald-500' : 'text-warning')}>
|
||||
{feishuConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</button>
|
||||
{/* 微信(开发中, 占位不可选) */}
|
||||
<div className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-1.5 opacity-60">
|
||||
<span className="flex h-3 w-3 shrink-0 items-center justify-center rounded border border-border" />
|
||||
<span className="text-[11px] text-secondary">微信</span>
|
||||
<span className="text-[9px] text-muted">公众号/企业微信</span>
|
||||
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">开发中</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
|
||||
手动或定时生成的复盘都会以卡片消息推送完整报告。复用「设置 → 实时监控」的飞书 Webhook。
|
||||
{reviewPushChannels.includes('feishu') && !feishuConfigured && (
|
||||
<Link to="/settings?tab=monitoring" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
|
||||
前往配置 →
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!draft.enabled && (
|
||||
<p className="mt-3 text-[10px] text-muted/70">
|
||||
当前: 已关闭。开启后将按设定时间自动复盘。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 操作区: 取消 + 保存(统一提交开关+时间) */}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowSchedule(false)}
|
||||
className="rounded-btn bg-elevated px-4 py-1.5 text-xs text-secondary transition-colors hover:text-foreground"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reviewMut.mutate({ enabled: draft.enabled, hour: draft.hour, minute: draft.minute })}
|
||||
disabled={reviewMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{reviewMut.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 市场摘要条 —— 复盘页的轻量上下文(非重复看板)
|
||||
// 仅一行:三大指数涨跌 · 情绪分 · 涨停结构 · 成交额
|
||||
// 详细数据请去 Dashboard 看,这里只给 AI 报告提供背景参照
|
||||
// ================================================================
|
||||
// 指数简称映射:全称太长(上证指数/深证成指/创业板指/科创综指)摘要条放不下,统一缩成单字
|
||||
const INDEX_SHORT: Record<string, string> = {
|
||||
'上证指数': '上', '深证成指': '深', '创业板指': '创', '科创综指': '科', '科创50': '科',
|
||||
}
|
||||
function indexShort(name?: string | null, symbol?: string): string {
|
||||
if (!name) return symbol ?? '—'
|
||||
return INDEX_SHORT[name] ?? (name.replace(/指数|成指|A股|综指|50/g, '').slice(0, 2) || name.slice(0, 1))
|
||||
}
|
||||
|
||||
// 批量替换文本中的指数全称为简称(用于历史列表 summary 显示,
|
||||
// 兼容存量旧报告 —— 它们存盘时 summary 还是全称)。
|
||||
const _INDEX_FULL_RE = /上证指数|深证成指|创业板指|科创综指|科创50/g
|
||||
function shortenIndexNames(text: string): string {
|
||||
return text.replace(_INDEX_FULL_RE, (m) => INDEX_SHORT[m] ?? m)
|
||||
}
|
||||
|
||||
// 从 summary 的指数段(如「上-2.26%、深-3.44%、创-4.07%、科-2.02%」)
|
||||
// 解析出 [{name, pctStr, pctNum}],供列表项按涨跌染色渲染。
|
||||
const _INDEX_PCT_RE = /([上深创科])([+-]?\d+\.\d+%)/g
|
||||
function parseIndexPcts(indexSegment: string): { name: string; pctStr: string; pctNum: number }[] {
|
||||
const out: { name: string; pctStr: string; pctNum: number }[] = []
|
||||
for (const m of indexSegment.matchAll(_INDEX_PCT_RE)) {
|
||||
out.push({ name: m[1], pctStr: m[2], pctNum: parseFloat(m[2]) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function MarketSummaryBar({ data }: { data: OverviewMarket }) {
|
||||
const score = data.emotion?.score ?? null
|
||||
const emoColor = scoreColor(score)
|
||||
const indices = (data.indices ?? []).slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-card border border-border bg-surface/80 px-4 py-2.5">
|
||||
{/* 情绪分(带色徽章)—— 复盘的核心定调 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded font-mono text-xs font-bold tabular-nums"
|
||||
style={{ color: emoColor, backgroundColor: `${emoColor}1a` }}
|
||||
>
|
||||
{score ?? '—'}
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[11px] font-medium text-foreground">{data.emotion?.label ?? '情绪'}</div>
|
||||
<div className="text-[9px] text-secondary">情绪温度</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden h-7 w-px bg-border sm:block" />
|
||||
|
||||
{/* 四大指数(简称:上深创科)*/}
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
{indices.map(idx => (
|
||||
<div key={idx.symbol} className="flex items-center gap-1">
|
||||
<span className="text-[11px] text-secondary">{indexShort(idx.name, idx.symbol)}</span>
|
||||
<span className={cn('font-mono text-[11px] font-semibold tabular-nums', pctClass(idx.change_pct))}>
|
||||
{fmtPctAlready(idx.change_pct, 2, true)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden h-7 w-px bg-border sm:block" />
|
||||
|
||||
{/* 涨跌结构 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-secondary">涨跌</span>
|
||||
<span className="font-mono font-semibold text-bull">{data.breadth?.up ?? 0}</span>
|
||||
<span className="text-muted">/</span>
|
||||
<span className="font-mono font-semibold text-bear">{data.breadth?.down ?? 0}</span>
|
||||
</div>
|
||||
|
||||
{/* 涨停结构 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-secondary">涨停</span>
|
||||
<span className="font-mono font-semibold text-bull">{data.limit?.limit_up ?? 0}</span>
|
||||
<span className="text-secondary">封板 {(data.limit?.seal_rate ?? 0).toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
{/* 成交额 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-secondary">成交</span>
|
||||
<span className="font-mono font-semibold text-foreground">{fmtBigNum(data.amount?.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 报告面板(流式 + 错误 + 历史/完成态)
|
||||
// ================================================================
|
||||
function ReportPanel({
|
||||
phase, content, error, isGenerating, viewing, onCopy, onDownload, onRegenerate, reportEndRef,
|
||||
}: {
|
||||
phase: ReviewPhase
|
||||
content: string
|
||||
error: string
|
||||
isGenerating: boolean
|
||||
viewing: AiReviewReport | null
|
||||
onCopy: () => void
|
||||
onDownload: () => void
|
||||
onRegenerate: () => void
|
||||
reportEndRef: React.RefObject<HTMLDivElement>
|
||||
}) {
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-card border border-border bg-surface/80 px-6 py-14">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-full bg-danger/10">
|
||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">复盘失败</div>
|
||||
<div className="max-w-md text-center text-xs text-secondary">{error || '请检查 AI 配置后重试'}</div>
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="mt-1 inline-flex items-center gap-1.5 rounded-btn bg-accent/15 px-3 py-1.5 text-xs text-accent transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />重新生成
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === 'idle' && !content) {
|
||||
return (
|
||||
<div className="flex min-h-[28rem] flex-col items-center justify-center gap-5 rounded-card border border-border bg-surface/80 px-6 py-16">
|
||||
<div className="relative">
|
||||
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
|
||||
<BookOpenCheck className="h-9 w-9 text-accent" strokeWidth={1.8} />
|
||||
</div>
|
||||
<Sparkles className="absolute -right-1 -top-1 h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-base font-semibold text-foreground">AI 大盘复盘</div>
|
||||
<p className="mx-auto mt-2 max-w-sm text-xs leading-relaxed text-secondary">
|
||||
一键生成今日盘后复盘报告 —— 从一句话定调到明日交易计划,
|
||||
结构化输出可直接指导次日仓位与节奏。
|
||||
</p>
|
||||
</div>
|
||||
{/* 报告七节预览 —— 空状态也有内容感,暗示报告结构 */}
|
||||
<div className="mt-2 grid w-full max-w-md grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ icon: '🎯', label: '一句话定调' },
|
||||
{ icon: '📊', label: '盘面总览' },
|
||||
{ icon: '🔥', label: '板块主线' },
|
||||
{ icon: '💰', label: '资金情绪' },
|
||||
{ icon: '📰', label: '消息催化' },
|
||||
{ icon: '🎯', label: '明日计划' },
|
||||
{ icon: '⚠️', label: '风险提示' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex flex-col items-center gap-1 rounded-btn bg-elevated/40 px-2 py-2">
|
||||
<span className="text-base">{s.icon}</span>
|
||||
<span className="text-[10px] text-secondary">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted">
|
||||
<Sparkles className="h-3 w-3 text-accent" />
|
||||
点击右上角「生成复盘」开始
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 仅当显示生成内容(非查看历史)且正在生成时,才显示流式光标
|
||||
const showCursor = isGenerating && !viewing
|
||||
// 查看历史时(即使后台在生成)也能复制/下载该历史报告
|
||||
const showActions = !!content && (!isGenerating || !!viewing)
|
||||
const showViewingTag = !!viewing
|
||||
const isLoading = phase === 'loading' && !content
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="overflow-hidden rounded-card border border-border bg-surface/80"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border bg-gradient-to-r from-accent/5 to-transparent px-4 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isGenerating ? <RefreshCw className="h-3.5 w-3.5 animate-spin text-accent" /> : <BookOpenCheck className="h-3.5 w-3.5 text-accent" />}
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'}
|
||||
</span>
|
||||
</div>
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={onCopy} className="inline-flex items-center gap-1 rounded-btn bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground hover:bg-elevated/70" title="复制全文">
|
||||
<Copy className="h-3 w-3" />复制
|
||||
</button>
|
||||
<button onClick={onDownload} className="inline-flex items-center gap-1 rounded-btn bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground hover:bg-elevated/70" title="下载为 Markdown">
|
||||
<Download className="h-3 w-3" />下载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-22rem)] overflow-y-auto px-5 py-4">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<div className="relative">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-full bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
|
||||
<Sparkles className="h-5 w-5 animate-pulse text-accent" />
|
||||
</div>
|
||||
<RefreshCw className="absolute -inset-1 h-13 w-13 animate-spin text-accent/30" style={{ animationDuration: '3s' }} />
|
||||
</div>
|
||||
<div className="text-sm text-foreground">AI 正在复盘今日盘面…</div>
|
||||
<div className="text-xs text-secondary">分析指数结构 · 连板梯队 · 板块轮动 · 资金情绪</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<MarkdownRenderer content={content} />
|
||||
{showCursor && (
|
||||
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-accent align-middle" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={reportEndRef} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 历史面板
|
||||
// ================================================================
|
||||
function HistoryPanel({
|
||||
reports, loading, viewingId, generating, onView, onBackToGenerating, onDelete,
|
||||
}: {
|
||||
reports: AiReviewReport[]
|
||||
loading: boolean
|
||||
viewingId: string | null
|
||||
generating: boolean
|
||||
onView: (r: AiReviewReport) => void
|
||||
onBackToGenerating: () => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const empty = !generating && reports.length === 0
|
||||
return (
|
||||
<div className="overflow-hidden rounded-card border border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 border-b border-border bg-gradient-to-r from-accent/5 to-transparent px-3 py-2.5">
|
||||
<History className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-xs font-medium text-foreground">历史复盘</span>
|
||||
<span className="font-mono text-[10px] text-muted">({reports.length})</span>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-26rem)] overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="grid h-20 place-items-center"><RefreshCw className="h-4 w-4 animate-spin text-muted" /></div>
|
||||
) : empty ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-3 py-10 text-center">
|
||||
<History className="h-7 w-7 text-muted/40" strokeWidth={1.5} />
|
||||
<div className="text-[11px] text-muted">暂无历史复盘</div>
|
||||
<div className="text-[10px] text-muted/60">生成完成后自动归档</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* 生成中占位项:列表顶部,点击回到正在生成的流式内容 */}
|
||||
{generating && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded px-2 py-2 cursor-pointer transition-colors',
|
||||
viewingId === null ? 'bg-accent/10 ring-1 ring-accent/20' : 'hover:bg-elevated/60',
|
||||
)}
|
||||
onClick={onBackToGenerating}
|
||||
>
|
||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-accent/15">
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[11px] font-medium text-accent">生成中…</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-secondary">AI 正在复盘今日盘面</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reports.map((r) => {
|
||||
const color = scoreColor(r.emotion_score)
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded px-2 py-2 cursor-pointer transition-colors',
|
||||
viewingId === r.id ? 'bg-accent/10 ring-1 ring-accent/20' : 'hover:bg-elevated/60',
|
||||
)}
|
||||
onClick={() => onView(r)}
|
||||
>
|
||||
<div
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded font-mono text-[10px] font-bold tabular-nums"
|
||||
style={{ color, backgroundColor: `${color}1a` }}
|
||||
>
|
||||
{r.emotion_score ?? '—'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{r.emotion_label ?? '—'}</span>
|
||||
<span className="font-mono text-[10px] text-secondary">{r.as_of}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
{r.summary
|
||||
? (() => {
|
||||
const pcts = parseIndexPcts(shortenIndexNames(r.summary).split('|')[0])
|
||||
if (pcts.length === 0) {
|
||||
return <span className="truncate text-[10px] text-secondary">{r.content.slice(0, 40)}</span>
|
||||
}
|
||||
return pcts.map((p) => (
|
||||
<span key={p.name} className="inline-flex items-center gap-0.5 text-[10px]">
|
||||
<span className="text-secondary">{p.name}</span>
|
||||
<span className={cn('font-mono font-medium tabular-nums', pctClass(p.pctNum))}>{p.pctStr}</span>
|
||||
</span>
|
||||
))
|
||||
})()
|
||||
: <span className="truncate text-[10px] text-secondary">{r.content.slice(0, 40)}</span>}
|
||||
</div>
|
||||
{r.created_at && (
|
||||
<div className="mt-0.5 font-mono text-[9px] text-muted">{fmtArchivedAt(r.created_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(r.id) }}
|
||||
className="shrink-0 p-1 text-muted opacity-0 transition-all hover:text-bear group-hover:opacity-100"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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,85 @@
|
||||
/**
|
||||
* 统一设置页面 — Tab 切换外壳。
|
||||
*
|
||||
* 通过 URL query param ?tab=xxx 同步 Tab 状态。
|
||||
*/
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { BarChart3, Key, Radio, SlidersHorizontal, Sparkles, Settings2, Zap } from 'lucide-react'
|
||||
import { SettingsKeysPanel } from './settings/Keys'
|
||||
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: 'TickFlow', 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,318 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink, Bell } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { LastStockChip } from '@/components/LastStockChip'
|
||||
import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart'
|
||||
import { api } from '@/lib/api'
|
||||
import { useLastStock } from '@/lib/useLastStock'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { toast } from '@/components/Toast'
|
||||
import {
|
||||
startAnalysis, findTodayReport, useHistoryReports,
|
||||
deleteReport, openHistoryReport,
|
||||
} from '@/lib/stockAnalysisStore'
|
||||
|
||||
/**
|
||||
* 个股分析页 —— 日 K + 关键价位(压力/支撑/密集区/枢轴/前高前低)+ AI 四维分析。
|
||||
*
|
||||
* 与财务分析页的区别:
|
||||
* - 以【行情 + 关键价位】为视觉主体(专用日 K 图表,不复用个股对话框图表)
|
||||
* - AI 分析输出买卖区间 / 操作建议(非财务质量评级)
|
||||
* - 报告胶囊用蓝色系,与财务分析(紫色)并存
|
||||
*/
|
||||
export function StockAnalysis() {
|
||||
const [symbol, setSymbol] = useState<string>('')
|
||||
const [name, setName] = useState<string>('')
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
|
||||
|
||||
const onSelect = (sym: string, nm: string) => {
|
||||
setSymbol(sym)
|
||||
setName(nm)
|
||||
setShowHistory(false)
|
||||
setConfirmReport(null)
|
||||
rememberStock(sym, nm)
|
||||
}
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!symbol || checking) return
|
||||
setChecking(true)
|
||||
try {
|
||||
// 当日已分析过 → 二次确认(查看今日报告 / 重新分析)
|
||||
const today = await findTodayReport(symbol)
|
||||
if (today) {
|
||||
setConfirmReport({ id: today.id, created_at: today.created_at, focus: today.focus })
|
||||
} else {
|
||||
await doAnalysis()
|
||||
}
|
||||
} catch {
|
||||
await doAnalysis()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doAnalysis = async () => {
|
||||
const r = await startAnalysis(symbol, name)
|
||||
if (r.error) toast(r.error, 'error')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="个股分析"
|
||||
titleExtra={
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
Beta
|
||||
</span>
|
||||
}
|
||||
subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<LastStockChip stock={lastStock} onSelect={onSelect} />
|
||||
{symbol && (
|
||||
<button
|
||||
onClick={() => setShowHistory(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border text-secondary text-xs hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<HistoryIcon className="h-3.5 w-3.5" />
|
||||
历史报告
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-72">
|
||||
<StockFinancialSearch onSelect={onSelect} />
|
||||
</div>
|
||||
{symbol && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPreviewSymbol(symbol)}
|
||||
title="查看个股日 K 详情"
|
||||
className="group flex items-center gap-2 text-sm rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-elevated transition-colors"
|
||||
>
|
||||
<span className="text-foreground font-medium group-hover:text-sky-300 transition-colors">{name || symbol}</span>
|
||||
<span className="text-[10px] font-mono text-muted">{symbol}</span>
|
||||
<ExternalLink className="h-3 w-3 text-muted opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={checking}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-sky-500/25 to-blue-500/15 border border-sky-400/30 text-sky-300 text-xs font-medium hover:from-sky-500/35 hover:to-blue-500/25 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI 个股分析
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toast('点位提醒功能开发中,敬请期待', 'error')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border/40 bg-elevated/40 text-muted text-xs font-medium hover:border-border/70 hover:text-secondary transition-all"
|
||||
title="当价格触及关键价位时提醒(开发中)"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
点位提醒
|
||||
<span className="rounded-full bg-amber-400/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
开发中
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
{!symbol ? (
|
||||
<EmptyState
|
||||
icon={LineChart}
|
||||
title="选择一只股票开始分析"
|
||||
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
|
||||
/>
|
||||
) : showHistory ? (
|
||||
<HistoryList symbol={symbol} />
|
||||
) : (
|
||||
<StockAnalysisBoard symbol={symbol} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 二次确认:已有历史报告 */}
|
||||
{confirmReport && (
|
||||
<ConfirmModal
|
||||
report={confirmReport}
|
||||
onView={() => { openHistoryReport(confirmReport.id); setConfirmReport(null) }}
|
||||
onRedo={async () => { setConfirmReport(null); await doAnalysis() }}
|
||||
onClose={() => setConfirmReport(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 个股日 K 详情对话框(点击名称/代码打开) */}
|
||||
<StockPreviewDialog
|
||||
symbol={previewSymbol}
|
||||
name={previewSymbol === symbol ? name : undefined}
|
||||
triggerInfo={null}
|
||||
onClose={() => setPreviewSymbol(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 分析看板:日 K + 关键价位 =====
|
||||
function StockAnalysisBoard({ symbol }: { symbol: string }) {
|
||||
const kline = useQuery({
|
||||
queryKey: ['kline', symbol, ''],
|
||||
queryFn: () => api.klineDaily(symbol, 250),
|
||||
enabled: !!symbol,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const levelsQ = useQuery({
|
||||
queryKey: QK.stockLevels(symbol),
|
||||
queryFn: () => api.stockAnalysisLevels(symbol, 250),
|
||||
enabled: !!symbol,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (kline.isLoading) {
|
||||
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
}
|
||||
|
||||
const rows = kline.data?.rows ?? []
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState icon={LineChart} title="暂无日 K 数据" hint="该标的尚未同步日 K,请先在数据页或自选页同步。" />
|
||||
}
|
||||
|
||||
const levels = (levelsQ.data?.levels ?? {}) as Record<LevelType, PriceLevel[]>
|
||||
|
||||
// 涨跌色:最后一根 K 线收 vs 前一根收(无前日则按开收判断)
|
||||
const last = rows[rows.length - 1]
|
||||
const prev = rows[rows.length - 2]
|
||||
const curClose = levelsQ.data?.close
|
||||
const isUp = prev ? (last.close >= prev.close) : (last.close >= last.open)
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/40">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<LineChart className="h-4 w-4 text-sky-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground">关键价位分析</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 shrink-0">
|
||||
<span className="text-[10px] text-muted">{rows.length} 个交易日</span>
|
||||
<span className="text-[10px] text-muted/60">·</span>
|
||||
<span className="text-[10px] text-muted">当前价</span>
|
||||
<span className={`text-base font-mono font-bold ${isUp ? 'text-bull' : 'text-bear'}`}>
|
||||
{curClose?.toFixed(2) ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<AnalysisKChart
|
||||
rows={rows}
|
||||
levels={levels}
|
||||
series={levelsQ.data?.series}
|
||||
seriesDates={levelsQ.data?.dates}
|
||||
defaultLevelTypes={['sr', 'pivot', 'keltner_s']}
|
||||
height={480}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 历史报告列表 =====
|
||||
function HistoryList({ symbol }: { symbol: string }) {
|
||||
const { reports, loaded } = useHistoryReports()
|
||||
const mine = reports.filter(r => r.symbol === symbol)
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
}
|
||||
if (mine.length === 0) {
|
||||
return <EmptyState icon={HistoryIcon} title="暂无历史报告" hint={`还没有 ${symbol} 的个股分析报告,点击「AI 个股分析」生成第一份。`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{mine.map(r => (
|
||||
<div key={r.id} className="rounded-card border border-border/60 bg-surface/40 p-3 hover:border-border transition-colors">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={() => openHistoryReport(r.id)} className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary">{fmtRelative(r.created_at)}</span>
|
||||
{r.close && <span className="text-[10px] font-mono text-muted">价 {r.close.toFixed(2)}</span>}
|
||||
{r.focus && <span className="text-[10px] text-sky-300/70 truncate">关注: {r.focus}</span>}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted truncate">{r.summary || '点击查看完整报告'}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
|
||||
className="shrink-0 text-[10px] text-muted hover:text-danger transition-colors px-2 py-1"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 二次确认弹窗 =====
|
||||
function ConfirmModal({ report, onView, onRedo, onClose }: {
|
||||
report: { id: string; created_at: string; focus: string }
|
||||
onView: () => void
|
||||
onRedo: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-sm bg-surface border border-border rounded-2xl p-5 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<HistoryIcon className="h-4 w-4 text-sky-400" />
|
||||
<span className="text-sm font-medium text-foreground">该个股已有分析报告</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary leading-relaxed mb-1">
|
||||
最近一次报告生成于 <span className="text-foreground">{fmtRelative(report.created_at)}</span>。
|
||||
</p>
|
||||
{report.focus && <p className="text-xs text-muted mb-1">关注点: {report.focus}</p>}
|
||||
<p className="text-xs text-muted mb-4">可直接查看历史,或重新生成一份新报告。</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onView}
|
||||
className="flex-1 h-8 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
查看历史
|
||||
</button>
|
||||
<button onClick={onRedo}
|
||||
className="flex-1 h-8 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs text-sky-300 hover:from-sky-500/30 transition-all">
|
||||
重新分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
} catch { return iso }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
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,124 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { FactorBacktestResult } from '@/lib/api'
|
||||
|
||||
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 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: 'rgba(15,23,42,0.95)',
|
||||
borderColor: 'rgba(148,163,184,0.2)',
|
||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
const date = params[0]?.axisValue ?? ''
|
||||
let html = `<div style="font-size:11px;color:#94a3b8;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: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
axisLabel: { color: '#64748b', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
series,
|
||||
} as any
|
||||
}, [result.group_nav, result.long_short_nav, result.run_id])
|
||||
|
||||
const chartRef = useECharts(option, [result.run_id])
|
||||
|
||||
// 图例
|
||||
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,88 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { FactorBacktestResult } from '@/lib/api'
|
||||
|
||||
interface Props {
|
||||
result: FactorBacktestResult
|
||||
}
|
||||
|
||||
export function FactorICChart({ result }: Props) {
|
||||
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: 'rgba(15,23,42,0.95)',
|
||||
borderColor: 'rgba(148,163,184,0.2)',
|
||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
const date = params[0]?.axisValue ?? ''
|
||||
let html = `<div style="font-size:11px;color:#94a3b8;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: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
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])
|
||||
|
||||
return <div ref={chartRef} className="h-[200px]" />
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
interface DistBin {
|
||||
range: string
|
||||
count: number
|
||||
ratio: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 收益分布直方图 — 全量模拟专用的候选标的收益分布。
|
||||
* 柱子颜色按收益正负区分(正红负绿),零轴居中。
|
||||
*/
|
||||
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
|
||||
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: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
|
||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: '#27272a' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: vals.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } })),
|
||||
barWidth: '90%',
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [distribution])
|
||||
|
||||
const chartRef = useECharts(option, [distribution])
|
||||
|
||||
return <div ref={chartRef} className="h-48 w-full" />
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { StrategyBacktestResult } from '@/lib/api'
|
||||
|
||||
interface Props {
|
||||
result: StrategyBacktestResult
|
||||
}
|
||||
|
||||
export function StrategyNavChart({ result }: Props) {
|
||||
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: '#334155' },
|
||||
},
|
||||
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: '#334155' } },
|
||||
},
|
||||
{
|
||||
type: 'category', data: dates, gridIndex: 1,
|
||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
||||
axisTick: { show: false },
|
||||
axisPointer: { show: true, type: 'line' },
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value', gridIndex: 0,
|
||||
scale: true,
|
||||
name: hasBenchmark ? '上证点位' : '策略资金',
|
||||
nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
||||
axisLabel: {
|
||||
color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
|
||||
fontSize: 10,
|
||||
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'value', gridIndex: 0,
|
||||
position: 'right',
|
||||
scale: true,
|
||||
name: hasBenchmark ? '策略资金' : '',
|
||||
nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
||||
axisLabel: {
|
||||
show: hasBenchmark,
|
||||
color: '#64748b',
|
||||
fontSize: 10,
|
||||
formatter: axisMoneyFmt,
|
||||
},
|
||||
splitLine: { show: false },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'value', gridIndex: 1,
|
||||
position: 'right',
|
||||
max: 0,
|
||||
axisLabel: {
|
||||
color: '#64748b', fontSize: 10,
|
||||
formatter: (v: number) => `${v.toFixed(1)}%`,
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
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: 'rgba(148,163,184,0.18)',
|
||||
backgroundColor: 'rgba(15,23,42,0.55)',
|
||||
fillerColor: 'rgba(59,130,246,0.18)',
|
||||
handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
|
||||
textStyle: { color: '#64748b', fontSize: 10 },
|
||||
brushSelect: false,
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
||||
borderColor: 'rgba(148,163,184,0.2)',
|
||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
const date = params[0]?.axisValue ?? ''
|
||||
let html = `<div style="font-size:11px;color:#94a3b8;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])
|
||||
|
||||
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,437 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
|
||||
Shuffle, Plug, Zap, Settings2, ExternalLink, Trash2,
|
||||
Terminal,
|
||||
} from 'lucide-react'
|
||||
import { useSettings } from '@/lib/useSharedQueries'
|
||||
import { api, type SettingsState } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
// 统一的输入框样式(与项目其他设置页一致)
|
||||
const INPUT_CLS =
|
||||
'w-full h-9 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'
|
||||
|
||||
const CODEX_PROVIDER = 'codex_cli'
|
||||
const OPENAI_PROVIDER = 'openai_compat'
|
||||
const CUSTOM_CODEX_MODEL = '__custom__'
|
||||
const CODEX_COMMAND = 'codex'
|
||||
|
||||
const CODEX_MODEL_OPTIONS = [
|
||||
{ label: 'Codex 默认(推荐)', value: '', hint: '使用当前 Codex CLI 支持的默认模型' },
|
||||
{ label: 'gpt-5.5', value: 'gpt-5.5', hint: '高能力模型' },
|
||||
{ label: 'gpt-5', value: 'gpt-5', hint: '通用模型' },
|
||||
]
|
||||
|
||||
const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [
|
||||
{ label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
|
||||
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
|
||||
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-5.2', website: 'https://open.bigmodel.cn/', websiteLabel: 'open.bigmodel.cn', description: '智谱 AI 官方 OpenAI 兼容接口。' },
|
||||
{ label: 'Kimi', url: 'https://api.moonshot.cn/v1', model: 'kimi-k2.6', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
|
||||
{ label: 'Codex CLI', provider: CODEX_PROVIDER, url: '', model: '', codexCommand: CODEX_COMMAND, website: 'https://developers.openai.com/codex/noninteractive', websiteLabel: 'codex exec', description: '调用本机 Codex CLI 的 codex exec, 适合已登录 ChatGPT/Codex 的本地环境。' },
|
||||
{ label: '炸鸡中转站', url: 'https://code.alysc.top/v1', model: 'gpt-5.5', website: 'https://code.alysc.top/sign-up?aff=1afk', websiteLabel: 'code.alysc.top', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.01倍率' },
|
||||
]
|
||||
|
||||
export function SettingsAIPanel() {
|
||||
const qc = useQueryClient()
|
||||
const settings = useSettings()
|
||||
const s = settings.data
|
||||
|
||||
const [provider, setProvider] = useState(OPENAI_PROVIDER)
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [codexCustomModel, setCodexCustomModel] = useState(false)
|
||||
const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND)
|
||||
const [customUa, setCustomUa] = useState(false)
|
||||
const [userAgent, setUserAgent] = useState('')
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||
|
||||
const isCodexProvider = provider === CODEX_PROVIDER
|
||||
const savedCodexProvider = s?.ai_provider === CODEX_PROVIDER
|
||||
const configured = s?.ai_configured ?? (savedCodexProvider ? !!(s?.ai_codex_command ?? CODEX_COMMAND) : s?.has_ai_key)
|
||||
const selectedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl))
|
||||
const codexModelSelectValue = codexCustomModel ? CUSTOM_CODEX_MODEL : model
|
||||
const canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (!s) return
|
||||
setProvider(s.ai_provider ?? OPENAI_PROVIDER)
|
||||
setBaseUrl(s.ai_base_url ?? '')
|
||||
setModel(s.ai_model ?? '')
|
||||
setCodexCustomModel(!!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model))
|
||||
setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND)
|
||||
const ua = s.ai_user_agent ?? ''
|
||||
setCustomUa(!!ua)
|
||||
setUserAgent(ua)
|
||||
}, [s])
|
||||
|
||||
const payload = () => ({
|
||||
provider,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey || undefined,
|
||||
model,
|
||||
codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand,
|
||||
user_agent: customUa ? userAgent : '',
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.saveAiSettings(payload()),
|
||||
onSuccess: (result) => {
|
||||
setSaved(true)
|
||||
setApiKey('')
|
||||
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
|
||||
...prev,
|
||||
ai_provider: result.ai_provider ?? provider,
|
||||
ai_base_url: baseUrl,
|
||||
ai_model: result.ai_model ?? model,
|
||||
ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand),
|
||||
ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)),
|
||||
...(apiKey ? {
|
||||
has_ai_key: true,
|
||||
ai_api_key_masked: `${apiKey.slice(0, 4)}......${apiKey.slice(-4)}`,
|
||||
} : {}),
|
||||
} : prev)
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
},
|
||||
})
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAiSettings(),
|
||||
onSuccess: () => {
|
||||
setConfirmClear(false)
|
||||
setProvider(OPENAI_PROVIDER)
|
||||
setBaseUrl('')
|
||||
setApiKey('')
|
||||
setModel('')
|
||||
setCodexCustomModel(false)
|
||||
setCodexCommand(CODEX_COMMAND)
|
||||
setTestResult(null)
|
||||
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
|
||||
...prev,
|
||||
ai_provider: OPENAI_PROVIDER,
|
||||
ai_base_url: '',
|
||||
ai_model: '',
|
||||
ai_codex_command: CODEX_COMMAND,
|
||||
has_ai_key: false,
|
||||
ai_configured: false,
|
||||
ai_api_key_masked: '',
|
||||
} : prev)
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
},
|
||||
})
|
||||
|
||||
const genRandomUa = () => {
|
||||
const major = 128 + Math.floor(Math.random() * 8)
|
||||
const platforms = [
|
||||
'Windows NT 10.0; Win64; x64',
|
||||
'Macintosh; Intel Mac OS X 10_15_7',
|
||||
'X11; Linux x86_64',
|
||||
]
|
||||
const pf = platforms[Math.floor(Math.random() * platforms.length)]
|
||||
setUserAgent(`Mozilla/5.0 (${pf}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`)
|
||||
}
|
||||
|
||||
const handlePreset = (p: typeof PRESETS[number]) => {
|
||||
setProvider(p.provider ?? OPENAI_PROVIDER)
|
||||
setBaseUrl(p.url)
|
||||
setModel(p.model)
|
||||
setCodexCustomModel(false)
|
||||
if (p.codexCommand) setCodexCommand(CODEX_COMMAND)
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
if (canSave) await api.saveAiSettings(payload())
|
||||
const r = await api.strategyAiTest()
|
||||
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · ${r.model ?? provider}` : (r.error ?? '未知错误') })
|
||||
} catch (e: any) {
|
||||
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-2xl">
|
||||
<Card icon={Plug} title="连接状态" right={
|
||||
configured && (
|
||||
<button onClick={handleTest} disabled={testing}
|
||||
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">
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
|
||||
{testing ? '测试中' : '测试'}
|
||||
</button>
|
||||
)
|
||||
}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${configured ? 'bg-emerald-400/10 text-emerald-400' : 'bg-amber-400/10 text-amber-400'}`}>
|
||||
{configured ? <Wifi className="h-4.5 w-4.5" /> : <WifiOff className="h-4.5 w-4.5" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{configured ? 'AI 已连接' : 'AI 未配置'}</div>
|
||||
<div className="text-xs text-muted mt-0.5 truncate">
|
||||
{configured
|
||||
? (savedCodexProvider
|
||||
? `${s?.ai_codex_command ?? CODEX_COMMAND} · ${s?.ai_model || '默认模型'}`
|
||||
: `${s?.ai_model} · ${s?.ai_api_key_masked}`)
|
||||
: (isCodexProvider ? '使用本机 codex exec, 此处无需填写 API Key。' : '配置 API Key 后即可使用 AI 功能。')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{testResult && (
|
||||
<div className={`mt-3 rounded-btn border px-3 py-2 text-xs flex items-center gap-2 ${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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card icon={Zap} title="快速预设">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<button key={p.label} onClick={() => handlePreset(p)}
|
||||
className={`rounded-lg border px-3 py-2 text-left transition-all ${selectedPreset?.label === p.label ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-base text-secondary hover:border-accent/30'}`}>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<span>{p.label}</span>
|
||||
{p.provider === CODEX_PROVIDER && <Terminal className="h-3 w-3" />}
|
||||
{p.partner && <span className="rounded-full border border-orange-400/30 bg-orange-400/10 px-1.5 py-px text-[9px] text-orange-400">赞助</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedPreset && (
|
||||
<div className="mt-3 rounded-btn border border-border/30 bg-base/30 px-3 py-2 text-[11px] leading-relaxed">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-secondary">{selectedPreset.description}</span>
|
||||
{selectedPreset.promo && <span className="text-amber-400">{selectedPreset.promo}</span>}
|
||||
</div>
|
||||
<a href={selectedPreset.website} target="_blank" rel="noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-muted hover:text-accent transition-colors">
|
||||
{selectedPreset.websiteLabel}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
icon={Settings2}
|
||||
title="自定义配置"
|
||||
right={
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title={isCodexProvider ? 'Use local Codex CLI via codex exec' : 'Use OpenAI-compatible Chat Completions API'}>
|
||||
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">{isCodexProvider ? 'codex exec' : 'Chat Completions'}</span>
|
||||
{isCodexProvider ? 'CLI' : '接口'}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{isCodexProvider ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="CLI 命令" hint="固定使用默认 codex 命令, 由后端自动解析本机 Codex Desktop/CLI, 不支持自定义可执行路径。">
|
||||
<div className={`${INPUT_CLS} flex items-center text-muted/80 select-none`} aria-label="Codex CLI command">
|
||||
{CODEX_COMMAND}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="模型(可选)"
|
||||
hint={codexCustomModel
|
||||
? '留空则使用 Codex 默认模型'
|
||||
: CODEX_MODEL_OPTIONS.find(o => o.value === model)?.hint}
|
||||
>
|
||||
<select
|
||||
value={codexModelSelectValue}
|
||||
onChange={e => {
|
||||
const value = e.target.value
|
||||
if (value === CUSTOM_CODEX_MODEL) {
|
||||
setCodexCustomModel(true)
|
||||
if (CODEX_MODEL_OPTIONS.some(o => o.value === model)) setModel('')
|
||||
} else {
|
||||
setCodexCustomModel(false)
|
||||
setModel(value)
|
||||
}
|
||||
}}
|
||||
className={INPUT_CLS}
|
||||
>
|
||||
{CODEX_MODEL_OPTIONS.map(option => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
<option value={CUSTOM_CODEX_MODEL}>自定义模型</option>
|
||||
</select>
|
||||
{codexCustomModel && (
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="例如 gpt-5.5"
|
||||
className={`${INPUT_CLS} mt-2`}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="API 地址">
|
||||
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder="https://code.alysc.top" className={INPUT_CLS} />
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
<input type="text" value={model} onChange={e => setModel(e.target.value)} placeholder="gpt-5.5" className={INPUT_CLS} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="API Key">
|
||||
<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={`${INPUT_CLS} pr-9`} />
|
||||
<button onClick={() => setShowKey(v => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted" tabIndex={-1} aria-label={showKey ? '隐藏' : '显示'}>
|
||||
{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-9 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>
|
||||
</Field>
|
||||
|
||||
<div className="border-t border-border/20" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Field label="自定义 User-Agent" inline>
|
||||
<Toggle checked={customUa} onChange={() => setCustomUa(v => !v)} />
|
||||
</Field>
|
||||
</div>
|
||||
{customUa && (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)} placeholder="粘贴浏览器 User-Agent" className={`${INPUT_CLS} flex-1`} />
|
||||
<button type="button" onClick={genRandomUa} title="随机生成浏览器 User-Agent" className="h-9 px-2.5 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
|
||||
<Shuffle className="h-3 w-3" /> 随机
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="rounded-card border border-amber-400/20 bg-amber-400/[0.04] px-4 py-3 flex items-start gap-3">
|
||||
<Shield className="h-4 w-4 text-amber-400/70 mt-0.5 shrink-0" />
|
||||
<div className="text-[11px] text-amber-400/70 leading-relaxed">
|
||||
{isCodexProvider
|
||||
? 'Codex CLI 模式会复用本机已登录的 Codex 账户, 个股、财务、复盘等分析上下文会发送给 OpenAI/Codex。保存即表示确认仅在本机或可信内网使用。'
|
||||
: 'API Key 仅保存在本机项目文件中, 不会上传到任何服务器。请妥善保管。'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave} className="flex-1 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>
|
||||
{configured && (
|
||||
<button onClick={() => setConfirmClear(true)} disabled={clear.isPending} className="h-10 px-4 rounded-xl bg-elevated text-secondary hover:text-danger text-sm flex items-center justify-center gap-1.5 hover:bg-elevated/80 disabled:opacity-50 transition-all shrink-0" title="Clear AI provider configuration">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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">清空 AI 配置</h3>
|
||||
<p className="text-xs text-secondary mb-5 leading-relaxed">
|
||||
这会清空已保存的 provider、API Key、API 地址、模型和 Codex CLI 命令。之后可以重新配置。
|
||||
</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={() => 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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 通用卡片(与 Keys 页风格统一) =====
|
||||
|
||||
interface CardProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
right?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Card({ icon: Icon, title, right, children }: CardProps) {
|
||||
return (
|
||||
<section className="rounded-card border border-border bg-surface p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 表单字段(统一 label + 输入框样式) =====
|
||||
|
||||
function Field({ label, hint, inline, children }: {
|
||||
label: string
|
||||
hint?: string
|
||||
inline?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted/50 uppercase tracking-wider">{label}</div>
|
||||
{hint && <div className="text-[10px] text-muted mt-0.5">{hint}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] text-muted/50 uppercase tracking-wider">{label}</div>
|
||||
{children}
|
||||
{hint && <div className="text-[10px] text-muted">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 开关 =====
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${checked ? 'bg-accent' : 'bg-elevated'}`}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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,439 @@
|
||||
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="TickFlow API Key">
|
||||
<p className="text-sm text-secondary leading-relaxed mb-4">
|
||||
在{' '}
|
||||
<a
|
||||
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent hover:underline inline-flex items-baseline gap-0.5"
|
||||
>
|
||||
tickflow.org
|
||||
<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">未配置</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' ? '粘贴 TickFlow 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">
|
||||
清除后将退回 None 档(仅历史日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' ? 'None' : t}</span>
|
||||
<span className="text-secondary">{s.desc}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-btn border border-warning/30 bg-warning/10 px-2.5 py-1.5 text-[11px] font-medium text-warning">
|
||||
高等档位包含较低档位的全部权益。
|
||||
</div>
|
||||
|
||||
{/* 检测说明 */}
|
||||
<div className="text-secondary space-y-1.5">
|
||||
<div className="font-medium text-foreground">档位检测说明</div>
|
||||
<p>保存 Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为「None」(不存 Key);有日K但无复权因子则判为「Free」;有复权因子再按代表能力判定 Starter/Pro/Expert。</p>
|
||||
<p className="text-muted">None 档与 Free 档运行时都走免费数据通道(仅历史日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,311 @@
|
||||
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: '/review', 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,572 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
Wifi,
|
||||
BarChart3,
|
||||
Flame,
|
||||
Zap,
|
||||
Webhook,
|
||||
ChevronDown,
|
||||
} 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()
|
||||
const tier = tierRank(caps?.label ?? '')
|
||||
const isNoneTier = tier < 0
|
||||
const isFreeTier = tier === 0
|
||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
||||
const refreshPages = prefs?.sse_refresh_pages ?? {}
|
||||
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
|
||||
const hasDepth = !!caps?.capabilities?.['depth5.batch']
|
||||
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
|
||||
const webhookDefault = prefs?.webhook_enabled_default ?? false
|
||||
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 [intervalDraft, setIntervalDraft] = useState(interval)
|
||||
const feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''
|
||||
const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? ''
|
||||
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
|
||||
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
|
||||
const [feishuError, setFeishuError] = useState('')
|
||||
// 飞书渠道配置区展开态 (推送通知卡片内)
|
||||
const [channelOpen, setChannelOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
setFeishuDraft(feishuWebhookUrl)
|
||||
setFeishuSecretDraft(feishuWebhookSecret)
|
||||
}, [feishuWebhookUrl, feishuWebhookSecret])
|
||||
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: () => api.watchlistList(),
|
||||
enabled: isFreeTier && watchlistSymbols.length > 0,
|
||||
})
|
||||
const watchlistNameBySymbol = new Map(
|
||||
(watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const),
|
||||
)
|
||||
|
||||
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 toggleWebhookDefault = useCallback(async (enabled: boolean) => {
|
||||
await api.updateWebhookDefault(enabled)
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
}, [qc])
|
||||
|
||||
const saveFeishuWebhook = useMutation({
|
||||
mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret),
|
||||
onSuccess: () => {
|
||||
setFeishuError('')
|
||||
toast('飞书 Webhook 已保存', 'success')
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
},
|
||||
onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')),
|
||||
})
|
||||
const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/'
|
||||
const submitFeishu = useCallback(() => {
|
||||
const url = feishuDraft.trim()
|
||||
const secret = feishuSecretDraft.trim()
|
||||
if (url && !url.startsWith(FEISHU_PREFIX)) {
|
||||
setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头')
|
||||
return
|
||||
}
|
||||
saveFeishuWebhook.mutate({ url, secret })
|
||||
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
|
||||
|
||||
const runFix = useMutation({
|
||||
mutationFn: () => api.runLimitLadderFix(),
|
||||
onSuccess: (data) => {
|
||||
toast(data.msg, data.ok ? 'success' : 'error')
|
||||
// 修正后连板梯队数据变了, 刷新相关缓存
|
||||
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
|
||||
},
|
||||
onError: () => toast('修正请求失败', 'error'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setIntervalDraft(interval)
|
||||
}, [interval])
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalDraft === interval) return
|
||||
const t = window.setTimeout(() => {
|
||||
updateInterval.mutate(intervalDraft)
|
||||
}, 2000)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [intervalDraft, interval, updateInterval])
|
||||
|
||||
// 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])
|
||||
|
||||
if (isNoneTier) {
|
||||
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">
|
||||
实时行情需要 Free 及以上档位。None 档可使用 free-api 获取历史日K(当日数据需盘后1-2小时),但不能调用付费服务器实时接口。
|
||||
</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">
|
||||
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
|
||||
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.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={intervalDraft}
|
||||
onChange={(e) => setIntervalDraft(parseFloat(e.target.value))}
|
||||
className="flex-1 h-1 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[10px] text-muted shrink-0">
|
||||
{intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isFreeTier && (
|
||||
<Card icon={Activity} title="自选股实时">
|
||||
<div className="mb-3 rounded-btn border border-accent/25 bg-accent/10 px-3 py-2 text-xs font-medium leading-snug text-accent">
|
||||
Free 档开启实时行情时自动监控「自选」页面前 5 个标的,最低 6 秒刷新。
|
||||
</div>
|
||||
{watchlistSymbols.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{watchlistSymbols.map(symbol => {
|
||||
const name = watchlistNameBySymbol.get(symbol)
|
||||
return (
|
||||
<div key={symbol} className="flex items-center justify-between rounded-btn bg-base/50 border border-border px-2 py-1.5">
|
||||
<div className="min-w-0 flex items-baseline gap-1.5">
|
||||
<span className="text-xs font-mono text-foreground">{symbol}</span>
|
||||
{name && <span className="truncate text-[11px] text-secondary">{name}</span>}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted shrink-0">自选页</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
|
||||
自选列表为空,Free 实时行情开启前请先添加自选股。
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className="text-[10px] text-muted">当前 {watchlistSymbols.length}/5 只</span>
|
||||
<Link
|
||||
to="/watchlist"
|
||||
className="px-3 py-1 rounded-btn bg-elevated text-secondary text-xs font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
管理自选
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{!isFreeTier && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!isFreeTier && (
|
||||
<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">
|
||||
{/* 连板梯队降级修正 (移至右列顶部) */}
|
||||
<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>
|
||||
|
||||
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
|
||||
飞书已实现; 微信开发中, QMT/ptrade 待定。
|
||||
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
|
||||
<Card icon={Webhook} title="推送通知">
|
||||
<p className="text-xs text-secondary mb-3">
|
||||
监控规则命中后,可把告警推送到外部。勾选渠道作为<b className="text-foreground/80">新建规则的默认推送</b>,
|
||||
单条规则仍可在编辑页独立修改。
|
||||
</p>
|
||||
|
||||
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
|
||||
<div className="space-y-2">
|
||||
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
|
||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
||||
<div
|
||||
onClick={() => setChannelOpen(o => !o)}
|
||||
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webhookDefault}
|
||||
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title="作为新建规则的默认推送渠道"
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground">飞书</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
{webhookDefault && (
|
||||
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
||||
)}
|
||||
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{feishuWebhookUrl ? '已配置' : '未配置'}
|
||||
</span>
|
||||
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{/* 飞书地址配置 — 行内展开 */}
|
||||
{channelOpen && (
|
||||
<div className="border-t border-border/60 bg-base/30 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-muted">Webhook 地址</span>
|
||||
<input
|
||||
value={feishuDraft}
|
||||
onChange={e => setFeishuDraft(e.target.value)}
|
||||
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block mt-2 space-y-1.5">
|
||||
<span className="text-[11px] text-muted">签名密钥 (可选 · 启用签名校验时填)</span>
|
||||
<input
|
||||
type="password"
|
||||
value={feishuSecretDraft}
|
||||
onChange={e => setFeishuSecretDraft(e.target.value)}
|
||||
placeholder="机器人未启用签名校验则留空"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{feishuError && (
|
||||
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={submitFeishu}
|
||||
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
|
||||
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{feishuWebhookUrl && (
|
||||
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="mt-3 text-[10px] text-muted">
|
||||
<summary className="cursor-pointer hover:text-secondary">如何获取飞书 Webhook 地址?</summary>
|
||||
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
||||
<li>打开飞书,进入目标群聊 → 群设置 → <b>群机器人</b></li>
|
||||
<li>点击「添加机器人」→ 选择「<b>自定义机器人</b>」</li>
|
||||
<li>填写机器人名称后添加,复制生成的 Webhook 地址</li>
|
||||
<li>安全设置若启用了「<b>签名校验</b>」,把密钥一并复制填到「签名密钥」框</li>
|
||||
<li>粘贴到上方输入框并保存</li>
|
||||
</ol>
|
||||
<p className="mt-1.5 pl-4 text-muted/70">
|
||||
📖 官方文档:
|
||||
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
||||
自定义机器人使用指南 ↗
|
||||
</a>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 占位渠道 — 不可点 */}
|
||||
{[
|
||||
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
|
||||
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
|
||||
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
|
||||
].map(ch => (
|
||||
<div
|
||||
key={ch.name}
|
||||
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
|
||||
>
|
||||
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
|
||||
<span className="text-[11px] text-secondary">{ch.name}</span>
|
||||
<span className="text-[9px] text-muted">{ch.hint}</span>
|
||||
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</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,270 @@
|
||||
/**
|
||||
* 系统设置面板 — 全局行为开关。
|
||||
*
|
||||
* 独立于实时监控, 放置影响整体应用行为的开关项。
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
|
||||
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">
|
||||
<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/tickflow-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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ===== 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