项目目录从 refer 迁移到 local

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-04 16:32:48 +08:00
parent 648a8b7f1c
commit aad34202f1
302 changed files with 0 additions and 0 deletions
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Bell, TrendingUp, TrendingDown, X } from 'lucide-react'
import type { AlertEvent } from '@/lib/api'
import { fmtPct, fmtPrice } from '@/lib/format'
import { cn } from '@/lib/cn'
import { playNotificationSound } from '@/lib/notificationSound'
// ===== 全局状态 (模块级, 仿 Toast.tsx 模式) =====
type Item = { id: number; alert: AlertEvent }
let _id = 0
let _queue: Item[] = []
const AUTO_DISMISS = 5000 // 5 秒自动消失
const _listeners: Set<(items: Item[]) => void> = new Set()
/** 从 localStorage 读取配置 */
function getEnabled(): boolean {
try {
const v = localStorage.getItem('alert_toast_enabled')
return v === null ? true : v === '1' // 默认开启
} catch { return true }
}
function getMaxVisible(): number {
try {
const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10)
return v >= 1 && v <= 10 ? v : 3 // 默认 3, 范围 1-10
} catch { return 3 }
}
/** 通知外部配置变更后刷新 (设置页改了配置后调用) */
export function refreshAlertToastConfig() {
_emit()
}
function _emit() { _listeners.forEach(fn => fn([..._queue])) }
/** 推入单条监控告警通知 (兼容入口, 不发声 — 发声由批量入口统一处理) */
export function pushAlertToast(alert: AlertEvent) {
pushAlertToasts([alert])
}
/**
* 批量推入监控告警通知 (一轮 SSE 多只新命中时调用)。
* - 每条都弹 Toast (受 maxVisible 上限, 超出丢最旧)
* - 整批只播放一声通知音, 避免短时连续响多声刷屏
*/
export function pushAlertToasts(alerts: AlertEvent[]) {
if (alerts.length === 0) return
if (!getEnabled()) return // 开关关闭: 不弹
const maxVisible = getMaxVisible()
const newItems = alerts.map(alert => ({ id: ++_id, alert }))
_queue = [..._queue, ...newItems]
// 超出上限: 丢弃最旧的
if (_queue.length > maxVisible) {
_queue = _queue.slice(-maxVisible)
}
_emit()
for (const item of newItems) {
setTimeout(() => dismiss(item.id), AUTO_DISMISS)
}
playNotificationSound() // 整批只响一声
}
/** 手动关闭 */
export function dismiss(id: number) {
_queue = _queue.filter(t => t.id !== id)
_emit()
}
// ===== 配色 =====
const SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent', warn: 'bg-warning', critical: 'bg-danger',
}
const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
strategy: { label: '策略', cls: 'bg-amber-400/15 text-amber-400' },
signal: { label: '信号', cls: 'bg-accent/15 text-accent' },
price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' },
market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' },
new_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' },
dropped: { label: '移出', cls: 'bg-danger/15 text-danger' },
}
// ===== 容器 — 挂在 Layout =====
export function AlertToastContainer() {
const [items, setItems] = useState<Item[]>([])
const navigate = useNavigate()
const sub = useCallback(() => {
_listeners.add(setItems)
return () => { _listeners.delete(setItems) }
}, [])
useEffect(sub, [sub])
// 点击通知 → 跳转监控中心 + 关闭当前通知
const handleClick = (id: number) => {
dismiss(id)
navigate('/monitor')
}
if (!items.length) return null
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 w-[320px] pointer-events-none">
<AnimatePresence>
{items
.filter(item => !(item.alert.source === 'strategy' && !item.alert.symbol))
.map(item => {
const ev = item.alert
const sev = SEVERITY_BAR[ev.severity ?? 'info'] ?? SEVERITY_BAR.info
const badgeKey = (ev.source === 'strategy' && ev.type) ? ev.type : ev.source
const badge = SOURCE_BADGE[badgeKey] ?? { label: badgeKey, cls: 'bg-elevated text-muted' }
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={item.id}
layout
initial={{ opacity: 0, x: 60, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 60, scale: 0.9 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
onClick={() => handleClick(item.id)}
className="pointer-events-auto relative overflow-hidden rounded-xl border border-border/60 bg-surface/95 backdrop-blur-md shadow-2xl pl-3 pr-2 py-2.5 cursor-pointer hover:border-accent/40 hover:shadow-accent/10 transition-all"
>
{/* 左侧色条 */}
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 顶行: 分类标签 + 代码/名称 + 涨跌幅 + 关闭 */}
<div className="flex items-center gap-2">
<span className={cn('shrink-0 rounded px-1 py-px text-[9px] font-medium', badge.cls)}>
{badge.label}
</span>
{ev.symbol && <span className="font-mono text-xs font-medium text-foreground shrink-0">{ev.symbol}</span>}
{ev.name && <span className="text-xs text-secondary truncate flex-1">{ev.name}</span>}
{ev.change_pct != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[10px] font-mono font-medium shrink-0', pct >= 0 ? 'text-danger' : 'text-bear')}>
{pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPct(pct)}
</span>
)}
<button onClick={(e) => { e.stopPropagation(); dismiss(item.id) }} className="shrink-0 p-0.5 rounded text-muted/50 hover:text-foreground hover:bg-elevated transition-colors cursor-pointer">
<X className="h-3 w-3" />
</button>
</div>
{/* 底行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
<div className="mt-1 flex items-center gap-1.5 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
<span className={cn('text-[11px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[11px] text-foreground/70"></span>
<span className="text-[11px] font-medium text-amber-400">{sname}</span>
<span className="flex-1" />
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
</div>
) : (
<div className="mt-1 flex items-center gap-1.5 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
{/* message 已含「条件摘要 · 现价 · 涨跌幅」(后端生成), 直接展示避免重复 */}
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
</div>
)}
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,154 @@
import { useEffect, useRef } from 'react'
import {
createChart,
CrosshairMode,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type HistogramData,
} from 'lightweight-charts'
export interface OHLC {
date: string
open: number
high: number
low: number
close: number
volume?: number
}
export function fmtBigNum(v: number): string {
if (v >= 1_000_000_000_000) return `${(v / 1_000_000_000_000).toFixed(2)}万亿`
if (v >= 100_000_000) return `${(v / 100_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
return v.toFixed(0)
}
const THEME = {
background: 'transparent',
textColor: '#A1A1AA',
gridColor: 'rgba(255,255,255,0.04)',
borderColor: '#27272A',
bull: '#F04438',
bear: '#12B76A',
volBull: 'rgba(240,68,56,0.4)',
volBear: 'rgba(18,183,106,0.4)',
}
interface Props {
data: OHLC[]
height?: number
}
export function CandlestickChart({ data, height = 480 }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const volRef = useRef<ISeriesApi<'Histogram'> | null>(null)
useEffect(() => {
if (!containerRef.current) return
const el = containerRef.current
const chart = createChart(el, {
width: el.clientWidth,
height,
layout: {
background: { color: THEME.background },
textColor: THEME.textColor,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 11,
},
grid: {
vertLines: { color: THEME.gridColor },
horzLines: { color: THEME.gridColor },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { labelVisible: false },
horzLine: { labelVisible: false },
},
rightPriceScale: { borderColor: THEME.borderColor },
timeScale: {
borderColor: THEME.borderColor,
timeVisible: false,
secondsVisible: false,
},
})
// Candlestick — occupies top 80% via default price scale
const candle = chart.addCandlestickSeries({
upColor: THEME.bull,
downColor: THEME.bear,
borderUpColor: THEME.bull,
borderDownColor: THEME.bear,
wickUpColor: THEME.bull,
wickDownColor: THEME.bear,
lastValueVisible: false,
priceLineVisible: false,
})
// Volume — separate price scale, squeezed to bottom 20%
const volume = chart.addHistogramSeries({
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
lastValueVisible: false,
priceLineVisible: false,
})
chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.8, bottom: 0 },
})
chartRef.current = chart
candleRef.current = candle
volRef.current = volume
const ro = new ResizeObserver(() => {
chart.applyOptions({ width: el.clientWidth })
})
ro.observe(el)
return () => {
ro.disconnect()
chart.remove()
chartRef.current = null
candleRef.current = null
volRef.current = null
}
}, [height])
useEffect(() => {
if (!chartRef.current || !candleRef.current || !volRef.current || data.length === 0) return
candleRef.current.setData(
data.map(d => ({
time: d.date as any,
open: d.open,
high: d.high,
low: d.low,
close: d.close,
})) as CandlestickData[],
)
volRef.current.setData(
data.map(d => ({
time: d.date as any,
value: d.volume ?? 0,
color: d.close >= d.open ? THEME.volBull : THEME.volBear,
})) as HistogramData[],
)
const ts = chartRef.current.timeScale()
if (data.length > 60) {
const startIdx = data.length - 60
ts.setVisibleRange({
from: data[startIdx].date as any,
to: data[data.length - 1].date as any,
})
} else {
ts.fitContent()
}
}, [data])
return <div ref={containerRef} className="w-full" style={{ height }} />
}
@@ -0,0 +1,24 @@
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns'
interface ColumnCustomizerProps {
columns: ColumnConfig[]
onChange: (columns: ColumnConfig[]) => void
open: boolean
onClose: () => void
}
export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCustomizerProps) {
return (
<ListColumnCustomizer
columns={columns}
groups={COLUMN_GROUPS}
onChange={onChange}
open={open}
onClose={onClose}
title="自定义列"
builtinSectionLabel="内置列"
extColumnAlign="right"
/>
)
}
@@ -0,0 +1,235 @@
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
interface DatePickerProps {
value: string // YYYY-MM-DD
onChange: (v: string) => void
min?: string
max?: string
placeholder?: string
className?: string
buttonClassName?: string
align?: 'left' | 'right'
}
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
function pad(n: number) { return String(n).padStart(2, '0') }
function toDateStr(y: number, m: number, d: number) {
return `${y}-${pad(m + 1)}-${pad(d)}`
}
function todayStr() {
const date = new Date()
return toDateStr(date.getFullYear(), date.getMonth(), date.getDate())
}
function viewDate(value: string, min?: string, max?: string) {
const source = value || max || min || todayStr()
return {
year: Number(source.slice(0, 4)),
month: Number(source.slice(5, 7)) - 1,
}
}
export function DatePicker({
value,
onChange,
min,
max,
placeholder = '选择日期',
className = '',
buttonClassName = '',
align = 'right',
}: DatePickerProps) {
const [open, setOpen] = useState(false)
const [showYearPicker, setShowYearPicker] = useState(false)
const ref = useRef<HTMLDivElement>(null)
// 当前显示的月份
const [viewYear, setViewYear] = useState(() => viewDate(value, min, max).year)
const [viewMonth, setViewMonth] = useState(() => viewDate(value, min, max).month)
// 当 value 外部变化时同步 view
useEffect(() => {
const next = viewDate(value, min, max)
setViewYear(next.year)
setViewMonth(next.month)
}, [value, min, max])
// 点击外部关闭
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1) }
else setViewMonth(viewMonth - 1)
}
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1) }
else setViewMonth(viewMonth + 1)
}
// 构建日历格子: 周一为第一天
const firstDay = new Date(viewYear, viewMonth, 1).getDay()
const offset = firstDay === 0 ? 6 : firstDay - 1 // 周一=0
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate()
const cells: { day: number; cur: boolean; dateStr: string; disabled: boolean }[] = []
// 上月尾部
for (let i = offset - 1; i >= 0; i--) {
const d = prevMonthDays - i
const m = viewMonth === 0 ? 11 : viewMonth - 1
const y = viewMonth === 0 ? viewYear - 1 : viewYear
const ds = toDateStr(y, m, d)
cells.push({ day: d, cur: false, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max })
}
// 当月
for (let d = 1; d <= daysInMonth; d++) {
const ds = toDateStr(viewYear, viewMonth, d)
cells.push({ day: d, cur: true, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max })
}
// 下月头部 — 补齐到 6 行 × 7 = 42
const remain = 42 - cells.length
for (let d = 1; d <= remain; d++) {
const m = viewMonth === 11 ? 0 : viewMonth + 1
const y = viewMonth === 11 ? viewYear + 1 : viewYear
const ds = toDateStr(y, m, d)
cells.push({ day: d, cur: false, dateStr: ds, disabled: !!min && ds < min || !!max && ds > max })
}
const displayLabel = value || placeholder
const today = todayStr()
return (
<div ref={ref} className={`relative inline-flex ${className}`}>
{/* 触发按钮 */}
<button
type="button"
onClick={() => setOpen(!open)}
className={`inline-flex items-center gap-1.5 h-7 px-2.5 rounded-input border border-border
bg-elevated hover:border-accent/50 text-xs text-foreground num
focus:outline-none focus:border-accent/60 transition-colors duration-150 cursor-pointer ${buttonClassName}`}
>
<Calendar className="h-3.5 w-3.5 text-accent" />
<span className={value ? undefined : 'text-muted'}>{displayLabel}</span>
</button>
{/* 弹出日历 */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className={`absolute ${align === 'left' ? 'left-0' : 'right-0'} top-full mt-1.5 z-50 w-[260px] rounded-card border border-border
bg-surface shadow-[0_8px_30px_rgba(0,0,0,0.4)] p-3`}
>
{/* 月份导航 */}
<div className="flex items-center justify-between mb-2">
<button
type="button"
onClick={showYearPicker ? () => setViewYear(viewYear - 12) : prevMonth}
className="p-1 rounded-btn hover:bg-elevated text-secondary hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setShowYearPicker(v => !v)}
className="text-sm font-medium text-foreground num hover:text-accent transition-colors cursor-pointer"
>
{showYearPicker
? `${viewYear - 5} - ${viewYear + 6}`
: `${viewYear}${viewMonth + 1}`
}
</button>
<button
type="button"
onClick={showYearPicker ? () => setViewYear(viewYear + 12) : nextMonth}
className="p-1 rounded-btn hover:bg-elevated text-secondary hover:text-foreground transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{showYearPicker ? (
/* 年份选择网格 */
<div className="grid grid-cols-4 gap-1">
{Array.from({ length: 12 }, (_, i) => viewYear - 5 + i).map(y => {
const isSelected = y === Number(value.slice(0, 4))
const isThisYear = y === new Date().getFullYear()
return (
<button
key={y}
type="button"
onClick={() => {
setViewYear(y)
setShowYearPicker(false)
}}
className={`h-8 text-xs rounded-btn transition-colors duration-100
${isSelected ? 'bg-accent text-white font-bold' : ''}
${isThisYear && !isSelected ? 'border border-accent/40' : ''}
${!isSelected ? 'hover:bg-elevated cursor-pointer text-foreground' : ''}
`}
>
{y}
</button>
)
})}
</div>
) : (
<>
{/* 星期头 */}
<div className="grid grid-cols-7 text-center text-[10px] text-muted mb-1">
{WEEKDAYS.map((w) => (
<div key={w}>{w}</div>
))}
</div>
{/* 日期格子 */}
<div className="grid grid-cols-7 gap-px">
{cells.map((c, i) => {
const isSelected = c.dateStr === value
const isToday = c.dateStr === today
return (
<button
key={i}
type="button"
disabled={c.disabled}
onClick={() => {
if (!c.disabled) {
onChange(c.dateStr)
setOpen(false)
}
}}
className={`
h-7 w-full text-xs rounded-btn transition-colors duration-100
${c.cur ? 'text-foreground' : 'text-muted/40'}
${isSelected ? 'bg-accent text-white font-bold' : ''}
${isToday && !isSelected ? 'border border-accent/40' : ''}
${!isSelected && !c.disabled ? 'hover:bg-elevated' : ''}
${c.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer'}
`}
>
{c.day}
</button>
)
})}
</div>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,592 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { MinuteKlineRow } from '@/lib/api'
type YMode = 'adaptive' | 'limit'
const THEME = {
line: '#3B82F6',
areaFill: 'rgba(59,130,246,0.40)',
avgLine: '#F59E0B',
refLine: 'rgba(255,255,255,0.25)',
volUp: 'rgba(240,68,56,0.6)',
volDown: 'rgba(18,183,106,0.6)',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
border: '#27272A',
}
interface Props {
data: MinuteKlineRow[]
height?: number
prevClose?: number
date?: string
symbol?: string
onPriceHover?: (price: number | null) => void
showLimitLines?: boolean
showAvgLine?: boolean
}
function fmtTime(dt: string): string {
const match = dt.match(/(\d{2}):(\d{2})/)
if (!match) return dt.slice(11, 16)
const h = (parseInt(match[1]) + 8) % 24
return `${String(h).padStart(2, '0')}:${match[2]}`
}
function computeAvgPrice(data: MinuteKlineRow[]): number[] {
// 分时均线 = 累计成交额 / 累计成交量(手→股)
const result: number[] = []
let sumAmt = 0
let sumVol = 0
for (const d of data) {
sumAmt += d.amount
sumVol += d.volume * 100
result.push(sumVol > 0 ? sumAmt / sumVol : d.close)
}
return result
}
function fmtAmt(v: number): string {
if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
return v.toFixed(0)
}
function isValidPrice(v: number | null | undefined): v is number {
return typeof v === 'number' && Number.isFinite(v) && v > 0
}
/** 生成全天分时时间刻度 9:30 ~ 11:30, 13:00 ~ 15:00, 每分钟一个点 (共242个) */
function generateFullDayTimes(): string[] {
const times: string[] = []
// 上午 9:30 ~ 11:30 (121 分钟)
for (let h = 9; h <= 11; h++) {
const startM = h === 9 ? 30 : 0
const endM = h === 11 ? 30 : 59
for (let m = startM; m <= endM; m++) {
times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
}
}
// 下午 13:00 ~ 15:00 (121 分钟)
for (let h = 13; h <= 15; h++) {
const endM = h === 15 ? 0 : 59
for (let m = 0; m <= endM; m++) {
times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
}
}
return times
}
const FULL_DAY_TIMES = generateFullDayTimes()
/** 根据 symbol 判断涨跌停幅度 (创业板/科创板 ±20%, 北交所 ±30%, 其余 ±10%) */
function getLimitPct(symbol?: string): number {
if (!symbol) return 0.10
if (symbol.endsWith('.BJ')) return 0.30 // 北交所
if (symbol.startsWith('300') || symbol.startsWith('301')) return 0.20 // 创业板
if (symbol.startsWith('688') || symbol.startsWith('689')) return 0.20 // 科创板
return 0.10
}
/** 计算实际涨跌停价 (四舍五入到2位小数) 和实际涨跌停幅度 */
function getLimitPrices(prevClose: number, symbol?: string): {
limitUp: number // 涨停价 (四舍五入)
limitDown: number // 跌停价 (四舍五入)
upPct: number // 实际涨停幅度 (如 9.97)
downPct: number // 实际跌停幅度 (如 -9.97)
} {
const pct = getLimitPct(symbol)
const rawUp = prevClose * (1 + pct)
const rawDown = prevClose * (1 - pct)
// A股涨跌停价四舍五入到分 (2位小数)
const limitUp = Math.round(rawUp * 100) / 100
const limitDown = Math.round(rawDown * 100) / 100
const upPct = (limitUp - prevClose) / prevClose * 100
const downPct = (limitDown - prevClose) / prevClose * 100
return { limitUp, limitDown, upPct, downPct }
}
function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, symbol?: string, showLimitLines = true, showAvgLine = true): EChartsOption {
// 将数据映射到全天时间轴上的正确位置
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
const highs = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
const lows = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
const avgData = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[]
const volumes = new Array(FULL_DAY_TIMES.length).fill(null) as (any | null)[]
const volNeutral = 'rgba(161,161,170,0.5)'
for (let i = 0; i < data.length; i++) {
const timeKey = fmtTime(data[i].datetime)
const idx = timeIndexMap.get(timeKey)
if (idx !== undefined) {
closes[idx] = data[i].close
highs[idx] = data[i].high
lows[idx] = data[i].low
avgData[idx] = avgPrices[i]
volumes[idx] = {
value: data[i].volume,
itemStyle: {
color: data[i].close > data[i].open ? THEME.volUp : data[i].close < data[i].open ? THEME.volDown : volNeutral,
},
}
}
}
const areaStyle: any = {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: areaColor },
{ offset: 1, color: 'rgba(0,0,0,0)' },
],
},
}
const markLineData: any[] = []
if (prevClose != null) {
markLineData.push({
yAxis: prevClose,
lineStyle: { color: THEME.refLine, type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
})
}
let yMin: number | undefined
let yMax: number | undefined
let maxDiff = 0
if (isValidPrice(prevClose) && data.length > 0) {
const priceArrays = showAvgLine ? [closes, highs, lows, avgData] : [closes, highs, lows]
for (const arr of priceArrays) {
for (const v of arr) {
if (!isValidPrice(v)) continue
const diff = Math.abs(v - prevClose)
if (diff > maxDiff) maxDiff = diff
}
}
if (showLimitLines && yMode === 'limit') {
const { limitUp, limitDown } = getLimitPrices(prevClose, symbol)
const limitDiffUp = limitUp - prevClose
const limitDiffDown = prevClose - limitDown
const limitDiff = Math.max(limitDiffUp, limitDiffDown)
// 涨跌停模式: Y 轴按实际涨跌停价
maxDiff = limitDiff
yMin = prevClose - maxDiff
yMax = prevClose + maxDiff
// 加 markLine 标注涨停价和跌停价 (仅虚线, 不显示文字)
markLineData.push(
{
yAxis: limitUp,
lineStyle: { color: 'rgba(199,64,64,0.4)', type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
},
{
yAxis: limitDown,
lineStyle: { color: 'rgba(45,155,101,0.4)', type: 'dashed', width: 1 },
label: { show: false },
symbol: 'none',
},
)
} else {
// 自适应模式: Y 轴按实际涨跌幅对称, 但不超出实际涨跌停范围
if (showLimitLines) {
const { limitUp, limitDown } = getLimitPrices(prevClose, symbol)
const limitDiff = Math.max(limitUp - prevClose, prevClose - limitDown)
maxDiff = Math.min(maxDiff, limitDiff)
}
if (!showLimitLines && maxDiff > 0) {
maxDiff *= 1.1
}
// 至少保证一个可视范围 (防止数据平时 maxDiff=0)。指数不使用涨跌停范围,最小范围要更紧,否则低波动指数会被压成横线。
const minDiff = showLimitLines ? prevClose * 0.01 : prevClose * 0.001
if (maxDiff < minDiff) maxDiff = minDiff
yMin = prevClose - maxDiff
yMax = prevClose + maxDiff
}
}
// x 轴标签: 9:30, 10:30, 11:30/13:00, 14:00, 15:00
// 11:30(idx 120) 和 13:00(idx 121) 相邻会重叠, 合并为一个标签
const xAxisLabelMap: Record<number, string> = {
0: '9:30',
60: '10:30',
120: '11:30/13:00',
181: '14:00',
241: '15:00',
}
const xAxisLabelFormatter = (_value: string, idx: number) => {
return xAxisLabelMap[idx] ?? ''
}
return {
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: 'transparent',
borderWidth: 0,
textStyle: { fontSize: 0 },
formatter: () => '',
axisPointer: {
type: 'cross',
label: {
show: true,
backgroundColor: 'rgba(39,39,42,0.9)',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
padding: [2, 5],
color: '#A1A1AA',
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
crossStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
},
},
axisPointer: {
link: [{ xAxisIndex: 'all' }],
},
grid: [
{ left: 60, right: 55, top: 24, bottom: '28%' },
{ left: 60, right: 55, top: '74%', bottom: 20 },
],
xAxis: [
{
type: 'category',
data: FULL_DAY_TIMES,
boundaryGap: false,
axisPointer: {
show: true,
lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 },
label: {
show: true,
backgroundColor: 'rgba(39,39,42,0.9)',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
padding: [2, 4],
color: '#A1A1AA',
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (params: any) => {
return params.value ?? ''
},
},
},
axisLine: { show: false },
axisLabel: {
color: THEME.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: xAxisLabelFormatter,
interval: 0,
},
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: { color: 'rgba(255,255,255,0.04)' },
},
},
{
type: 'category',
gridIndex: 1,
data: FULL_DAY_TIMES,
boundaryGap: false,
axisLine: { show: false },
axisLabel: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
],
yAxis: [
{
type: 'value',
min: yMin,
max: yMax,
interval: maxDiff || undefined,
splitArea: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: THEME.grid } },
axisPointer: {
label: {
formatter: (params: any) => {
const v = params.value
return typeof v === 'number' ? v.toFixed(2) : ''
},
},
},
axisLabel: {
color: THEME.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => v.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
...(isValidPrice(prevClose) && yMin != null && yMax != null ? [{
type: 'value' as const,
position: 'right' as const,
gridIndex: 0,
min: yMin,
max: yMax,
interval: maxDiff || undefined,
splitArea: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisPointer: {
label: {
formatter: (params: any) => {
const v = params.value
if (typeof v !== 'number') return ''
const pct = (v - prevClose) / prevClose * 100
if (Math.abs(pct) < 0.01) return '0.00%'
return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%'
},
},
},
axisLabel: {
color: THEME.text,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => {
const pct = (v - prevClose) / prevClose * 100
if (Math.abs(pct) < 0.01) return '0.00%'
return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%'
},
},
}] : []),
],
series: [
{
name: '价格',
type: 'line',
data: closes,
smooth: false,
symbol: 'none',
cursor: 'crosshair',
lineStyle: { width: 1.2, color: lineColor },
areaStyle,
connectNulls: true,
markLine: markLineData.length > 0 ? { symbol: 'none', data: markLineData, animation: false, silent: true } : undefined,
},
...(showAvgLine ? [{
name: '均价',
type: 'line' as const,
data: avgData,
smooth: false,
symbol: 'none',
cursor: 'crosshair',
lineStyle: { width: 1, color: THEME.avgLine },
connectNulls: true,
}] : []),
{
name: '成交量',
type: 'bar',
data: volumes,
xAxisIndex: 1,
yAxisIndex: 1,
cursor: 'crosshair',
},
],
}
}
export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<ECharts | null>(null)
const roRef = useRef<ResizeObserver | null>(null)
const moRef = useRef<MutationObserver | null>(null)
const dataRef = useRef(data)
dataRef.current = data
const onPriceHoverRef = useRef(onPriceHover)
onPriceHoverRef.current = onPriceHover
// 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart)
const fullDayToDataIdx = useRef<Map<number, number>>(new Map())
const [infoIdx, setInfoIdx] = useState(data.length - 1)
const [yMode, setYMode] = useState<YMode>('adaptive')
const avgPrices = useMemo(() => computeAvgPrice(data), [data])
// 分时线颜色:基于最新价 vs 昨收
const lastClose = data.length > 0 ? data[data.length - 1].close : null
const lineIsUp = lastClose != null && prevClose != null ? lastClose > prevClose : true
const lineIsFlat = lastClose != null && prevClose != null ? lastClose === prevClose : false
const lineColor = lineIsFlat ? '#A1A1AA' : lineIsUp ? '#C74040' : '#2D9B65'
const areaFill = lineIsFlat ? 'rgba(180,180,190,0.40)' : lineIsUp ? 'rgba(199,64,64,0.40)' : 'rgba(34,197,94,0.40)'
useEffect(() => {
setInfoIdx(data.length - 1)
}, [data.length])
useEffect(() => {
const el = containerRef.current
if (!el) return
let chart = chartRef.current
if (!chart) {
chart = echarts.init(el, undefined, { renderer: 'canvas' })
chartRef.current = chart
// 强制 canvas 使用十字光标,覆盖 ECharts 默认的 pointer
const forceCursor = () => {
const canvases = el.querySelectorAll('canvas')
canvases.forEach(c => { c.style.setProperty('cursor', 'crosshair', 'important') })
}
forceCursor()
// MutationObserver: ECharts 内部可能重建/修改 canvas 属性,持续强制 cursor
const mo = new MutationObserver(forceCursor)
mo.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] })
moRef.current = mo
roRef.current = new ResizeObserver(() => {
chart!.resize()
forceCursor()
})
roRef.current.observe(el)
chart.on('updateAxisPointer', (event: any) => {
const axesInfo = event.axesInfo
if (!axesInfo) return
for (const info of Object.values(axesInfo)) {
const val = (info as any)?.value
if (val == null) continue
const fullDayIdx = typeof val === 'number' ? val : -1
if (fullDayIdx >= 0) {
const dataIdx = fullDayToDataIdx.current.get(fullDayIdx) ?? -1
setInfoIdx(dataIdx)
const d = dataRef.current
if (dataIdx >= 0 && dataIdx < d.length) {
onPriceHoverRef.current?.(d[dataIdx].close)
}
return
}
}
})
chart.on('globalout', () => {
onPriceHoverRef.current?.(null)
})
}
if (data.length > 0) {
// 构建全日索引 → 数据索引 的映射
const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i]))
const mapping = new Map<number, number>()
for (let i = 0; i < data.length; i++) {
const timeKey = fmtTime(data[i].datetime)
const fullDayIdx = timeIndexMap.get(timeKey)
if (fullDayIdx !== undefined) {
mapping.set(fullDayIdx, i)
}
}
fullDayToDataIdx.current = mapping
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine), true)
} else {
chart.clear()
}
}, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine])
useEffect(() => {
return () => {
chartRef.current?.off('updateAxisPointer')
chartRef.current?.off('globalout')
moRef.current?.disconnect()
roRef.current?.disconnect()
chartRef.current?.dispose()
chartRef.current = null
moRef.current = null
roRef.current = null
}
}, [])
const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
const avg = d != null ? avgPrices[infoIdx] : null
const chg = d && prevClose != null ? d.close - prevClose : null
const isUp = chg != null ? chg > 0 : true
const isFlat = chg != null ? chg === 0 : false
const priceClr = isFlat ? '#A1A1AA' : isUp ? '#C74040' : '#2D9B65'
return (
<div className="w-full">
{/* 按钮行: 切换式按钮组, 居右 */}
{showLimitLines && <div className="flex items-center justify-end px-1 pb-0.5">
<div className="inline-flex items-center rounded bg-elevated overflow-hidden">
<button
onClick={() => setYMode('adaptive')}
className={`px-2.5 py-0.5 text-[10px] font-mono cursor-pointer transition-colors ${
yMode === 'adaptive'
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary'
}`}
>
</button>
<div className="w-px h-3 bg-border/40" />
<button
onClick={() => setYMode('limit')}
className={`px-2.5 py-0.5 text-[10px] font-mono cursor-pointer transition-colors ${
yMode === 'limit'
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary'
}`}
>
</button>
</div>
</div>}
<div style={{ backgroundColor: 'rgba(39,39,42,0.6)' }}>
{/* 第一行: 日期 + OHLC */}
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ height: 20 }}>
{!d && <span className="text-muted"></span>}
{d && (
<>
{date && <span className="text-muted">{date}</span>}
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.open.toFixed(2)}</span>
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.high.toFixed(2)}</span>
<span className="text-muted"></span>
<span style={{ color: priceClr }}>{d.low.toFixed(2)}</span>
<span className="text-muted"></span>
<span style={{ color: priceClr }} className="font-semibold">{d.close.toFixed(2)}</span>
</>
)}
</div>
{/* 第二行: 价格+均价+量+额 */}
<div className="flex items-center gap-x-4 px-2 font-mono text-[11px] select-none" style={{ height: 20 }}>
{d && (
<>
<span className="flex items-center gap-x-1">
<span style={{ display: 'inline-block', width: 14, height: 2, background: priceClr }} />
<span style={{ color: priceClr }}>{d.close.toFixed(2)}</span>
</span>
{showAvgLine && <span className="flex items-center gap-x-1">
<span style={{ display: 'inline-block', width: 14, height: 2, background: THEME.avgLine }} />
<span style={{ color: THEME.avgLine }}>{avg?.toFixed(2)}</span>
</span>}
<span className="text-muted"></span>
<span className="text-secondary">{d.volume.toFixed(0)}</span>
<span className="text-muted"></span>
<span className="text-secondary">{fmtAmt(d.amount)}</span>
</>
)}
</div>
</div>
<div ref={containerRef} className="w-full" style={{ height: height - 42, cursor: 'crosshair' }} />
</div>
)
}
@@ -0,0 +1,20 @@
import { type LucideIcon, Construction } from 'lucide-react'
interface Props {
icon?: LucideIcon
title: string
hint?: string
}
// §6.0.5 四态评审 — empty 状态:图示 + 引导,而不是一句"暂无数据"
export function EmptyState({ icon: Icon = Construction, title, hint }: Props) {
return (
<div className="h-full grid place-items-center px-8 py-16">
<div className="text-center max-w-md">
<Icon className="mx-auto h-10 w-10 text-muted" strokeWidth={1.5} />
<h2 className="mt-4 text-base font-medium text-foreground">{title}</h2>
{hint && <p className="mt-2 text-sm text-secondary leading-relaxed">{hint}</p>}
</div>
</div>
)
}
@@ -0,0 +1,263 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react'
import { api, type EndpointItem } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { EXPERT_RANK, tierRank } from '@/lib/capability-labels'
interface EpResult {
ok: boolean
median_ms?: number | null
min_ms?: number | null
max_ms?: number | null
rounds?: number
success?: number
error?: string
}
export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) {
const qc = useQueryClient()
const [results, setResults] = useState<Record<string, EpResult | null>>({})
const [testing, setTesting] = useState<Record<string, boolean>>({})
const [switching, setSwitching] = useState<string | null>(null)
// 动态加载端点清单 —— 前端无法跨域直连 tickflow.org,走后端代理
const { data, isLoading } = useQuery({
queryKey: QK.endpoints,
queryFn: api.listEndpoints,
staleTime: 5 * 60 * 1000,
})
const endpoints = data?.endpoints ?? []
const isFallback = data?.source === 'fallback'
const testRounds = data?.testRounds
async function testOne(url: string) {
setTesting(prev => ({ ...prev, [url]: true }))
setResults(prev => ({ ...prev, [url]: null }))
try {
const res = await api.testEndpoint(url, testRounds)
setResults(prev => ({ ...prev, [url]: res }))
} catch (e: any) {
setResults(prev => ({ ...prev, [url]: { ok: false, error: e?.message ?? '请求失败' } }))
} finally {
setTesting(prev => ({ ...prev, [url]: false }))
}
}
async function testAll() {
setResults({})
await Promise.all(endpoints.map(ep => testOne(ep.url)))
}
const anyTesting = Object.values(testing).some(Boolean)
const isFree = !hasKey
// 专线端点需 Expert 及以上套餐;Free 模式必然不可用
const canUsePremium = !isFree && tierRank(tierLabel) >= EXPERT_RANK
const currentLabel = endpoints.find(ep => ep.url === currentEndpoint)?.label ?? currentEndpoint
async function applyEndpoint(url: string) {
setSwitching(url)
try {
await api.switchEndpoint(url)
await qc.invalidateQueries({ queryKey: QK.settings })
onClose()
} catch {
// 错误由 query 处理
} finally {
setSwitching(null)
}
}
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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 w-[480px] max-h-[90vh] flex flex-col rounded-card border border-border bg-base shadow-2xl overflow-hidden"
>
{/* 顶栏 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="flex items-center gap-3">
{isFree && (
<span className="text-[10px] px-2 py-0.5 rounded bg-warning/10 text-warning/80">Free </span>
)}
<button
onClick={testAll}
disabled={isFree || anyTesting || endpoints.length === 0}
title={isFree ? 'Free 模式不可使用付费端点,请先配置 API Key' : undefined}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 disabled:opacity-50 transition-colors"
>
{anyTesting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
</button>
<button onClick={onClose} className="p-1 rounded text-secondary hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 当前使用 */}
<div className="mx-4 mt-3 px-3 py-2 rounded-btn bg-accent/8 border border-accent/20 flex items-center gap-2 shrink-0">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_rgba(61,214,140,0.5)]" />
<span className="text-[11px] text-secondary">使</span>
<span className="text-[11px] font-medium text-foreground">{currentLabel}</span>
<span className="text-[10px] text-muted font-mono ml-auto">{currentEndpoint.replace('https://', '')}</span>
</div>
{/* Free 模式提示 —— 以下均为 Starter+ 付费端点 */}
{isFree && (
<div className="mx-4 mt-2 px-3 py-1.5 rounded-btn bg-warning/8 border border-warning/20 shrink-0">
<span className="text-[10px] text-warning/80 leading-snug">
Starter+ Free 使 API Key
</span>
</div>
)}
{/* 端点列表 —— 可滚动区,顶栏/当前使用/底栏始终可见 */}
<div className="p-4 space-y-2 overflow-y-auto min-h-0">
{isLoading ? (
<div className="py-10 flex items-center justify-center gap-2 text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs"></span>
</div>
) : endpoints.length === 0 ? (
<div className="py-10 text-center text-xs text-muted"></div>
) : (
endpoints.map(ep => (
<EpRow key={ep.url} ep={ep} result={results[ep.url]} testing={testing[ep.url]} isCurrent={ep.url === currentEndpoint} isFree={isFree} canUsePremium={canUsePremium} switching={switching} onApply={applyEndpoint} />
))
)}
</div>
{isFallback ? (
<span className="text-[10px] text-warning/70"></span>
) : null}
</motion.div>
</div>
</AnimatePresence>
)
}
function EpRow({ ep, result, testing, isCurrent, isFree, canUsePremium, switching, onApply }: {
ep: EndpointItem
result: EpResult | null
testing?: boolean
isCurrent?: boolean
isFree?: boolean
canUsePremium?: boolean
switching: string | null
onApply: (url: string) => void
}) {
const isError = result && !result.ok
const canApply = result?.ok && !testing && !isCurrent
const isPremium = ep.premium === true
const median = result?.median_ms
return (
<div
className={`flex items-start gap-3 px-3 py-2.5 rounded-btn border transition-colors ${
isError
? 'border-danger/30 bg-danger/5'
: result?.ok
? 'border-bear/20 bg-bear/5'
: isCurrent
? 'border-accent/20 bg-accent/5'
: 'border-border bg-surface'
}`}
>
<div className="flex-1 min-w-0">
{/* 第1行:label + 徽章(左) / 中位延迟(右) */}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-xs font-medium text-foreground">{ep.label}</span>
{isPremium && (
<span className="inline-flex items-center gap-0.5 text-[9px] px-1.5 py-px rounded-sm bg-warning/15 text-warning font-medium" title={ep.description ?? '需专线加速权限'}>
<Crown className="h-2.5 w-2.5" />
线
</span>
)}
{isCurrent && (
<span className="text-[9px] px-1.5 py-px rounded-sm bg-accent/15 text-accent font-medium">使</span>
)}
</div>
{/* 中位延迟 —— 测试中/前/后都占位,避免高度跳动 */}
<span className="ml-auto shrink-0 text-sm font-mono font-medium tabular-nums leading-none">
{isFree ? (
// Free 模式:普通付费端点需 Starter+,premium 端点需 Expert+
<span
className={`text-[10px] px-1.5 py-0.5 rounded-sm font-medium font-sans ${
isPremium
? 'bg-warning/15 text-warning'
: 'bg-muted/10 text-muted/70'
}`}
title={isPremium ? '需 Expert 及以上套餐' : '需 Starter+ 套餐'}
>
{isPremium ? 'Expert+' : 'Starter+'}
</span>
) : testing ? (
<span className="text-[11px] text-muted animate-pulse"></span>
) : result && result.ok && median != null ? (
<span className={median < 500 ? 'text-bear' : median < 1000 ? 'text-warning' : 'text-danger'}>
{median} ms
</span>
) : result && !result.ok ? (
<span className="text-[11px] text-danger">{result.error ?? '不可达'}</span>
) : (
<span className="text-[11px] text-muted/40"></span>
)}
</span>
</div>
{/* 第2行:description */}
<span className="block text-[10px] text-muted/70 leading-snug mt-0.5 break-words">{ep.description}</span>
{/* 第3行:URL(左) / min~max·成功率(右) —— 副信息始终占位,行数不变 */}
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted/50 font-mono truncate min-w-0" title={ep.url}>{ep.url.replace('https://', '')}</span>
<span className="ml-auto shrink-0 text-[9px] text-muted/50 font-mono whitespace-nowrap">
{result && result.ok && result.min_ms != null
? `${result.min_ms}~${result.max_ms} · ${result.success}/${result.rounds}`
: '\u00A0'}
</span>
</div>
</div>
{/* 应用按钮区域 —— Free 模式不可用任何付费端点;专线端点需 Expert+ */}
{isFree ? null : (isPremium && !canUsePremium) ? (
// 专线端点:需 Expert 及以上套餐权限,当前套餐不足,不可应用
<span
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-warning/10 text-warning/70 cursor-not-allowed select-none mt-0.5"
title="需要 Expert 及以上套餐的专线加速权限"
>
<Crown className="h-3 w-3" />
Expert+
</span>
) : canApply ? (
<button
onClick={() => onApply(ep.url)}
disabled={switching !== null}
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50 transition-colors mt-0.5"
>
{switching === ep.url ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</button>
) : null}
</div>
)
}
@@ -0,0 +1,436 @@
import { useMemo, useState, type ComponentType } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3,
CalendarDays,
Database,
Layers3,
Search,
Tags,
TrendingUp,
Users,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api, type AnalysisColumn, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
interface DimensionAnalysisProps {
menuId?: string
title?: string
subtitle?: string
kindLabel?: string
emptyHint?: string
accentClass?: string
keywords?: string[]
fallbackFieldNames?: string[]
}
interface DimensionGroup {
key: string
count: number
rows: Record<string, any>[]
metrics: Record<string, number | null>
}
const PAGE_LIMIT = 5000
const SEPARATORS = /[、,;|/\s]+/
function normalizeText(v: unknown): string {
if (v == null) return ''
return String(v).trim()
}
function fieldLabel(fields: ExtDataField[], name: string): string {
return fields.find(f => f.name === name)?.label || name
}
function isNumericField(f: ExtDataField) {
return f.dtype === 'int' || f.dtype === 'float'
}
function scoreConfig(config: ExtDataConfig, keywords: string[]) {
const haystack = [
config.id,
config.label,
config.description ?? '',
...config.fields.flatMap(f => [f.name, f.label]),
].join(' ').toLowerCase()
return keywords.reduce((n, k) => n + (haystack.includes(k.toLowerCase()) ? 1 : 0), 0)
}
function pickDefaultConfig(configs: ExtDataConfig[], keywords: string[]) {
const ranked = [...configs].sort((a, b) => scoreConfig(b, keywords) - scoreConfig(a, keywords))
return ranked[0]?.id ?? ''
}
function pickDefaultDimensionField(config: ExtDataConfig | undefined, fallbackFieldNames: string[]) {
if (!config) return ''
const fields = config.fields.filter(f => f.name !== 'symbol' && f.name !== 'code')
for (const name of fallbackFieldNames) {
const matched = fields.find(f => f.name.toLowerCase().includes(name) || f.label.toLowerCase().includes(name))
if (matched) return matched.name
}
return fields.find(f => !isNumericField(f))?.name ?? fields[0]?.name ?? ''
}
function formatNumber(v: unknown, digits = 2) {
if (typeof v !== 'number' || !Number.isFinite(v)) return '—'
return v.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function formatValue(v: unknown, col?: AnalysisColumn) {
if (v == null || v === '') return '—'
if (typeof v === 'boolean') return v ? '是' : '否'
if (typeof v !== 'number') return String(v)
const digits = col?.precision ?? (col?.type === 'number' || col?.type === 'amount' || col?.type === 'percent' ? 2 : 2)
if (col?.type === 'percent') return `${formatNumber(v, digits)}%`
if (col?.type === 'amount' || col?.format === 'amount') return formatNumber(v, digits)
return formatNumber(v, digits)
}
function splitDimensionValues(value: unknown) {
const text = normalizeText(value)
if (!text) return []
return text.split(SEPARATORS).map(s => s.trim()).filter(Boolean)
}
function StatCard({ label, value, hint, icon: Icon }: {
label: string
value: string | number
hint: string
icon: ComponentType<{ className?: string }>
}) {
return (
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted">{label}</span>
<Icon className="h-4 w-4 text-secondary" />
</div>
<div className="mt-3 text-2xl font-semibold tracking-tight text-foreground">{value}</div>
<div className="mt-1 text-[11px] text-muted">{hint}</div>
</div>
)
}
function columnsFromFields(fields: ExtDataField[], dimensionField: string, rows: Record<string, any>[]) {
const numericFields = fields.filter(f => isNumericField(f) && rows.some(r => typeof r[f.name] === 'number')).slice(0, 4)
return [
...fields.filter(f => ['symbol', 'code', 'name', '股票简称', '股票代码'].includes(f.name)),
...fields.filter(f => f.name === dimensionField),
...numericFields,
]
.filter((f, i, arr) => arr.findIndex(x => x.name === f.name) === i)
.map<AnalysisColumn>(f => ({
field: f.name,
label: f.label || f.name,
type: isNumericField(f) ? 'number' : 'string',
precision: f.dtype === 'float' ? 2 : null,
sortable: isNumericField(f),
visible: true,
}))
}
function aggregate(rows: Record<string, any>[], col: AnalysisColumn) {
if (col.field === '__count') return rows.length
const values = rows.map(r => r[col.field]).filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
if (!values.length) return null
switch (col.aggregate) {
case 'sum': return values.reduce((a, b) => a + b, 0)
case 'min': return Math.min(...values)
case 'max': return Math.max(...values)
case 'avg':
default:
return values.reduce((a, b) => a + b, 0) / values.length
}
}
export function ExtDimensionAnalysis({
menuId,
title,
subtitle,
kindLabel = '维度',
emptyHint = '还没有可用于分析的扩展数据。',
accentClass = 'bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.16),transparent_36%)]',
keywords = [],
fallbackFieldNames = [],
}: DimensionAnalysisProps) {
const configs = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const menuQuery = useQuery({
queryKey: QK.analysisMenu(menuId ?? ''),
queryFn: () => api.analysisMenu(menuId!),
enabled: !!menuId,
})
const menu = menuQuery.data
const [selectedConfigId, setSelectedConfigId] = useState('')
const [dimensionField, setDimensionField] = useState('')
const [search, setSearch] = useState('')
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
const availableConfigs = configs.data?.items ?? []
const activeConfigId = menu?.data_source || selectedConfigId || pickDefaultConfig(availableConfigs, keywords)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const activeDimensionField = menu?.dimension_field || dimensionField || pickDefaultDimensionField(activeConfig, fallbackFieldNames)
const activeTitle = menu?.label || title || '扩展分析'
const activeKindLabel = menu?.template === 'table' ? '数据' : kindLabel
const configuredColumns = useMemo(() => {
const cols = menu?.detail_columns?.filter(c => c.visible !== false) ?? []
return cols.length > 0 ? cols : null
}, [menu?.detail_columns])
const requestedColumns = useMemo(() => {
const cols = [activeDimensionField, ...(configuredColumns?.map(c => c.field) ?? [])].filter(Boolean)
return Array.from(new Set(cols))
}, [activeDimensionField, configuredColumns])
const columnsKey = requestedColumns.join(',')
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT, columnsKey),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT, columns: requestedColumns }),
enabled: !!activeConfigId,
})
const rows = rowsQuery.data?.rows ?? []
const baseFields = activeConfig?.fields ?? rowsQuery.data?.fields ?? []
const fields = rows.some(r => r.name != null) && !baseFields.some(f => f.name === 'name')
? [...baseFields, { name: 'name', dtype: 'string', label: '名称' }]
: baseFields
const dimensionOptions = fields.filter(f => f.name !== 'symbol' && f.name !== 'code')
const displayColumns = configuredColumns ?? columnsFromFields(fields, activeDimensionField, rows)
const groupColumns = (menu?.group_columns?.length ? menu.group_columns : [
{ field: '__dimension', label: activeKindLabel },
{ field: '__count', label: '股票数', type: 'number' as const, sortable: true },
]).filter(c => c.visible !== false)
const sortedRows = useMemo(() => {
if (!menu?.default_sort?.field) return rows
const factor = menu.default_sort.order === 'asc' ? 1 : -1
return [...rows].sort((a, b) => {
const av = a[menu.default_sort!.field]
const bv = b[menu.default_sort!.field]
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor
return String(av ?? '').localeCompare(String(bv ?? '')) * factor
})
}, [rows, menu?.default_sort])
const groups = useMemo<DimensionGroup[]>(() => {
if (!activeDimensionField || menu?.template === 'table' || menu?.template === 'ranking') return []
const map = new Map<string, Record<string, any>[]>()
for (const row of sortedRows) {
const values = splitDimensionValues(row[activeDimensionField])
for (const value of values) {
const item = map.get(value) ?? []
item.push(row)
map.set(value, item)
}
}
return [...map.entries()]
.map(([key, itemRows]) => ({
key,
count: itemRows.length,
rows: itemRows,
metrics: Object.fromEntries(groupColumns.map(col => [col.field, aggregate(itemRows, col)])),
}))
.sort((a, b) => b.count - a.count)
}, [sortedRows, activeDimensionField, groupColumns, menu?.template])
const filteredGroups = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return groups
return groups.filter(g => g.key.toLowerCase().includes(q))
}, [groups, search])
const currentGroup = filteredGroups.find(g => g.key === selectedGroup) ?? filteredGroups[0]
const tableRows = menu?.template === 'table' || menu?.template === 'ranking' ? sortedRows : (currentGroup?.rows ?? [])
const coveredSymbols = useMemo(() => {
const set = new Set<string>()
rows.forEach(r => { if (r.symbol) set.add(String(r.symbol)) })
return set.size
}, [rows])
const missingConfiguredColumns = displayColumns.filter(c => rows.length > 0 && !Object.prototype.hasOwnProperty.call(rows[0], c.field))
return (
<>
<PageHeader
title={activeTitle}
subtitle={menu ? `${menu.template} · ${menu.data_source}` : subtitle}
right={
<div className="flex items-center gap-2">
<select
value={activeConfigId}
onChange={(e) => { setSelectedConfigId(e.target.value); setDimensionField(''); setSelectedGroup(null) }}
className="h-8 min-w-40 rounded-btn border border-border bg-surface px-2 text-xs text-foreground focus:outline-none focus:border-accent/50"
>
{availableConfigs.length === 0 ? (
<option value=""></option>
) : availableConfigs.map(config => (
<option key={config.id} value={config.id}>{config.label}</option>
))}
</select>
<select
value={activeDimensionField}
onChange={(e) => { setDimensionField(e.target.value); setSelectedGroup(null) }}
disabled={!activeConfig}
className="h-8 min-w-36 rounded-btn border border-border bg-surface px-2 text-xs text-foreground disabled:opacity-50 focus:outline-none focus:border-accent/50"
>
{dimensionOptions.length === 0 ? (
<option value=""></option>
) : dimensionOptions.map(field => (
<option key={field.name} value={field.name}>{field.label || field.name}</option>
))}
</select>
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-7xl">
<section className={`relative overflow-hidden rounded-2xl border border-border bg-surface p-6 ${accentClass}`}>
<div className="relative z-10 max-w-3xl">
<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">
<Layers3 className="h-3.5 w-3.5" />
· ·
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-foreground">{activeTitle}</h2>
<p className="mt-2 text-sm leading-6 text-secondary">
使 detail_columns
</p>
</div>
<div className="absolute right-8 top-6 hidden h-28 w-28 rounded-full border border-white/10 bg-white/[0.03] lg:flex items-center justify-center">
<BarChart3 className="h-12 w-12 text-white/20" />
</div>
</section>
{configs.isLoading || menuQuery.isLoading ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted"></div>
) : menuQuery.isError ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted"></div>
) : !activeConfig ? (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center">
<Database className="mx-auto h-8 w-8 text-muted" />
<div className="mt-3 text-sm text-secondary">{emptyHint}</div>
<div className="mt-1 text-xs text-muted"></div>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard label="数据源" value={activeConfig.label} hint={`${activeConfig.mode === 'snapshot' ? '快照' : '时序'} · ${activeConfig.id}`} icon={Database} />
<StatCard label="分组数量" value={groups.length || '—'} hint={activeDimensionField ? `${fieldLabel(fields, activeDimensionField)} 聚合` : '未配置分组字段'} icon={Tags} />
<StatCard label="覆盖标的" value={coveredSymbols || rows.length} hint={rowsQuery.data?.date ? `数据日期 ${rowsQuery.data.date}` : '当前快照'} icon={Users} />
<StatCard label="列表列数" value={displayColumns.length} hint={`配置列 ${displayColumns.length}`} icon={TrendingUp} />
</div>
{missingConfiguredColumns.length > 0 && (
<div className="rounded-card border border-warning/40 bg-warning/5 px-4 py-3 text-xs text-warning">
{missingConfiguredColumns.map(c => c.field).join('、')}
</div>
)}
<div className={menu?.template === 'table' || menu?.template === 'ranking' ? 'grid grid-cols-1' : 'grid grid-cols-1 xl:grid-cols-[24rem_1fr] gap-5'}>
{menu?.template !== 'table' && menu?.template !== 'ranking' && (
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground"></h3>
<p className="mt-0.5 text-[11px] text-muted"></p>
</div>
<span className="text-[10px] text-muted">Top {Math.min(filteredGroups.length, 100)}</span>
</div>
<div className="p-3 border-b border-border/60">
<div className="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) => { setSearch(e.target.value); setSelectedGroup(null) }}
placeholder={`搜索${activeKindLabel}`}
className="h-8 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="max-h-[560px] overflow-auto p-2 space-y-1">
{filteredGroups.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted"></div>
) : filteredGroups.slice(0, 100).map((group, i) => {
const active = currentGroup?.key === group.key
return (
<button
key={group.key}
onClick={() => setSelectedGroup(group.key)}
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${active ? 'bg-accent/10 border border-accent/25' : 'border border-transparent hover:bg-elevated/60'}`}
>
<div className="flex items-center gap-2">
<span className="w-5 text-[10px] font-mono text-muted">#{i + 1}</span>
<span className="flex-1 truncate text-xs font-medium text-foreground">{group.key}</span>
<span className="font-mono text-xs text-secondary">{group.count}</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-accent/70"
style={{ width: `${Math.max(6, (group.count / (filteredGroups[0]?.count || 1)) * 100)}%` }}
/>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-muted">
{groupColumns.filter(c => !['__dimension', '__count'].includes(c.field)).slice(0, 3).map(col => (
<span key={col.field}>{col.label || col.field}: {formatValue(group.metrics[col.field], col)}</span>
))}
</div>
</button>
)
})}
</div>
</section>
)}
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{menu?.template === 'table' || menu?.template === 'ranking' ? '明细列表' : currentGroup?.key ?? `选择${activeKindLabel}`}</h3>
<span className="rounded-full bg-accent/10 px-2 py-0.5 text-[10px] text-accent">{tableRows.length} </span>
</div>
<p className="mt-1 text-xs text-muted"> detail_columns{displayColumns.map(f => f.label || f.field).join(' / ') || '暂无字段'}</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted">
<CalendarDays className="h-3.5 w-3.5" />
{rowsQuery.data?.date ?? '当前快照'}
</div>
</div>
<div className="overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/50 text-[11px] text-muted">
<tr>
{displayColumns.map(col => (
<th key={col.field} className="whitespace-nowrap px-4 py-2 font-medium" style={col.width ? { width: col.width } : undefined}>{col.label || col.field}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{rowsQuery.isLoading ? (
<tr><td className="px-4 py-8 text-center text-muted" colSpan={Math.max(displayColumns.length, 1)}></td></tr>
) : tableRows.length === 0 ? (
<tr><td className="px-4 py-8 text-center text-muted" colSpan={Math.max(displayColumns.length, 1)}></td></tr>
) : tableRows.slice(0, 300).map((row, i) => (
<tr key={`${row.symbol ?? row.code ?? i}-${i}`} className="hover:bg-elevated/30">
{displayColumns.map(col => (
<td key={col.field} className={`whitespace-nowrap px-4 py-2 ${col.field === activeDimensionField ? 'max-w-[18rem] truncate text-secondary' : col.field === 'symbol' || col.field === 'code' || col.field === '股票代码' ? 'font-mono text-secondary' : 'text-foreground'}`}>
{formatValue(row[col.field], col)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{tableRows.length > 300 && (
<div className="border-t border-border px-4 py-2 text-center text-[11px] text-muted"> 300 {tableRows.length} </div>
)}
</section>
</div>
</>
)}
</div>
</>
)
}
@@ -0,0 +1,31 @@
import { Clock } from 'lucide-react'
import type { StockRef } from '@/lib/useLastStock'
/**
* "上次查看"个股胶囊 —— 显示在 PageHeader 右侧。
* 上方名称、下方代码,小字体二排;点击恢复该个股的查看。
*/
export function LastStockChip({
stock,
onSelect,
}: {
stock: StockRef | null
onSelect?: (symbol: string, name: string) => void
}) {
if (!stock) return null
return (
<button
onClick={() => onSelect?.(stock.symbol, stock.name)}
title={`继续查看 ${stock.name}`}
className="group inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-elevated/40 px-2 py-1 hover:border-border hover:bg-elevated transition-colors"
>
<Clock className="h-3 w-3 text-muted shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span className="text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors max-w-[7em] truncate">
{stock.name}
</span>
<span className="text-[9px] font-mono text-muted">{stock.symbol}</span>
</span>
</button>
)
}
+588
View File
@@ -0,0 +1,588 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
import { AiReportBubble } from '@/components/financials/AiReportBubble'
import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useVersion,
} from '@/lib/useSharedQueries'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import {
Star,
ScanSearch,
History,
FileText,
Settings,
Key,
Database,
Loader2,
LayoutDashboard,
Tags,
TrendingUp,
Flame,
BarChart3,
Sparkles,
Layers3,
Landmark,
Cable,
RadioTower,
CheckCircle2,
BookOpenCheck,
ExternalLink,
X,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { cn } from '@/lib/cn'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
const BRAND = '#8B5CF6'
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
const CORE_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
] as const
type CoreIndex = (typeof CORE_INDEXES)[number]
const nav = [
{ to: '/', label: '看板', icon: LayoutDashboard },
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
{ to: '/financials', label: '财务分析', icon: FileText },
{ to: '/monitor', label: '监控中心', icon: RadioTower },
{ to: '/review', label: '复盘', icon: BookOpenCheck },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/trading', label: '交易', icon: Cable },
{ to: '/data', label: '数据', icon: Database },
] as const
function fmtIndexValue(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(2)
}
function fmtIndexPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
}
function indexPctClass(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return 'text-muted'
const n = Number(v)
if (n === 0) return 'text-foreground'
return n > 0 ? 'text-bull' : 'text-bear'
}
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
function MonitorBadge({ active }: { active: boolean }) {
const unread = useUnreadAlerts()
// 尊重用户设置: 可在菜单设置里关闭数字提示
const badgeEnabled = (() => {
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
})()
if (active || unread <= 0 || !badgeEnabled) return null
return (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white animate-pulse">
{unread > 99 ? '99+' : unread}
</span>
)
}
function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) {
if (items.length === 0) return null
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
return (
<div className="mt-2 grid grid-cols-2 gap-1.5">
{items.map(item => {
const q = quoteBySymbol.get(item.symbol)
const value = q?.last_price ?? q?.close
const pct = q?.change_pct
return (
<NavLink
key={item.symbol}
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="block rounded bg-elevated/60 px-2 py-1.5 transition-colors hover:bg-elevated"
title={`${item.name} ${item.symbol}`}
>
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] text-secondary">{item.name}</span>
<span className={`text-[10px] font-mono ${indexPctClass(pct)}`}>{fmtIndexPct(pct)}</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10px] text-foreground/80">
{fmtIndexValue(value)}
</div>
</NavLink>
)
})}
</div>
)
}
// ===== 档位卡片 =====
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
const isNone = base === 'none'
const tierConfig: Record<string, {
desc: string
tagBg: React.CSSProperties
dotStyle: React.CSSProperties
labelTextStyle: React.CSSProperties
}> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '基础日K · 自选实时',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '批量同步 · 行情池',
tagBg: { background: 'rgba(59,130,246,0.2)' },
dotStyle: { background: '#3b82f6' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
desc: '分钟K · 实时行情 · 盘口',
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
}
const t = tierConfig[base] || tierConfig.none
// none 档显示英文「None」,无 label 时也显示「None」
const displayLabel = isNone ? 'None' : (label || 'None')
return (
<NavLink
to="/settings?tab=account"
className="mt-2.5 group block -mx-2.5"
title="API 设置"
>
<div className="relative overflow-hidden rounded-lg border border-blue-400/20 bg-gradient-to-br from-blue-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-blue-400/35 hover:from-blue-500/[0.16]">
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-blue-500/10 blur-2xl" />
<div className="relative flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-blue-400/10 text-blue-300 ring-1 ring-blue-400/20">
<Key className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">TickFlow</span>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ ...t.dotStyle, ...(base === 'expert' ? { animation: 'pulse 2s infinite' } : {}) }}
/>
</div>
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
{isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
</div>
</div>
<span
className="inline-flex h-[18px] max-w-[68px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none"
style={t.tagBg}
>
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
</span>
<Settings className="h-3 w-3 shrink-0 text-muted group-hover:text-blue-300 transition-colors" />
</div>
</div>
</NavLink>
)
}
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
return (
<NavLink
to="/settings?tab=ai"
className="mt-2 group block -mx-2.5"
title="AI 配置"
>
<div className="relative overflow-hidden rounded-lg border border-purple-400/20 bg-gradient-to-br from-purple-500/[0.12] via-surface to-surface px-3 py-2 transition-all hover:border-purple-400/35 hover:from-purple-500/[0.16]">
<div className="absolute -right-5 -top-6 h-14 w-14 rounded-full bg-purple-500/10 blur-2xl" />
<div className="relative flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-purple-400/10 text-purple-300 ring-1 ring-purple-400/20">
<Sparkles className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">AI </span>
<span className={`h-1.5 w-1.5 rounded-full ${configured ? 'bg-bear' : 'bg-warning'}`} />
</div>
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
{configured ? (model || '已接入模型') : '接入策略生成模型'}
</div>
</div>
<Settings className="h-3 w-3 text-muted group-hover:text-purple-300 transition-colors" />
</div>
</div>
</NavLink>
)
}
export function Layout() {
// ===== 共享 hooks (替代内联 useQuery) =====
const { data: caps } = useCapabilities()
const { data: settingsState } = useSettings()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences()
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
const { data: quoteStatus } = useQuoteStatus({ poll: true })
const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus,
queryFn: api.analysisMenus,
})
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
const { data: pipelineJobs } = useQuery({
queryKey: QK.pipelineJobs,
queryFn: () => api.pipelineJobs(1),
refetchInterval: (query) => (query.state.data?.active_id ? 2000 : 15000),
refetchIntervalInBackground: true,
})
const isDataSyncing = !!pipelineJobs?.active_id
// 数据同步完成的"瞬时反馈": isDataSyncing 从 true→false 时显示绿色对勾,
// 闪烁约 3 秒后自动消失。
const [dataSyncJustDone, setDataSyncJustDone] = useState(false)
const prevSyncingRef = useRef(false)
useEffect(() => {
// 仅在"刚结束"(true→false)且非首次挂载时触发
if (prevSyncingRef.current && !isDataSyncing) {
setDataSyncJustDone(true)
const t = setTimeout(() => setDataSyncJustDone(false), 3000)
prevSyncingRef.current = isDataSyncing
return () => clearTimeout(t)
}
prevSyncingRef.current = isDataSyncing
}, [isDataSyncing])
const qc = useQueryClient()
const navigate = useNavigate()
const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
const [dismissFreeHint, setDismissFreeHint] = useState(false)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
// 卡片数据:固定显示时也拉取(即使实时行情关闭)
const showSidebarQuotes = indicesPinned || realtimeEnabled
const { data: sidebarIndexQuotes } = useQuery({
queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const,
queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)),
enabled: showSidebarQuotes && sidebarIndexes.length > 0,
placeholderData: (prev) => prev,
})
// SSE: 行情更新时自动刷新相关 queries + 告警通知
useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages)
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
refetchInterval: 15000,
refetchIntervalInBackground: true,
select: (data) => data.total,
})
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
const alertsTotal = alertsTotalQuery.data
useEffect(() => {
if (alertsTotal != null) setAlertTotal(alertsTotal)
}, [alertsTotal])
// 合并内置页面 + 可见的扩展分析菜单
const analysisNav = (analysisMenus?.items ?? [])
.filter(m => m.visible)
.map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 }))
const allNav = [...nav, ...analysisNav]
const savedOrder = prefs?.nav_order ?? []
const navItems = savedOrder.length > 0
? (() => {
const byTo = new Map(allNav.map(n => [n.to, n]))
const ordered = savedOrder
.map(id => byTo.get(id) ?? byTo.get(`/analysis/${id}`))
.filter(Boolean)
const seen = new Set(ordered.map(n => n!.to))
return [...ordered as typeof allNav, ...allNav.filter(n => !seen.has(n.to))]
})()
: allNav
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
const handleToggle = async (enabled: boolean) => {
// 开启时重新校验档位
if (enabled) {
const fresh = await qc.fetchQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
const freshTier = tierRank(fresh.label ?? '')
if (freshTier < 0) return
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
if (enabled && isTrading) {
api.intradayRefresh().catch(() => {})
}
}
return (
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
<div className="px-5 py-5 border-b border-border shrink-0">
{/* Brand block — 原创 logo + 等宽 wordmark */}
<div className="flex items-center gap-2.5">
<Logo
size={28}
className="shrink-0 drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]"
style={{ color: BRAND }}
/>
<div
className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight"
style={{ textShadow: `0 0 10px ${BRAND}44` }}
>
<div>TickFlow</div>
<div>Stock Panel</div>
</div>
</div>
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
Quant · Terminal
</div>
<div
className="mt-3 h-px"
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
/>
<TierBadge
label={caps?.label ?? ''}
hasKey={settingsState?.mode !== 'none'}
/>
<AIConfigBadge
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
model={settingsState?.ai_model}
/>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
{visibleNavItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
}
>
{({ isActive }) => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
{/* 个股分析 Beta 标识 */}
{(to === '/stock-analysis' || to === '/review') && (
<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 shrink-0">
Beta
</span>
)}
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
{to === '/data' && isDataSyncing && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
)}
{to === '/data' && !isDataSyncing && dataSyncJustDone && (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
)}
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
{to === '/monitor' && <MonitorBadge active={isActive} />}
</>
)}
</NavLink>
))}
</nav>
{/* 全局行情开关 */}
<div className="border-t border-border px-3 py-2.5 shrink-0">
{isNoneTier ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Free+
</span>
</div>
<div className="mt-1.5 text-[10px] leading-snug text-muted">
<a
href={TICKFLOW_REGISTER_URL}
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
>
TickFlow
<ExternalLink className="h-2.5 w-2.5 self-center" />
</a>
</div>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
realtimeEnabled && isRunning && isTrading
? 'bg-accent animate-pulse'
: realtimeEnabled
? 'bg-warning/60'
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
· {realtimeModeLabel}
</span>
<button
onClick={() => navigate('/settings?tab=monitoring')}
className="text-secondary hover:text-foreground transition-colors shrink-0"
title="实时监控设置"
>
<Settings className="h-3 w-3" />
</button>
</div>
<button
onClick={() => handleToggle(!realtimeEnabled)}
disabled={toggleQuote.isPending}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
realtimeEnabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggleQuote.isPending ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
realtimeEnabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
)}
{/* 状态提示 */}
{realtimeEnabled && !isNoneTier && (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
title="关闭提示"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)}
{isRunning && isTrading ? (
<div className="text-accent"></div>
) : realtimeEnabled && !isTrading ? (
<div className="text-warning/70"></div>
) : null}
</div>
)}
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
<NavLink
to="/settings"
className={({ isActive }) =>
cn(
'flex items-center justify-between gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
}
>
<span className="flex items-center gap-3">
<Settings className="h-4 w-4 shrink-0" />
<span></span>
</span>
<span className="font-mono text-[10px] text-muted/50 select-none">
{version ?? ''}
</span>
</NavLink>
</div>
</aside>
<motion.main
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="h-full overflow-auto scrollbar-gutter-stable"
>
<Outlet />
</motion.main>
<ToastContainer />
<AlertToastContainer />
<AiAnalysisHost />
<AiReportBubble />
<StockAnalysisHost />
<StockAnalysisBubble />
</div>
)
}
@@ -0,0 +1,757 @@
/**
* 通用列表列自定义组件。
*
* 布局:
* - 上半区「已启用」:已启用的列,@dnd-kit 拖拽排序
* - 下半区「内置列」:按业务分组折叠
* - 底部「扩展数据列」:复用 ext_data schema,按需添加字段
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
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 { X, GripVertical, Plus, ChevronDown, ChevronRight, Database, Settings2, Search, Eye, EyeOff } from 'lucide-react'
import { api } from '@/lib/api'
import { useQuery } from '@tanstack/react-query'
import { QK } from '@/lib/queryKeys'
import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig } from '@/lib/list-columns'
import { resolveCandleConfig } from '@/lib/list-columns'
interface ListColumnCustomizerProps {
columns: ColumnConfig[]
groups: ColumnGroup[]
onChange: (columns: ColumnConfig[]) => void
open: boolean
onClose: () => void
title?: string
builtinSectionLabel?: string
extColumnAlign?: 'left' | 'center' | 'right'
extFieldFilter?: (field: { name: string; label: string; type: string }) => boolean
/** 是否显示扩展数据列区块(默认 true;信息条等无法渲染 ext 数据的场景设为 false)。 */
showExtColumns?: boolean
/** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */
showStandaloneToggle?: boolean
}
function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: {
col: ColumnConfig
onRemove: (id: string) => void
onConfig: (id: string | null) => void
configOpen: boolean
extTableLabel: string
extConfig: React.ReactNode
candleConfig: React.ReactNode
strategiesConfig: React.ReactNode
showStandaloneToggle?: boolean
onToggleStandalone?: (id: string) => void
}) {
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: col.id })
const isExt = col.source.type === 'ext'
const isCandle = col.source.type === 'builtin' && col.source.key === 'candle'
const isStrategies = col.source.type === 'builtin' && col.source.key === 'strategies'
const hasConfig = isExt || isCandle || isStrategies
return (
<>
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
className={`flex items-center gap-2 px-2 py-1.5 rounded group ${
isDragging ? 'bg-elevated shadow-lg' : 'hover:bg-elevated/50'
}`}
>
<span
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
>
<GripVertical className="h-3.5 w-3.5" />
</span>
<span className="flex-1 text-xs text-foreground truncate">
{isExt && col.source.type === 'ext'
? `${col.label}${extTableLabel}`
: col.label}
</span>
{showStandaloneToggle && (
<button
onClick={() => onToggleStandalone?.(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary opacity-0 group-hover:opacity-100'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
{hasConfig && (
<button
onClick={() => onConfig(configOpen ? null : col.id)}
className={`transition-all ${configOpen ? 'text-accent' : 'opacity-0 group-hover:opacity-100 text-muted hover:text-accent'}`}
title="配置"
>
<Settings2 className="h-3 w-3" />
</button>
)}
<button
onClick={() => onRemove(col.id)}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-danger transition-all shrink-0"
title="隐藏"
>
<EyeOff className="h-3 w-3" />
</button>
</div>
{hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : strategiesConfig)}
</>
)
}
export function ListColumnCustomizer({
columns,
groups,
onChange,
open,
onClose,
title = '自定义列',
builtinSectionLabel = '内置列',
extColumnAlign = 'center',
extFieldFilter,
showExtColumns = true,
showStandaloneToggle = false,
}: ListColumnCustomizerProps) {
const extSchema = useQuery({
queryKey: QK.extDataSchemaAll,
queryFn: api.extDataSchemaAll,
enabled: open && showExtColumns,
staleTime: 60_000,
})
const [searchQuery, setSearchQuery] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [configOpenId, setConfigOpenId] = useState<string | null>(null)
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set())
const colById = useMemo(() => new Map(columns.map(c => [c.id, c])), [columns])
const keyToId = useMemo(() => {
const m = new Map<string, string>()
for (const c of columns) {
if (c.source.type === 'builtin' || c.source.type === 'computed') m.set(c.source.key, c.id)
}
return m
}, [columns])
const activeCols = useMemo(() => columns.filter(c => !c.pinned && c.visible), [columns])
const toggleVisible = useCallback((colId: string) => {
onChange(columns.map(c =>
c.id === colId && !c.pinned ? { ...c, visible: !c.visible } : c
))
}, [columns, onChange])
const toggleStandalone = useCallback((colId: string) => {
onChange(columns.map(c =>
c.id === colId ? { ...c, standalone: !c.standalone } : c
))
}, [columns, onChange])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const reorderCols = columns.filter(c => !c.pinned)
const pinnedCols = columns.filter(c => c.pinned)
const ids = reorderCols.map(c => c.id)
const oldIdx = ids.indexOf(active.id as string)
const newIdx = ids.indexOf(over.id as string)
if (oldIdx < 0 || newIdx < 0) return
const reordered = arrayMove(reorderCols, oldIdx, newIdx)
onChange([...pinnedCols, ...reordered])
}, [columns, onChange])
const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string) => {
const colId = `ext:${configId}:${fieldName}`
if (columns.some(c => c.id === colId)) {
toggleVisible(colId)
return
}
const newCol: ColumnConfig = {
id: colId,
source: { type: 'ext', configId, fieldName, fieldLabel },
label: fieldLabel || fieldName,
visible: true,
align: extColumnAlign,
}
const actionIdx = columns.findIndex(c => c.id === 'builtin:action')
if (actionIdx >= 0) {
const next = [...columns]
next.splice(actionIdx, 0, newCol)
onChange(next)
} else {
onChange([...columns, newCol])
}
}, [columns, extColumnAlign, onChange, toggleVisible])
const hideColumn = useCallback((colId: string) => {
onChange(columns.map(c => c.id === colId ? { ...c, visible: false } : c))
}, [columns, onChange])
const updateExtDisplay = useCallback((colId: string, patch: Partial<ExtColumnDisplayConfig>) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
return { ...c, extDisplay: { displayMode: 'tag', ...(c.extDisplay || {}), ...patch } }
}))
}, [columns, onChange])
const resetExtDisplay = useCallback((colId: string) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
const { extDisplay, ...rest } = c
return rest
}))
}, [columns, onChange])
const updateCandleConfig = useCallback((colId: string, patch: Partial<CandleColumnConfig>) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
return { ...c, candleConfig: { ...c.candleConfig, ...patch } }
}))
}, [columns, onChange])
const resetCandleConfig = useCallback((colId: string) => {
onChange(columns.map(c => {
if (c.id !== colId) return c
const { candleConfig, ...rest } = c
return rest
}))
}, [columns, onChange])
const toggleGroup = useCallback((groupId: string) => {
setExpandedGroups(prev => {
const next = new Set(prev)
if (next.has(groupId)) next.delete(groupId)
else next.add(groupId)
return next
})
}, [])
const toggleTableExpand = useCallback((tableId: string) => {
setExpandedTables(prev => {
const next = new Set(prev)
if (next.has(tableId)) next.delete(tableId)
else next.add(tableId)
return next
})
}, [])
const extTables = extSchema.data?.items ?? []
const extTableLabelMap = new Map(extTables.map(t => [t.id, t.label]))
const query = searchQuery.trim().toLowerCase()
const filteredGroups = useMemo(() => {
if (!query) return groups
return groups.filter(g =>
g.label.includes(query) ||
g.keys.some(k => {
const id = keyToId.get(k)
return id && (colById.get(id)?.label ?? '').toLowerCase().includes(query)
})
)
}, [groups, query, keyToId, colById])
useEffect(() => {
if (!open) { setConfigOpenId(null); setSearchQuery('') }
}, [open])
useEffect(() => {
if (query) setExpandedGroups(new Set(filteredGroups.map(g => g.id)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
const renderExtConfig = (col: ColumnConfig) => (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<select
value={col.extDisplay?.displayMode ?? 'tag'}
onChange={e => updateExtDisplay(col.id, { displayMode: e.target.value as 'tag' | 'text' })}
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50"
>
<option value="tag"></option>
<option value="text"></option>
</select>
</label>
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<input
type="text"
value={col.extDisplay?.separator ?? ''}
onChange={e => updateExtDisplay(col.id, { separator: e.target.value })}
placeholder="默认:、,;-"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
)}
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<input
type="text"
value={col.extDisplay?.maxWidth ?? ''}
onChange={e => updateExtDisplay(col.id, { maxWidth: e.target.value })}
placeholder="如 200px"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">N个</span>
<input
type="number" min={0}
value={col.extDisplay?.maxTags ?? ''}
onChange={e => {
const v = e.target.value ? Number(e.target.value) : undefined
updateExtDisplay(col.id, { maxTags: v, ...(v ? {} : { hiddenIndices: undefined }) })
}}
placeholder="0=全部"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
)}
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (col.extDisplay?.maxTags ?? 0) > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => {
const hidden = col.extDisplay?.hiddenIndices?.includes(i)
return (
<button
key={i}
onClick={() => {
const cur = col.extDisplay?.hiddenIndices ?? []
const next = hidden ? cur.filter(x => x !== i) : [...cur, i]
updateExtDisplay(col.id, { hiddenIndices: next.length ? next : undefined })
}}
className={`w-6 h-6 rounded text-[10px] font-medium transition-colors ${
hidden ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'
}`}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex rounded overflow-hidden border border-border">
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'horizontal' })}
className={`px-3 py-1 text-xs transition-colors ${
(col.extDisplay?.tagLayout ?? 'horizontal') === 'horizontal'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'vertical' })}
className={`px-3 py-1 text-xs transition-colors border-l border-border ${
col.extDisplay?.tagLayout === 'vertical'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
</div>
</label>
)}
{col.extDisplay && (
<div className="flex justify-end pt-1">
<button onClick={() => resetExtDisplay(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
</button>
</div>
)}
</div>
</motion.div>
)
// 策略列配置(精简版:仅显示数量/位置/排列方向,复用 extDisplay 存储)
const renderStrategiesConfig = (col: ColumnConfig) => (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">N个</span>
<input
type="number" min={0}
value={col.extDisplay?.maxTags ?? ''}
onChange={e => {
const v = e.target.value ? Number(e.target.value) : undefined
updateExtDisplay(col.id, { maxTags: v, ...(v ? {} : { hiddenIndices: undefined }) })
}}
placeholder="0=全部"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
{(col.extDisplay?.maxTags ?? 0) > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => {
const hidden = col.extDisplay?.hiddenIndices?.includes(i)
return (
<button
key={i}
onClick={() => {
const cur = col.extDisplay?.hiddenIndices ?? []
const next = hidden ? cur.filter(x => x !== i) : [...cur, i]
updateExtDisplay(col.id, { hiddenIndices: next.length ? next : undefined })
}}
className={`w-6 h-6 rounded text-[10px] font-medium transition-colors ${
hidden ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'
}`}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<div className="flex rounded overflow-hidden border border-border">
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'horizontal' })}
className={`px-3 py-1 text-xs transition-colors ${
(col.extDisplay?.tagLayout ?? 'horizontal') === 'horizontal'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'vertical' })}
className={`px-3 py-1 text-xs transition-colors border-l border-border ${
col.extDisplay?.tagLayout === 'vertical'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
></button>
</div>
</label>
{col.extDisplay && (
<div className="flex justify-end pt-1">
<button onClick={() => resetExtDisplay(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
</button>
</div>
)}
</div>
</motion.div>
)
const renderCandleConfig = (col: ColumnConfig) => {
const cfg = resolveCandleConfig(col.candleConfig)
// 数值输入:空字符串回退到默认值,便于清空重输
const numInput = (
field: keyof CandleColumnConfig,
label: string,
) => (
<label key={field} className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">{label}</span>
<input
type="number"
value={cfg[field]}
onChange={e => {
const raw = e.target.value
// 调用 resolveCandleConfig 钳制:过大取上限、过小取最小值
const merged = resolveCandleConfig({ ...col.candleConfig, [field]: raw === '' ? undefined : Number(raw) })
updateCandleConfig(col.id, { [field]: merged[field] })
}}
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50 tabular-nums"
/>
</label>
)
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
{numInput('days', '日k天数')}
{numInput('enabledWidth', '开启宽度')}
{numInput('enabledHeight', '开启高度')}
{numInput('disabledWidth', '收起宽度')}
{numInput('disabledHeight', '收起高度')}
<div className="text-[10px] text-muted leading-relaxed pt-0.5">
40300 / 32200 / 160
</div>
{col.candleConfig && (
<div className="flex justify-end pt-1">
<button onClick={() => resetCandleConfig(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
</button>
</div>
)}
</div>
</motion.div>
)
}
const renderCheckbox = (checked: boolean) => (
<span className={`shrink-0 w-4 h-4 rounded flex items-center justify-center transition-colors ${
checked ? 'bg-accent text-white' : 'border border-border text-transparent group-hover:border-muted'
}`}>
<svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5L6.5 12L13 4" />
</svg>
</span>
)
const renderBuiltinRow = (col: ColumnConfig) => (
<div
key={col.id}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
<button onClick={() => toggleVisible(col.id)} className="flex items-center gap-2 flex-1 min-w-0">
{renderCheckbox(col.visible)}
<span className={`flex-1 text-xs truncate ${col.visible ? 'text-foreground' : 'text-muted'}`}>{col.label}</span>
</button>
{showStandaloneToggle && col.visible && (
<button
onClick={() => toggleStandalone(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
</div>
)
const renderExtFieldRow = (configId: string, field: { name: string; label: string; type: string }) => {
const colId = `ext:${configId}:${field.name}`
const existing = columns.find(c => c.id === colId)
const checked = !!existing?.visible
return (
<button
key={field.name}
onClick={() => addExtColumn(configId, field.name, field.label)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
{renderCheckbox(checked)}
<span className={`flex-1 text-xs truncate ${checked ? 'text-foreground' : 'text-muted'}`}>{field.label || field.name}</span>
<span className="text-[10px] text-muted shrink-0">{field.type}</span>
</button>
)
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex justify-end">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[360px] max-w-[90vw] h-full bg-base border-l border-border shadow-2xl flex flex-col"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-3 pt-3 pb-1">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索列名..."
className="w-full h-8 pl-8 pr-3 rounded-lg bg-elevated border border-border text-xs text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 transition-colors"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{activeCols.length > 0 && (
<div>
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Eye className="h-3 w-3 text-accent/70" />
<span className="text-[10px] font-semibold text-accent/80 uppercase tracking-wider"></span>
<span className="text-[10px] text-muted">{activeCols.length} · </span>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={activeCols.map(c => c.id)} strategy={verticalListSortingStrategy}>
{activeCols.map(col => (
<SortableActiveCol
key={col.id}
col={col}
onRemove={hideColumn}
onConfig={setConfigOpenId}
configOpen={configOpenId === col.id}
extTableLabel={col.source.type === 'ext' ? (extTableLabelMap.get(col.source.configId) || col.source.configId) : ''}
extConfig={renderExtConfig(col)}
candleConfig={renderCandleConfig(col)}
strategiesConfig={renderStrategiesConfig(col)}
showStandaloneToggle={showStandaloneToggle}
onToggleStandalone={toggleStandalone}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
<div className="border-t border-border my-1" />
<div>
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Plus className="h-3 w-3 text-muted" />
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider">{builtinSectionLabel}</span>
</div>
{filteredGroups.map(group => {
const isExpanded = expandedGroups.has(group.id)
const groupCols = group.keys
.map(k => keyToId.get(k))
.filter((id): id is string => !!id)
.map(id => colById.get(id))
.filter((c): c is ColumnConfig => !!c)
if (groupCols.length === 0) return null
const visCount = groupCols.filter(c => c.visible).length
return (
<div key={group.id}>
<button
onClick={() => toggleGroup(group.id)}
className="flex items-center gap-1.5 w-full px-1 py-1.5 rounded hover:bg-elevated/30 text-left transition-colors"
>
{isExpanded ? <ChevronDown className="h-3 w-3 text-muted" /> : <ChevronRight className="h-3 w-3 text-muted" />}
{group.icon && <span className="text-[11px]">{group.icon}</span>}
<span className="text-[11px] font-medium text-secondary">{group.label}</span>
<span className="text-[10px] text-muted ml-auto">{visCount}/{groupCols.length}</span>
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.12 }}
className="overflow-hidden"
>
<div className="space-y-0.5 pb-0.5">
{groupCols.map(col => renderBuiltinRow(col))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
{showExtColumns && extTables.length > 0 && (
<div className="pt-1 border-t border-border mt-1">
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Database className="h-3 w-3 text-accent/70" />
<span className="text-[10px] font-semibold text-accent/80 uppercase tracking-wider"></span>
</div>
<div className="space-y-0.5">
{extTables.map(table => {
const isExpanded = expandedTables.has(table.id)
const dataFields = table.columns
.filter(c => !['symbol', 'code', 'date'].includes(c.name))
.filter(c => extFieldFilter ? extFieldFilter(c) : true)
const activeCount = dataFields.filter(f => {
const colId = `ext:${table.id}:${f.name}`
return columns.some(c => c.id === colId && c.visible)
}).length
return (
<div key={table.id}>
<button
onClick={() => toggleTableExpand(table.id)}
className="flex items-center gap-1.5 w-full px-1 py-1.5 rounded hover:bg-elevated/30 text-left transition-colors"
>
{isExpanded ? <ChevronDown className="h-3 w-3 text-muted" /> : <ChevronRight className="h-3 w-3 text-muted" />}
<Database className="h-3 w-3 text-accent shrink-0" />
<span className="text-[11px] font-medium text-secondary flex-1">{table.label}</span>
<span className="text-[10px] text-muted">{table.mode === 'snapshot' ? '快照' : '时序'}</span>
{activeCount > 0 && (
<span className="text-[10px] text-accent font-medium">{activeCount}</span>
)}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="space-y-0.5 pb-0.5">
{dataFields.map(field => renderExtFieldRow(table.id, field))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
</div>
)}
{showExtColumns && extTables.length === 0 && extSchema.isSuccess && (
<div className="text-xs text-muted text-center py-4">
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
+59
View File
@@ -0,0 +1,59 @@
// 原创 logo:方括号 [ ] 包裹一根带 wick 的 K 线
//
// 概念:
// - 外层 brackets:终端 / 代码 / 引用边界 — 赛博 + quant 气质
// - 中央 wick+body:一根标准 K 线 — 直接的金融指代
// - body 偏上 + 下影长:bullish 站稳感 (上影短 / 下影长)
//
// 用 currentColor,继承父级 color 设定,方便切换品牌色。
interface LogoProps {
className?: string
size?: number
style?: React.CSSProperties
}
export function Logo({ className, size = 32, style }: LogoProps) {
return (
<svg
viewBox="0 0 32 32"
width={size}
height={size}
fill="none"
className={className}
style={style}
role="img"
aria-label="TickFlow Stock Panel"
>
{/* 左方括号 */}
<path
d="M10 4 L4 4 L4 28 L10 28"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="miter"
strokeLinecap="butt"
/>
{/* 右方括号 */}
<path
d="M22 4 L28 4 L28 28 L22 28"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="miter"
strokeLinecap="butt"
/>
{/* K 线 wick(上下影线,半透明) */}
<line
x1="16" y1="7" x2="16" y2="25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeOpacity="0.6"
/>
{/* K 线 body — 偏上,上影短/下影长, bullish 站稳感 */}
<rect
x="13" y="9" width="6" height="10"
fill="currentColor"
rx="0.5"
/>
</svg>
)
}
@@ -0,0 +1,28 @@
import { cn } from '@/lib/cn'
interface Props {
title: string
subtitle?: string
/** 标题右侧、subtitle 之前的额外节点(如状态徽标) */
titleExtra?: React.ReactNode
right?: React.ReactNode
className?: string
}
export function PageHeader({ title, subtitle, titleExtra, right, className }: Props) {
return (
<header
className={cn(
'px-5 pt-3 pb-2 border-b border-border flex items-center justify-between gap-4',
className,
)}
>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight">{title}</h1>
{titleExtra}
{subtitle && <span className="text-xs text-muted">{subtitle}</span>}
</div>
{right}
</header>
)
}
@@ -0,0 +1,445 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Repeat, Sparkles, ArrowDownUp, RefreshCw, AlertCircle } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { fmtPct } from '@/lib/format'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
interface Props {
onClose: () => void
}
const DEFAULT_DAYS = 12
const ROW_HEIGHT = 30 // 每行高度(px), 与单元格样式配合
const OVERSCAN = 8 // 上下额外渲染行数, 减少滚动时的白屏闪烁
const MIN_DAYS = 7
const MAX_DAYS = 30
// 涨幅 → 背景色梯度(A 股语义: 红涨绿跌)。强度越大色越深, 一眼看出强势/弱势概念
function pctBgClass(pct: number): string {
if (pct >= 0.05) return 'bg-bull/25'
if (pct >= 0.03) return 'bg-bull/18'
if (pct >= 0.01) return 'bg-bull/10'
if (pct > -0.01) return ''
if (pct > -0.03) return 'bg-bear/10'
if (pct > -0.05) return 'bg-bear/18'
return 'bg-bear/25'
}
// 把 "2026-07-01" 格式化成 "7/01" 紧凑显示(表头窄列)
function shortDate(s: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (!m) return s
return `${Number(m[2])}/${m[3]}`
}
// 排名 → 前景色(A 股语义: 红=强, 绿=弱)。前 10 红, 后 10 绿, 中间默认强调色。
// total 兜底: 概念总数未知时只判前 10, 不判后 10。
function rankColorClass(rank: number, total: number): string {
if (rank <= 10) return 'text-bull'
if (total > 20 && rank > total - 10) return 'text-bear'
return 'text-accent'
}
export function RpsRotationDialog({ onClose }: Props) {
const [days, setDays] = useState(DEFAULT_DAYS)
const [reversed, setReversed] = useState(false) // false=高→低, true=低→高
const [selected, setSelected] = useState<string | null>(null) // 点中的概念名, 高亮追踪
// ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ----
const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告
const [analyzing, setAnalyzing] = useState(false) // 生成中
const [analysisError, setAnalysisError] = useState('') // 错误信息
const [analysisMeta, setAnalysisMeta] = useState<{ summary?: string } | null>(null)
const [focus, setFocus] = useState('') // 用户追加的关注点
const runAnalysis = useCallback(async (daysParam: number, focusParam: string) => {
setAnalyzing(true)
setAnalysis('')
setAnalysisError('')
setAnalysisMeta(null)
try {
for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) {
if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary })
else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? ''))
else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误')
// done: 无操作
}
} catch (e) {
setAnalysisError(e instanceof Error ? e.message : String(e))
} finally {
setAnalyzing(false)
}
}, [])
// 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开
const { data, isLoading, error } = useQuery({
queryKey: QK.rpsRotation(days),
queryFn: () => api.rpsRotation(days),
staleTime: 5 * 60 * 1000,
})
const dates = data?.dates ?? []
const columns = data?.columns ?? {}
const conceptCount = data?.concept_count ?? 0
// 行数 = 最长那列的长度(理论上每天概念数应一致, 取最大兜底)
const rowCount = useMemo(
() => dates.reduce((m, d) => Math.max(m, columns[d]?.length ?? 0), 0),
[dates, columns],
)
// 行索引: 翻转时不重排数据, 只翻转访问索引(省一次大数组操作)
const getRowIndex = useCallback(
(displayIdx: number) => (reversed ? rowCount - 1 - displayIdx : displayIdx),
[reversed, rowCount],
)
// ---- 手写虚拟滚动 ----
// 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。
// 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。
const scrollRef = useRef<HTMLDivElement>(null)
// AI 报告区滚动容器: 流式生成时自动滚到底部
const analysisRef = useRef<HTMLDivElement>(null)
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 })
// 流式生成中: analysis 每次追加都把报告区滚到底部, 跟踪最新文字
useEffect(() => {
if (!analyzing) return
const el = analysisRef.current
if (el) el.scrollTop = el.scrollHeight
}, [analysis, analyzing])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
const scrollTop = el.scrollTop
const viewportH = el.clientHeight
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN)
const end = Math.min(rowCount, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN)
setVisibleRange(prev => (prev.start === start && prev.end === end ? prev : { start, end }))
}, [rowCount])
useEffect(() => {
// rowCount 变化(切天数/数据到达)时重算可视范围
handleScroll()
}, [handleScroll, rowCount])
// ESC 关闭
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
// 选中概念的追踪行: 找出它在每个日期列的(排名, 涨幅)。
// 每列已按涨幅降序排好, 故排名 = 该概念在数组里的索引 + 1。
// 未入选该日(概念当天无数据)显示空, 便于横向看排名变化。
const selectedRow = useMemo(() => {
if (!selected) return null
const cells: ({ rank: number; pct: number } | null)[] = []
for (const d of dates) {
const col = columns[d] ?? []
const idx = col.findIndex(([name]) => name === selected)
cells.push(idx >= 0 ? { rank: idx + 1, pct: col[idx][1] } : null)
}
return cells
}, [selected, dates, columns])
const renderRows = useMemo(() => {
const rows: JSX.Element[] = []
for (let displayIdx = visibleRange.start; displayIdx < visibleRange.end; displayIdx++) {
const rawIdx = getRowIndex(displayIdx)
const cells = dates.map((d) => {
const cell = columns[d]?.[rawIdx]
if (!cell) {
return (
<td key={d} className="px-2 py-1 text-center text-muted/40">
<span className="text-[10px]"></span>
</td>
)
}
const [name, pct] = cell
const isSelected = selected === name
return (
<td
key={d}
onClick={() => setSelected(prev => prev === name ? null : name)}
className={cn(
'px-2 py-1 cursor-pointer whitespace-nowrap text-center align-middle transition-colors',
pctBgClass(pct),
isSelected && 'ring-1 ring-inset ring-accent bg-accent/20',
)}
>
<div className="flex flex-col items-center gap-0.5 leading-tight">
<span className={cn(
'text-[11px] max-w-[84px] truncate',
isSelected ? 'text-accent font-medium' : 'text-secondary',
)} title={name}>{name}</span>
<span className={cn(
'text-[10px] tabular-nums',
pct > 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted',
)}>{fmtPct(pct)}</span>
</div>
</td>
)
})
rows.push(
<tr
key={displayIdx}
style={{ height: ROW_HEIGHT }}
className="border-b border-border/30"
>
<td className="sticky left-0 z-10 bg-surface px-2 text-center text-[10px] text-muted tabular-nums border-r border-border/40">
{displayIdx + 1}
</td>
{cells}
</tr>,
)
}
return rows
}, [visibleRange, getRowIndex, dates, columns, selected])
return (
<AnimatePresence>
<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"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="w-[92vw] max-w-[1100px] h-[88vh] bg-surface border border-border rounded-card shadow-xl flex flex-col"
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Repeat className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
<span className="text-[11px] text-muted">
{conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'}
</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
{/* 上半区: AI 轮动分析 */}
<div className="shrink-0 border-b border-border flex flex-col max-h-[42%]">
{/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */}
<div className="flex items-center gap-2 px-4 py-1.5 bg-elevated/30 shrink-0">
<Sparkles className={cn('h-3.5 w-3.5 text-accent/60', analyzing && 'animate-pulse')} />
<span className="text-[11px] text-muted shrink-0">AI </span>
{analysisMeta?.summary && (
<span className="text-[11px] text-accent/80 truncate">{analysisMeta.summary}</span>
)}
<div className="flex items-center gap-1.5 ml-auto">
<input
type="text"
value={focus}
onChange={e => setFocus(e.target.value)}
placeholder="关注点(可选)"
disabled={analyzing}
className="w-28 px-2 py-0.5 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40 disabled:opacity-50"
/>
<button
onClick={() => runAnalysis(days, focus)}
disabled={analyzing}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] transition-colors cursor-pointer border',
analyzing
? 'opacity-60 cursor-not-allowed border-border text-muted'
: 'bg-accent/10 text-accent border-accent/30 hover:bg-accent/20',
)}
>
{analyzing
? <><RefreshCw className="h-3 w-3 animate-spin" /></>
: analysis
? <><RefreshCw className="h-3 w-3" /></>
: <><Sparkles className="h-3 w-3" /></>}
</button>
</div>
</div>
{/* 报告内容区: 四态渲染 */}
<div ref={analysisRef} className="flex-1 min-h-0 overflow-auto">
{analysisError ? (
<div className="flex items-center gap-2 px-4 py-4 text-[11px] text-danger">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span>{analysisError}</span>
<button
onClick={() => runAnalysis(days, focus)}
className="ml-auto text-accent hover:underline shrink-0"
></button>
</div>
) : analysis || analyzing ? (
<div className="px-4 py-2.5 text-[12px] leading-relaxed">
<MarkdownRenderer content={analysis} />
{analyzing && (
<span className="inline-block w-1.5 h-3.5 bg-accent animate-pulse align-middle ml-0.5" />
)}
</div>
) : (
<div className="px-4 py-4 text-center text-[11px] text-muted/60">
,AI 线 / / 退 / vs游资 {days}
</div>
)}
</div>
</div>
{/* 工具栏 */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted"></span>
<input
type="range"
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
value={days}
onChange={e => setDays(Number(e.target.value))}
className="w-24 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-secondary tabular-nums w-5">{days}</span>
</div>
<button
onClick={() => setReversed(r => !r)}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] transition-colors cursor-pointer border',
reversed
? 'bg-accent/10 text-accent border-accent/30'
: 'border-border text-muted hover:text-secondary hover:bg-elevated',
)}
title="翻转排序(高↔低)"
>
<ArrowDownUp className="h-3 w-3" />
{reversed ? '低→高' : '高→低'}
</button>
{selected && (
<button
onClick={() => setSelected(null)}
className="text-[11px] text-accent hover:underline cursor-pointer"
>
{selected}
</button>
)}
</div>
{/* 下半区: 涨幅轮动矩阵(虚拟滚动) */}
<div className="flex-1 min-h-0 flex flex-col">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-[11px] text-danger">
,
</div>
) : rowCount === 0 ? (
<div className="flex items-center justify-center py-16 text-[11px] text-muted">
,
</div>
) : (
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-auto"
>
<table className="min-w-full border-collapse">
{/* 表头: 日期列, 最新在最左 */}
<thead className="sticky top-0 z-20 bg-surface">
<tr>
<th className="sticky left-0 z-30 bg-surface px-2 py-1.5 text-[10px] font-normal text-muted border-b border-r border-border/40">
#
</th>
{dates.map(d => (
<th
key={d}
className="px-2 py-1.5 text-[10px] font-normal text-muted border-b border-border/40 whitespace-nowrap text-center"
title={d}
>
{shortDate(d)}
</th>
))}
</tr>
{/* 选中概念追踪行: 在日期表头下方单独一行, 横向展示它在各日的排名+涨幅 */}
<AnimatePresence>
{selected && selectedRow && (
<motion.tr
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="border-b border-accent/20 bg-accent/5"
>
<td className="sticky left-0 z-30 bg-surface px-2 py-1 text-center border-r border-border/40">
<span className="text-[10px] text-accent truncate block max-w-[44px]" title={selected}>
{selected}
</span>
</td>
{selectedRow.map((cell, i) => (
<td key={i} className="px-2 py-1 text-center whitespace-nowrap align-middle">
{cell ? (
<div className="flex flex-col items-center gap-0.5 leading-tight">
<span className={cn(
'text-[11px] font-medium tabular-nums',
rankColorClass(cell.rank, conceptCount),
)}>
#{cell.rank}
</span>
<span className={cn(
'text-[10px] tabular-nums',
cell.pct > 0 ? 'text-bull' : cell.pct < 0 ? 'text-bear' : 'text-muted',
)}>
{fmtPct(cell.pct)}
</span>
</div>
) : (
<span className="text-[10px] text-muted/40"></span>
)}
</td>
))}
</motion.tr>
)}
</AnimatePresence>
</thead>
<tbody>
{/* 顶部占位: 把滚动位置撑起来 */}
{visibleRange.start > 0 && (
<tr style={{ height: visibleRange.start * ROW_HEIGHT }}>
<td colSpan={dates.length + 1} />
</tr>
)}
{renderRows}
{/* 底部占位 */}
{visibleRange.end < rowCount && (
<tr style={{ height: (rowCount - visibleRange.end) * ROW_HEIGHT }}>
<td colSpan={dates.length + 1} />
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* 底部提示 */}
<div className="px-4 py-1.5 border-t border-border shrink-0">
<span className="text-[10px] text-muted">
·
</span>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
@@ -0,0 +1,143 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { HelpCircle } from 'lucide-react'
import { api } from '@/lib/api'
import { toast } from '@/components/Toast'
/** 单方向(涨停/跌停)的修正明细块 */
function SealedDirBlock({ title, color, counts, rawTotal }: {
title: string
color: 'bull' | 'bear'
counts?: { real: number; fake: number; pending: number }
rawTotal?: number
}) {
const real = counts?.real ?? 0
const fake = counts?.fake ?? 0
// pending 从原始总数推算(后端 pending 含另一方向票, 不可用)
const pending = Math.max(0, (rawTotal ?? 0) - real - fake)
const original = rawTotal ?? (real + fake + pending)
const fixed = real + pending
return (
<div className="mb-2 last:mb-0">
<div className={`flex items-center justify-between px-1 py-0.5 rounded bg-${color}/5 mb-1`}>
<span className={`text-[10px] font-medium text-${color}`}>{title}</span>
<span className="tabular-nums text-[10px]">
<span className="text-muted line-through">{original}</span>
<span className="text-muted/50 mx-1"></span>
<span className={`font-bold text-${color}`}>{fixed}</span>
</span>
</div>
<div className="flex gap-3 px-1 text-[10px]">
<span className={`flex items-center gap-0.5 text-${color}`}><span className={`h-1 w-1 rounded-full bg-${color}`} /> {real}</span>
<span className="flex items-center gap-0.5 text-yellow-500"><span className="h-1 w-1 rounded-full bg-yellow-500" /> {fake}</span>
{pending > 0 && (
<span className="flex items-center gap-0.5 text-muted"><span className="h-1 w-1 rounded-full bg-muted" /> {pending}</span>
)}
</div>
</div>
)
}
/** 修正/降级 标识 + 问号弹窗(连板梯队/看板共用) */
export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sealedCountsUp, sealedCountsDown, rawUp, rawDown, invalidateKeys = ['limit-ladder'] }: {
degraded: boolean
hasDepth: boolean
isHistorical: boolean
sealedReady: boolean | undefined
sealedCountsUp?: { real: number; fake: number; pending: number }
sealedCountsDown?: { real: number; fake: number; pending: number }
rawUp?: number
rawDown?: number
/** 修正后要刷新的 queryKey 前缀(默认连板梯队) */
invalidateKeys?: string[]
}) {
const [showHint, setShowHint] = useState(false)
const navigate = useNavigate()
const qc = useQueryClient()
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
toast(data.msg, data.ok ? 'success' : 'error')
if (data.ok) invalidateKeys.forEach(k => qc.invalidateQueries({ queryKey: [k] }))
},
onError: () => toast('修正请求失败', 'error'),
})
// 组装原因文案(仅降级时用)
const reasons: string[] = []
if (!hasDepth) reasons.push('当前套餐无五档盘口能力(需 Pro+),涨停判定基于收盘价,可能含假涨停')
if (isHistorical) reasons.push('历史日期的盘口快照不可获取,无法判定真假板')
if (hasDepth && !isHistorical && !sealedReady) reasons.push('盘中 sealed 数据尚未就绪,收盘后自动恢复')
const label = degraded ? '降级' : '修正'
return (
<div className="relative inline-flex items-center">
<button
onClick={() => setShowHint(v => !v)}
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
>
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
</button>
<AnimatePresence>
{showHint && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowHint(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 }}
className="absolute top-full left-0 mt-1 z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
onClick={e => e.stopPropagation()}
>
{degraded ? (
<>
<div className="font-medium text-foreground mb-1.5"></div>
{reasons.map((r, i) => (
<div key={i} className="flex gap-1 mb-1">
<span className="text-yellow-500 shrink-0">·</span>
<span>{r}</span>
</div>
))}
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
(/)Pro+
</div>
</>
) : (
<>
<div className="font-medium text-foreground mb-1.5"></div>
<SealedDirBlock title="涨停" color="bull" counts={sealedCountsUp} rawTotal={rawUp} />
<SealedDirBlock title="跌停" color="bear" counts={sealedCountsDown} rawTotal={rawDown} />
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
,//{sealedReady && '数据为盘中快照,收盘后自动定版。'}
</div>
</>
)}
<div className="mt-2 flex gap-1.5">
{hasDepth && !isHistorical && (
<button
onClick={() => { runFix.mutate(); setShowHint(false) }}
disabled={runFix.isPending}
className="flex-1 px-2 py-1.5 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors text-center disabled:opacity-50"
>
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
)}
<button
onClick={() => { setShowHint(false); navigate('/settings?tab=monitoring&highlight=depth-fix') }}
className={`${hasDepth && !isHistorical ? '' : 'w-full'} px-2 py-1.5 rounded text-[11px] bg-elevated text-secondary hover:text-foreground transition-colors text-center`}
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api, type KlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import {
EChartsCandlestick,
OVERLAY_INDICATORS,
SUB_CHARTS,
type ChartMarker,
type ChartPriceLine,
type ChartRange,
type OHLC,
type StockInfo,
} from '@/components/EChartsCandlestick'
const SUB_INFO_H = 16
const SUB_GAP = 4
const MAX_DAYS = 2000
export interface StockDailyKChartResult {
rows: OHLC[]
rawRows: KlineRow[]
stockInfo?: StockInfo
name?: string
}
interface Props {
symbol: string
height?: number
className?: string
dateRange?: { start: string; end: string }
markers?: ChartMarker[]
ranges?: ChartRange[]
priceLines?: ChartPriceLine[]
showLimitMarkers?: boolean
showIndicatorControls?: boolean
showMarkerToggle?: boolean
showMA?: boolean
showInfoBar?: boolean
visibleBars?: number
linkedPrice?: number | null
onDateClick?: (date: string) => void
onDataChange?: (result: StockDailyKChartResult) => void
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
extColumns?: string
}
function isValidRow(r: any): boolean {
return r && r.date != null && r.open != null && r.close != null
}
export function toOHLC(rows: KlineRow[]): OHLC[] {
return rows
.filter(isValidRow)
.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 buildLimitUpMarkers(rows: KlineRow[]): ChartMarker[] {
const markers: ChartMarker[] = []
for (const r of rows) {
const date = typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)
if (r.signal_broken_limit_up) {
markers.push({ date, kind: 'neutral', above: true, color: '#8B5CF6', label: '炸' })
} else if (r.signal_limit_up) {
const boards: number = r.consecutive_limit_ups ?? 1
markers.push({ date, kind: 'buy', above: true, color: '#FACC15', label: boards <= 1 ? '板' : String(boards) })
}
}
return markers
}
export function getDefaultRange(): { start: string; end: string } {
const now = new Date()
const end = now.toISOString().slice(0, 10)
const s = new Date(now)
s.setMonth(s.getMonth() - 6)
const start = s.toISOString().slice(0, 10)
return { start, end }
}
function rangeDays(range: { start: string; end: string }): number {
const start = new Date(range.start)
const end = new Date(range.end)
return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS)
}
export function StockDailyKChart({
symbol,
height = 520,
className,
dateRange: externalDateRange,
markers,
ranges,
priceLines,
showLimitMarkers = true,
showIndicatorControls = true,
showMarkerToggle = true,
showMA = true,
showInfoBar = true,
visibleBars = 60,
linkedPrice,
onDateClick,
onDataChange,
extColumns,
}: Props) {
const [activeIndicators, setActiveIndicators] = useState<string[]>(['vol'])
const [showMarkers, setShowMarkers] = useState(true)
const dateRange = externalDateRange ?? getDefaultRange()
const days = useMemo(() => rangeDays(dateRange), [dateRange])
// extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数)
const kline = useQuery({
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns),
enabled: !!symbol,
placeholderData: (prev) => prev,
})
const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
const stockInfo = kline.data?.stock_info
const limitMarkers = useMemo(() => buildLimitUpMarkers(kline.data?.rows ?? []), [kline.data?.rows])
const allMarkers = useMemo(() => [
...(markers ?? []),
...(showLimitMarkers ? limitMarkers : []),
], [limitMarkers, markers, showLimitMarkers])
const toggleIndicator = useCallback((key: string) => {
setActiveIndicators(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
}, [])
const activeSubDefs = activeIndicators
.map(key => SUB_CHARTS.find(s => s.key === key))
.filter((d): d is typeof SUB_CHARTS[number] => !!d)
let subExtraH = 0
activeSubDefs.forEach(def => { subExtraH += SUB_INFO_H + def.height })
if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14
const chartHeight = height + subExtraH
useEffect(() => {
onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name })
}, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo])
if (!symbol) return null
return (
<div className={className} style={{ minHeight: chartHeight }}>
{showIndicatorControls && rows.length > 0 && (
<div className="flex items-center gap-1.5 px-1 pb-0.5">
{SUB_CHARTS.map(ind => (
<button
key={ind.key}
onClick={() => toggleIndicator(ind.key)}
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
activeIndicators.includes(ind.key)
? 'bg-accent/20 text-accent'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
{ind.label}
</button>
))}
{OVERLAY_INDICATORS.map(ind => (
<button
key={ind.key}
onClick={() => toggleIndicator(ind.key)}
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
activeIndicators.includes(ind.key)
? 'bg-accent/20 text-accent'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
{ind.label}
</button>
))}
{showMarkerToggle && showLimitMarkers && (
<button
onClick={() => setShowMarkers(v => !v)}
className={`ml-auto px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
showMarkers
? 'text-[#FACC15] bg-[#FACC15]/10'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
</button>
)}
</div>
)}
{kline.isLoading && <div className="text-sm text-muted py-4"></div>}
{kline.isError && <div className="text-sm text-danger py-2">K加载失败</div>}
{!kline.isLoading && !kline.isError && (kline.data?.rows?.length ?? 0) > 0 && rows.length === 0 && (
<div className="text-sm text-danger py-2"></div>
)}
{rows.length > 0 && (
<EChartsCandlestick
data={rows}
markers={allMarkers}
ranges={ranges}
priceLines={priceLines}
height={chartHeight - 22}
showMA={showMA}
showInfoBar={showInfoBar}
showMarkers={showMarkers}
stockInfo={stockInfo}
symbol={symbol}
linkedPrice={linkedPrice}
onDateClick={onDateClick}
visibleBars={visibleBars}
activeIndicators={activeIndicators}
/>
)}
</div>
)
}
@@ -0,0 +1,278 @@
import { useState, type ReactNode } from 'react'
import { Settings2, RadioTower, Star } from 'lucide-react'
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
const BULL = '#C74040'
const BEAR = '#2D9B65'
interface Props {
symbol: string
name?: string
stockInfo?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
rows: KlineRow[]
/** 信息条字段配置(由 StockPanel 提升,受控) */
fields: ColumnConfig[]
onFieldsChange: (fields: ColumnConfig[]) => void
/** 财务指标最新一期(来自 useFinancialMetrics,受 Cap.FINANCIAL 门控) */
financialMetrics?: FinancialMetricRecord
/** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */
onMonitor?: () => void
/** 加自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
}
/**
* 精简渲染扩展数据值(信息条专用)。
* 仍尊重 extDisplay 配置:text=纯文本,tag(默认)=按分隔符拆成小标签 + maxTags 截断。
* 与自选列表的差异:标签模式无 maxWidth/排列方向,但保留 +N 展开交互。
*/
function renderExtInline(
val: unknown,
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
): ReactNode {
if (val == null || (typeof val === 'number' && Number.isNaN(val))) {
return <span className="text-muted"></span>
}
if (typeof val === 'number') {
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {
return <span className={val ? 'text-bull' : 'text-muted'}>{val ? '是' : '否'}</span>
}
const str = String(val)
// 纯文本模式
if (col.extDisplay?.displayMode === 'text') {
return <span>{str}</span>
}
// 标签模式(默认):按分隔符拆成小标签
const sep = col.extDisplay?.separator?.trim() || null
const tags = sep
? str.split(sep).map(s => s.trim()).filter(Boolean)
: str.split(/[、,;\-]/).map(s => s.trim()).filter(Boolean)
if (tags.length === 0) return <span className="text-muted"></span>
// maxTags 截断 + 展开交互:收起时显示前 N 个 + +N,展开时显示全部 + 收起
const maxTags = col.extDisplay?.maxTags ?? 0
const hiddenIndices = maxTags > 0 ? col.extDisplay?.hiddenIndices : undefined
const showAll = maxTags <= 0 || expanded
const sliced = showAll ? tags : tags.slice(0, maxTags)
const shown = hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced
const overflow = tags.length - shown.length
return (
<span className="inline-flex flex-wrap items-center gap-0.5">
{shown.map((tag, i) => (
<span key={i} className="inline-block px-1 rounded text-[10px] leading-tight text-yellow-500 bg-yellow-500/10">
{tag}
</span>
))}
{!showAll && overflow > 0 && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
>
+{overflow}
</button>
)}
{showAll && maxTags > 0 && tags.length > maxTags && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-muted hover:text-foreground transition-colors"
>
</button>
)}
</span>
)
}
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) {
// 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前
const [customizerOpen, setCustomizerOpen] = useState(false)
// ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰
const [expandedExt, setExpandedExt] = useState<Set<string>>(new Set())
const toggleExtExpand = (key: string) => {
setExpandedExt(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
if (rows.length === 0) return null
const latest = rows[rows.length - 1]
const prev = rows.length >= 2 ? rows[rows.length - 2] : null
const close = Number(latest.close)
const chg = prev ? close - Number(prev.close) : 0
const chgPct = prev ? chg / Number(prev.close) * 100 : 0
const isUp = chg >= 0
const clr = isUp ? BULL : BEAR
const totalShares = stockInfo?.total_shares
const floatShares = stockInfo?.float_shares
const marketCap = totalShares ? close * totalShares : null
const floatMarketCap = floatShares ? close * floatShares : null
const turnoverRate = floatShares && latest.volume
? (Number(latest.volume) * 100 / floatShares * 100)
: null
const displayName = stockInfo?.name ?? name ?? ''
const extData = stockInfo?.ext ?? {}
// 按指标 key 计算格式化值,无数据返回 null(渲染时跳过,与原行为一致)。
// 普通函数:依赖行情值每次 render 都变,useCallback 无收益;且必须定义在早期 return 之后。
const computeBuiltinValue = (key: string): string | null => {
switch (key) {
case 'market_cap': return marketCap != null ? fmtBigNum(marketCap) : null
case 'float_market_cap': return floatMarketCap != null ? fmtBigNum(floatMarketCap) : null
case 'turnover': return turnoverRate != null ? `${turnoverRate.toFixed(2)}%` : null
case 'volume': return latest.volume != null ? fmtVolume(Number(latest.volume)) : null
case 'amplitude': {
const prevClose = prev ? Number(prev.close) : null
if (prevClose == null || prevClose === 0) return null
const hi = Number(latest.high)
const lo = Number(latest.low)
return `${((hi - lo) / prevClose * 100).toFixed(2)}%`
}
case 'open': return fmtPrice(Number(latest.open))
case 'high': return fmtPrice(Number(latest.high))
case 'low': return fmtPrice(Number(latest.low))
// 财务指标:百分比字段存储为百分点(12.3 表示 12.3%),直接 toFixed(2) + %
case 'eps': return financialMetrics?.eps_basic != null ? fmtPrice(financialMetrics.eps_basic) : null
case 'bps': return financialMetrics?.bps != null ? fmtPrice(financialMetrics.bps) : null
case 'roe': return financialMetrics?.roe != null ? `${financialMetrics.roe.toFixed(2)}%` : null
case 'gross_margin':return financialMetrics?.gross_margin != null ? `${financialMetrics.gross_margin.toFixed(2)}%` : null
case 'net_margin': return financialMetrics?.net_margin != null ? `${financialMetrics.net_margin.toFixed(2)}%` : null
case 'debt_ratio': return financialMetrics?.debt_to_asset_ratio != null ? `${financialMetrics.debt_to_asset_ratio.toFixed(2)}%` : null
case 'revenue_yoy': return financialMetrics?.revenue_yoy != null ? `${financialMetrics.revenue_yoy.toFixed(2)}%` : null
case 'net_income_yoy': return financialMetrics?.net_income_yoy != null ? `${financialMetrics.net_income_yoy.toFixed(2)}%` : null
// PE/PB 后端无此字段,用现价现算(PE 基于最新一期 EPS,非严格 TTM)
case 'pe_ttm': {
const eps = financialMetrics?.eps_basic
return eps && eps !== 0 ? fmtPrice(close / eps) : null
}
case 'pb': {
const bps = financialMetrics?.bps
return bps && bps !== 0 ? fmtPrice(close / bps) : null
}
default: return null
}
}
const visibleFields = fields.filter(f => f.visible)
// 按是否单独显示分组:普通列共一行,standalone 列各占一行
const inlineFields = visibleFields.filter(f => !f.standalone)
const standaloneFields = visibleFields.filter(f => f.standalone)
// 渲染单个字段(builtin / ext 通用)
const renderField = (f: ColumnConfig): ReactNode => {
if (f.source.type === 'ext') {
const { configId, fieldName } = f.source
const val = extData[`${configId}__${fieldName}`]
// 无值的 ext 字段整体跳过(与 builtin 无数据行为一致)
if (val == null || (typeof val === 'number' && Number.isNaN(val))) return null
const cellKey = `${symbol}::${f.id}`
return (
<span key={f.id} className="inline-flex items-center gap-1">
<span>{f.label}</span>
<span className="text-secondary">
{renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))}
</span>
</span>
)
}
// builtin
const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '')
if (value == null) return null
return (
<span key={f.id}>
{f.label} <span className="text-secondary">{value}</span>
</span>
)
}
return (
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
{/* Row 1: code, name, price, change, change% */}
<div className="flex items-baseline gap-x-3 flex-wrap">
<span className="text-foreground font-bold text-sm tracking-wide">{symbol}</span>
<span className="text-secondary font-medium">{displayName}</span>
<span style={{ color: clr }} className="text-lg font-bold tabular-nums">
{fmtPrice(close)}
</span>
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chg)}
</span>
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
</span>
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
{onToggleWatchlist && (
<button
onClick={onToggleWatchlist}
className={`p-1 rounded-btn transition-colors cursor-pointer ${inWatchlist ? 'text-[#FACC15]' : 'text-muted hover:text-foreground hover:bg-elevated'}`}
title={inWatchlist ? '移出自选' : '加自选'}
>
<Star className="h-3.5 w-3.5" />
</button>
)}
{onMonitor && (
<button
onClick={onMonitor}
className="p-1 rounded-btn text-amber-400 hover:bg-amber-400/10 transition-colors cursor-pointer"
title="加监控"
>
<RadioTower className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => setCustomizerOpen(true)}
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="自定义信息条"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap */}
{inlineFields.length > 0 && (
<div className="flex items-center gap-x-4 gap-y-1 text-[11px] flex-wrap text-muted">
{inlineFields.map(renderField)}
</div>
)}
{/* 单独显示的指标:各占一行 */}
{standaloneFields.map(f => {
const node = renderField(f)
if (node == null) return null
return (
<div key={f.id} className="flex items-center gap-x-4 text-[11px] flex-wrap text-muted">
{node}
</div>
)
})}
<ListColumnCustomizer
columns={fields}
groups={INFO_GROUPS}
onChange={onFieldsChange}
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
title="信息条指标"
builtinSectionLabel="可选指标"
extColumnAlign="left"
showStandaloneToggle
/>
</div>
)
}
@@ -0,0 +1,120 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { EChartsIntraday } from '@/components/EChartsIntraday'
interface Props {
symbol: string
date: string | null
height?: number
prevClose?: number
className?: string
onPriceHover?: (price: number | null) => void
}
export function StockIntradayChart({
symbol,
date,
height = 520,
prevClose,
className,
onPriceHover,
}: Props) {
const qc = useQueryClient()
const [minuteDismissed, setMinuteDismissed] = useState(false)
const minute = useQuery({
queryKey: QK.klineMinute(symbol, date ?? ''),
queryFn: () => api.klineMinute(symbol, date ?? undefined),
enabled: !!symbol && !!date,
})
const fetchMinute = useMutation({
mutationFn: () => api.extendMinuteHistory(5, 'day'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['kline-minute', symbol] })
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
setMinuteDismissed(false)
},
})
const minuteRows: MinuteKlineRow[] = useMemo(() => minute.data?.rows ?? [], [minute.data?.rows])
// source=none 表示本地无数据且 TickFlow 也拉不到 (停牌/复牌延迟/非交易日)
// 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据
const sourceIsNone = minute.data?.source === 'none'
useEffect(() => {
setMinuteDismissed(false)
onPriceHover?.(null)
}, [date, onPriceHover])
if (!symbol || !date) return null
return (
<div className={className} style={{ height, flexShrink: 0 }}>
{minute.isLoading && <div className="text-xs text-muted py-2"></div>}
{!minute.isLoading && minuteRows.length === 0 && (
<>
{fetchMinute.isPending ? (
<div className="flex items-center justify-center h-full gap-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>5K</span>
</div>
) : sourceIsNone ? (
// 数据源确认无此日分钟数据 (停牌/复牌延迟等): 静态提示 + 保留重试
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-xs text-muted"></div>
<button
onClick={() => fetchMinute.mutate()}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs font-medium hover:bg-elevated/80 transition-colors duration-150"
>
</button>
</div>
) : minuteDismissed ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-xs text-muted"></div>
<button
onClick={() => setMinuteDismissed(false)}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors duration-150"
>
K
</button>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-sm text-foreground">5K</div>
<div className="flex items-center gap-3">
<button
onClick={() => fetchMinute.mutate()}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors duration-150"
>
</button>
<button
onClick={() => setMinuteDismissed(true)}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"
>
</button>
</div>
</div>
)}
</>
)}
{minuteRows.length > 0 && (
<EChartsIntraday
data={minuteRows}
height={height}
prevClose={prevClose}
date={date}
symbol={symbol}
onPriceHover={onPriceHover}
/>
)}
</div>
)
}
@@ -0,0 +1,166 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
import { StockInfoBar } from '@/components/StockInfoBar'
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
import { StockIntradayChart } from '@/components/StockIntradayChart'
import { useFinancialMetrics } from '@/lib/useFinancials'
import { useCapabilities } from '@/lib/useSharedQueries'
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import {
loadInfoFields,
saveInfoFields,
buildInfoExtColumnsParam,
type ColumnConfig,
} from '@/lib/stock-info-fields'
interface Props {
symbol: string
height?: number
showIntraday?: boolean
className?: string
/** 当用户点击蜡烛选中日期时回调(用于外部自动开启分时图)。 */
onSelectDate?: (date: string) => void
/** 外部传入的日期范围 */
dateRange?: { start: string; end: string }
markers?: ChartMarker[]
ranges?: ChartRange[]
priceLines?: ChartPriceLine[]
showLimitMarkers?: boolean
showMarkerToggle?: boolean
/** 加监控回调 (传入后信息条显示 RadioTower 图标) */
onMonitor?: () => void
/** 加自选 (传入后信息条显示 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
}
export { getDefaultRange }
export function StockPanel({
symbol,
height = 520,
showIntraday = true,
className,
onSelectDate,
dateRange: externalDateRange,
markers,
ranges,
priceLines,
showLimitMarkers = true,
showMarkerToggle = true,
onMonitor,
inWatchlist,
onToggleWatchlist,
}: Props) {
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields])
const handleFieldsChange = useCallback((next: ColumnConfig[]) => {
setFields(next)
saveInfoFields(next)
}, [])
// 财务指标:仅当信息条配置含可见的财务字段且用户具备 FINANCIAL 能力 (Expert) 时才请求
// 无能力时跳过请求, 避免后端抛 CapabilityDenied (403) 导致 free/starter 档弹错误提示
const { data: caps } = useCapabilities()
const hasFinancialCap = !!caps?.capabilities?.['financial']
const hasFinanceField = useMemo(
() => fields.some(f => f.visible && f.source.type === 'builtin'
&& ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'].includes(f.source.key)),
[fields],
)
const financials = useFinancialMetrics(hasFinanceField && hasFinancialCap ? symbol : undefined)
const dateRange = externalDateRange ?? getDefaultRange()
const handleDateClick = useCallback((date: string) => {
setSelectedDate(date)
onSelectDate?.(date)
}, [onSelectDate])
const rows = dailyResult?.rows ?? []
const stockInfo = dailyResult?.stockInfo
const rawRows: KlineRow[] = dailyResult?.rawRows ?? []
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
// 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存,
// 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据,
// 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。
const prevSymbol = useRef<string | null>(symbol)
useEffect(() => {
if (prevSymbol.current === symbol) return
prevSymbol.current = symbol
setSelectedDate(null)
setLinkedPrice(null)
setDailyResult(null)
}, [symbol])
// 当分时开启、无选中日期时,自动选中最新日期
useEffect(() => {
if (showIntraday && !selectedDate && rows.length > 0) {
setSelectedDate(rows[rows.length - 1].date)
}
}, [showIntraday, selectedDate, rows])
const selectedIdx = selectedDate ? rows.findIndex(r => r.date === selectedDate) : -1
const prevClose = selectedIdx > 0
? rows[selectedIdx - 1].close
: rows.length >= 2
? rows[rows.length - 2].close
: undefined
if (!symbol) return null
// 财务指标最新一期(metrics 按 period_end 排序,取首项)
const financialMetrics: FinancialMetricRecord | undefined = financials.data?.data?.[0]
return (
<div className={className}>
<StockInfoBar
symbol={symbol}
name={dailyResult?.name}
stockInfo={stockInfo}
rows={rawRows}
fields={fields}
onFieldsChange={handleFieldsChange}
financialMetrics={financialMetrics}
onMonitor={onMonitor}
inWatchlist={inWatchlist}
onToggleWatchlist={onToggleWatchlist}
/>
<div className="flex gap-3 items-start">
<StockDailyKChart
symbol={symbol}
height={height}
className="flex-1 min-w-0"
dateRange={dateRange}
markers={markers}
ranges={ranges}
priceLines={priceLines}
showLimitMarkers={showLimitMarkers}
showMarkerToggle={showMarkerToggle}
linkedPrice={linkedPrice}
onDateClick={handleDateClick}
onDataChange={setDailyResult}
visibleBars={showIntraday ? 40 : 60}
extColumns={extColumns}
/>
{showIntraday && selectedDate && (
<StockIntradayChart
symbol={symbol}
date={selectedDate}
height={height}
prevClose={prevClose}
onPriceHover={setLinkedPrice}
className="flex-1 min-w-0 border-l border-border pl-3"
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,279 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { X, RefreshCw, Clock } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cnSignal } from '@/lib/signals'
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
import { DatePicker } from '@/components/DatePicker'
import { RuleEditor } from '@/components/monitor/RuleEditor'
interface Props {
symbol: string | null
name?: string
onClose: () => void
/** 触发信息 (来自监控触发记录, 有值时在顶栏下方显示) */
triggerInfo?: {
price?: number | null
changePct?: number | null
ts?: number
signals?: string[]
message?: string
} | null
}
// ===== 板块标识(与 Screener 列表一致)=====
// 预设快捷范围(只保留半年和1年)
const PRESETS: { label: string; months: number }[] = [
{ label: '半年', months: 6 },
{ label: '1年', months: 12 },
]
function boardTag(symbol: string): { label: string; color: string } | null {
if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' }
if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' }
if (/^[48]/.test(symbol)) return { label: '北', color: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' }
return null
}
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
const [showIntraday, setShowIntraday] = useState(false)
const [dateRange, setDateRange] = useState(getDefaultRange)
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
const qc = useQueryClient()
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
enabled: !!symbol,
})
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
const toggleWatchlist = useMutation({
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
// ESC 关闭
useEffect(() => {
if (!symbol) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [symbol, onClose])
const handleRefresh = () => {
if (!symbol) return
qc.invalidateQueries({ queryKey: ['kline', symbol!] })
if (showIntraday) {
qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] })
}
}
return (
<AnimatePresence>
{symbol && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 遮罩 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={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 w-[92vw] max-w-[1100px] max-h-[95vh] rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col"
>
{/* 顶栏 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
{(() => {
const board = symbol ? boardTag(symbol) : null
return board ? (
<span className={`inline-flex items-center justify-center w-[18px] h-[18px] rounded text-[9px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
) : null
})()}
<span className="font-mono text-sm font-medium text-foreground">{symbol}</span>
{name && <span className="text-xs text-muted">{name}</span>}
</div>
<div className="flex items-center gap-1.5">
{/* 日期范围快捷 */}
{PRESETS.map(p => {
const now = new Date()
const s = new Date(now)
s.setMonth(s.getMonth() - p.months)
const expected = s.toISOString().slice(0, 10)
const isActive = dateRange.start === expected
return (
<button
key={p.label}
onClick={() => {
const end = new Date().toISOString().slice(0, 10)
const ns = new Date()
ns.setMonth(ns.getMonth() - p.months)
setDateRange({ start: ns.toISOString().slice(0, 10), end })
}}
className={`h-6 px-1.5 rounded text-[11px] transition-colors cursor-pointer
${isActive
? 'bg-accent/20 text-accent font-medium border border-accent/30'
: 'text-muted hover:text-foreground hover:bg-elevated border border-transparent'
}`}
>
{p.label}
</button>
)
})}
<DatePicker
value={dateRange.start}
onChange={(v) => setDateRange(prev => ({ ...prev, start: v }))}
max={dateRange.end}
/>
<span className="text-muted/40 text-[10px]">~</span>
<DatePicker
value={dateRange.end}
onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))}
min={dateRange.start}
/>
<span className="text-muted/20 mx-0.5">|</span>
{/* 分时开关 */}
<button
onClick={() => setShowIntraday((v) => !v)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors ${
showIntraday
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary border border-border hover:border-accent/30'
}`}
>
<Clock className="h-3 w-3" />
</button>
<span className="text-muted/20 mx-0.5">|</span>
{/* 刷新 */}
<button
onClick={handleRefresh}
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
title="刷新"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
{/* 关闭 */}
<button
onClick={onClose}
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 触发信息条 (来自监控触发记录) */}
{triggerInfo && (
<div className="flex items-center gap-4 border-b border-amber-400/20 bg-amber-400/[0.06] px-5 py-2 shrink-0">
{/* 左: 触发标记 + 时间 */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-semibold text-amber-400"> </span>
{triggerInfo.ts && (
<span className="text-[11px] text-secondary font-mono">
{new Date(triggerInfo.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
{/* 中: 价格 + 涨跌幅 */}
<div className="flex items-center gap-2 shrink-0">
{triggerInfo.price != null && (
<span className="text-[11px] font-mono text-foreground/80">{triggerInfo.price.toFixed(2)}</span>
)}
{triggerInfo.changePct != null && (
<span className={`text-[11px] font-mono font-medium ${triggerInfo.changePct >= 0 ? 'text-danger' : 'text-bear'}`}>
{triggerInfo.changePct >= 0 ? '+' : ''}{(triggerInfo.changePct * 100).toFixed(2)}%
</span>
)}
</div>
{/* 右: 消息 + 信号标签 */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
{triggerInfo.message && (
<span className="text-[11px] text-foreground/70 truncate">{triggerInfo.message}</span>
)}
{triggerInfo.signals && triggerInfo.signals.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{triggerInfo.signals.map((s, j) => (
<span key={j} className="rounded bg-accent/10 px-1.5 py-0.5 text-[9px] text-accent/80">{cnSignal(s)}</span>
))}
</div>
)}
</div>
</div>
)}
{/* K 线内容 */}
<div className="flex-1 overflow-auto p-4">
<StockPanel
symbol={symbol}
height={420}
showIntraday={showIntraday}
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
dateRange={dateRange}
onMonitor={() => setShowMonitorEditor(true)}
inWatchlist={inWatchlist}
onToggleWatchlist={() => toggleWatchlist.mutate()}
/>
</div>
{/* 加监控编辑器弹层 */}
<AnimatePresence>
{showMonitorEditor && symbol && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-20 flex items-start justify-center overflow-auto bg-black/40 p-4"
onClick={() => setShowMonitorEditor(false)}
>
<div className="mt-8 w-full max-w-2xl" onClick={e => e.stopPropagation()}>
<RuleEditor
rule={null}
simple
preset={{
scope: 'symbols',
symbols: [symbol],
type: 'signal',
logic: 'or',
}}
onClose={() => setShowMonitorEditor(false)}
onSaved={() => setShowMonitorEditor(false)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState } from 'react'
// ===== 全局 toast 状态 =====
type ToastItem = { id: number; msg: string; kind: 'error' | 'success' }
let _id = 0
const _listeners: Set<(items: ToastItem[]) => void> = new Set()
let _queue: ToastItem[] = []
function _emit() { _listeners.forEach(fn => fn([..._queue])) }
function toast(msg: string, kind: 'error' | 'success' = 'error') {
const item = { id: ++_id, msg, kind }
_queue = [..._queue, item]
_emit()
setTimeout(() => { _queue = _queue.filter(t => t.id !== item.id); _emit() }, 4000)
}
export { toast }
// ===== Toast 容器 — 挂在 Layout 最顶层 =====
export function ToastContainer() {
const [items, setItems] = useState<ToastItem[]>([])
const sub = useCallback(() => {
_listeners.add(setItems)
return () => { _listeners.delete(setItems) }
}, [])
useEffect(sub, [sub])
if (!items.length) return null
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
{items.map(t => (
<div
key={t.id}
className={`pointer-events-auto px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 fade-in duration-200 ${
t.kind === 'error'
? 'bg-red-500/90 text-white'
: 'bg-emerald-500/90 text-white'
}`}
>
{t.msg}
</div>
))}
</div>
)
}
@@ -0,0 +1,106 @@
/**
* 回测预热期徽标 — 点击弹出说明气泡。
*
* 解释「回测开头几个月没有交易」这一高频疑问: 技术指标需要历史数据预热,
* 系统会自动在回测起点之前多取约 120 天 (≈4 个月) 数据; 若本地数据恰好从
* 起点才开始, 开头几个月指标算不出、信号不触发, 属正常现象。
*
* 实现要点:
* - 点击触发 (非 hover), 移动端友好
* - 用 createPortal 渲染到 body, 绕开父容器 overflow 裁剪 (回测配置面板有 overflow-y-auto)
* - 全屏透明遮罩点击关闭 + ESC 关闭
* - 气泡位置 = 锚点 rect 实时计算, 自动判断向左/向右展开避免溢出屏幕
*/
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
interface Pos { top: number; left: number }
export function WarmupBadge() {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLButtonElement>(null)
const [pos, setPos] = useState<Pos>({ top: 0, left: 0 })
// 打开时根据锚点 rect 计算气泡位置 (向下方弹出)
useLayoutEffect(() => {
if (!open || !anchorRef.current) return
const rect = anchorRef.current.getBoundingClientRect()
const POPUP_W = 272
const GAP = 8
// 优先左对齐锚点; 右侧不够则右对齐; 兜底贴左边
let left = rect.left
if (left + POPUP_W > window.innerWidth - 8) {
left = rect.right - POPUP_W
}
left = Math.max(8, left)
setPos({ top: rect.bottom + GAP, left })
}, [open])
// ESC 关闭
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
return (
<>
<button
ref={anchorRef}
type="button"
onClick={() => setOpen(o => !o)}
className="inline-flex items-center gap-0.5 rounded-full px-1.5 text-[10px] text-amber-500/70 transition-colors hover:bg-amber-400/10 hover:text-amber-500"
title="为什么开头可能没交易?"
>
<Info className="h-3 w-3" strokeWidth={1.5} />
120
</button>
{createPortal(
<AnimatePresence>
{open && (
<>
{/* 全屏透明遮罩: 点击关闭 */}
<div
className="fixed inset-0 z-[60]"
onClick={() => setOpen(false)}
/>
{/* 气泡: 绝对定位到 body, 绕开 overflow 裁剪 */}
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15 }}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: 272 }}
className="z-[70] rounded-btn border border-border bg-surface p-3 text-[11px] leading-relaxed text-secondary shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="mb-1.5 font-medium text-foreground">?</div>
<p className="text-muted">
(MA / MACD / RSI )
<span className="font-medium text-amber-300"> 120 (4 )</span>
</p>
<p className="mt-1.5 text-muted">
, ,
<span className="text-secondary"> , bug</span>
</p>
<div className="mt-2 border-t border-border/60 pt-2 text-muted">
<span className="text-secondary">:</span> ,
</div>
{/* 小箭头指向锚点 */}
<div
className="absolute -top-1 h-2 w-2 rotate-45 border-l border-t border-border bg-surface"
style={{ left: 12 }}
/>
</motion.div>
</>
)}
</AnimatePresence>,
document.body,
)}
</>
)
}
@@ -0,0 +1,501 @@
/**
* 概念/行业分析 — 共享 UI 组件
*
* 包含:
* - AnalysisConfigDialog: 字段配置弹窗
* - DimensionHeatmap: 维度热力图块
* - OverviewStatCards: 总览统计卡片
* - DimensionGroupSidebar: 维度分组侧边栏
*/
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import {
AlertCircle,
BarChart3,
ChevronDown,
Database,
DownloadCloud,
RefreshCw,
Search,
Settings2,
Tags,
TrendingUp,
X,
} from 'lucide-react'
import { api, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import type { DimensionGroup, QuoteMap } from '@/lib/analysis-adapter'
import { computeQuoteMetrics } from '@/lib/analysis-adapter'
import { fmtPct, priceColorClass } from '@/lib/format'
// ===== 配置类型 =====
export interface AnalysisFieldConfig {
/** 选中的扩展数据源 ID */
configId?: string
/** 手动指定的维度字段(覆盖自动探测) */
dimensionField?: string
/** 层级字段统计级别(如行业 1/2/3 级) */
hierarchyLevel?: 1 | 2 | 3
}
export interface SchemaOption {
id: string
label: string
columns: { name: string; type: string; label: string }[]
}
// ===== 配置弹窗 =====
export function AnalysisConfigDialog({
currentConfig,
onSave,
onClose,
showHierarchyLevel = false,
}: {
currentConfig: AnalysisFieldConfig
onSave: (config: AnalysisFieldConfig) => void
onClose: () => void
showHierarchyLevel?: boolean
}) {
const [draft, setDraft] = useState<AnalysisFieldConfig>(currentConfig)
const { data: extList } = useQuery({
queryKey: QK.extData,
queryFn: api.extDataList,
})
const { data: schemaData } = useQuery({
queryKey: QK.extDataSchemaAll,
queryFn: api.extDataSchemaAll,
})
const configs = extList?.items ?? []
const fieldsForConfig = useMemo((): ExtDataField[] => {
if (!draft.configId || !schemaData?.items) return []
const schema = schemaData.items.find(s => s.id === draft.configId)
return schema?.columns.map(c => ({ name: c.name, dtype: c.type, label: c.label })) ?? []
}, [draft.configId, schemaData])
const nonMetaFields = fieldsForConfig.filter(
f => !['symbol', 'code', 'date', 'name'].includes(f.name),
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-surface border border-border rounded-lg shadow-xl w-[420px]"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<span className="text-sm font-medium"></span>
<button onClick={onClose} className="p-0.5 text-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 pb-4 space-y-3">
{/* 数据源选择 */}
<div className="space-y-1.5">
<span className="text-xs text-secondary"></span>
<select
value={draft.configId ?? ''}
onChange={e => setDraft(d => ({ ...d, configId: e.target.value || undefined, dimensionField: undefined }))}
className="w-full h-8 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
>
<option value=""></option>
{configs.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
</div>
{/* 维度字段选择 */}
{nonMetaFields.length > 0 && (
<div className="space-y-1.5">
<span className="text-xs text-secondary"></span>
<select
value={draft.dimensionField ?? ''}
onChange={e => setDraft(d => ({ ...d, dimensionField: e.target.value || undefined }))}
className="w-full h-8 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
>
<option value=""></option>
{nonMetaFields.map(f => (
<option key={f.name} value={f.name}>{f.label || f.name}</option>
))}
</select>
</div>
)}
{showHierarchyLevel && (
<div className="space-y-1.5">
<span className="text-xs text-secondary"></span>
<select
value={draft.hierarchyLevel ?? 2}
onChange={e => setDraft(d => ({ ...d, hierarchyLevel: Number(e.target.value) as 1 | 2 | 3 }))}
className="w-full h-8 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
>
<option value={1}></option>
<option value={2}></option>
<option value={3}></option>
</select>
</div>
)}
<div className="text-[10px] text-muted leading-relaxed">
"个股→概念/行业""概念/行业→成分股列表"
{showHierarchyLevel ? ' 行业字段按 “-” 拆分为 1/2/3 级。' : ''}
</div>
</div>
<div className="flex justify-end gap-2 px-4 py-3 border-t border-border">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-secondary hover:text-foreground"></button>
<button
onClick={() => { onSave(draft); onClose() }}
className="px-3 py-1.5 text-xs bg-accent/15 text-accent rounded hover:bg-accent/25"
>
</button>
</div>
</motion.div>
</div>
)
}
// ===== 总览统计卡片 =====
export function OverviewStatCards({
groups,
totalStocks,
configLabel,
dateStr,
accentColor,
}: {
groups: DimensionGroup[]
totalStocks: number
configLabel: string
dateStr?: string | null
accentColor: string
}) {
const cards = [
{ label: '数据源', value: configLabel, hint: dateStr ?? '快照', icon: Database },
{ label: '维度数量', value: groups.length, hint: '按维度聚合分组', icon: Tags },
{ label: '覆盖标的', value: totalStocks, hint: '去重股票数', icon: BarChart3 },
{
label: '最大分组',
value: groups[0]?.key ?? '—',
hint: groups[0] ? `${groups[0].count}` : '',
icon: TrendingUp,
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{cards.map(card => (
<div key={card.label} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-muted">{card.label}</span>
<card.icon className="h-4 w-4" style={{ color: accentColor }} />
</div>
<div className="mt-2 text-xl font-semibold tracking-tight text-foreground truncate">
{card.value}
</div>
<div className="mt-1 text-[11px] text-muted truncate">{card.hint}</div>
</div>
))}
</div>
)
}
// ===== 维度热力图 =====
export function DimensionHeatmap({
groups,
quoteMap,
selectedKey,
onSelect,
colorScheme,
}: {
groups: DimensionGroup[]
quoteMap: Map<string, QuoteMap>
selectedKey: string | null
onSelect: (key: string | null) => void
colorScheme: 'blue' | 'amber'
}) {
const [showAll, setShowAll] = useState(false)
// 收起时最大高度(约 3 行标签高度)
const collapsedMaxH = '8rem'
const enriched = useMemo(() => {
return groups.map(g => {
const qm = computeQuoteMetrics(g.stocks, quoteMap)
return { ...g, quoteMetrics: qm }
})
}, [groups, quoteMap])
// 根据涨跌比渲染颜色强度
const colors = colorScheme === 'blue'
? { up: [59, 130, 246], down: [96, 165, 250], bg: [30, 64, 175] }
: { up: [245, 158, 11], down: [251, 191, 36], bg: [180, 83, 9] }
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted"></span>
<button
onClick={() => setShowAll(v => !v)}
className="text-[10px] text-muted hover:text-foreground flex items-center gap-0.5"
>
{showAll ? '收起' : `展开全部 (${enriched.length})`}
<ChevronDown className={`h-3 w-3 transition-transform ${showAll ? 'rotate-180' : ''}`} />
</button>
</div>
<div
className="flex flex-wrap gap-1.5 overflow-hidden transition-[max-height] duration-200"
style={{ maxHeight: showAll ? 'none' : collapsedMaxH }}
>
{enriched.map(g => {
const qm = g.quoteMetrics
const total = qm.upCount + qm.downCount + qm.flatCount
const upRatio = total > 0 ? qm.upCount / total : 0.5
const size = Math.max(0.6, Math.min(1.4, g.count / (enriched[0]?.count || 1)))
const active = selectedKey === g.key
const [r, gr, b] = colors.up
return (
<button
key={g.key}
onClick={() => onSelect(active ? null : g.key)}
className="px-2.5 py-1.5 rounded-sm text-[11px] whitespace-nowrap cursor-pointer hover:brightness-125 transition-all"
style={{
fontSize: `${10 + size * 2}px`,
color: active ? '#fff' : `rgba(${r},${gr},${b},${0.6 + upRatio * 0.4})`,
backgroundColor: active
? `rgba(${r},${gr},${b},0.7)`
: `rgba(${r},${gr},${b},${0.08 + upRatio * 0.15})`,
outline: active ? `1px solid rgba(${r},${gr},${b},0.8)` : 'none',
outlineOffset: 1,
}}
title={`${g.key}: ${g.count}只, 涨${qm.upCount}/跌${qm.downCount}, 均幅${qm.avgPct != null ? fmtPct(qm.avgPct) : '—'}`}
>
{g.key}
<span className="ml-1 opacity-60">{g.count}</span>
{qm.avgPct != null && (
<span className={`ml-1 ${priceColorClass(qm.avgPct)}`} style={{ fontSize: '9px' }}>
{fmtPct(qm.avgPct)}
</span>
)}
</button>
)
})}
</div>
</div>
)
}
// ===== 维度分组侧边栏 =====
export function DimensionGroupSidebar({
groups,
quoteMap,
selectedKey,
onSelect,
searchValue,
onSearchChange,
kindLabel,
colorScheme,
}: {
groups: DimensionGroup[]
quoteMap: Map<string, QuoteMap>
selectedKey: string | null
onSelect: (key: string) => void
searchValue: string
onSearchChange: (v: string) => void
kindLabel: string
colorScheme: 'blue' | 'amber'
}) {
const q = searchValue.trim().toLowerCase()
const filtered = q
? groups.filter(g => g.key.toLowerCase().includes(q))
: groups
const accentColor = colorScheme === 'blue' ? 'rgba(59,130,246,0.7)' : 'rgba(245,158,11,0.7)'
const accentBg = colorScheme === 'blue' ? 'rgba(59,130,246,0.1)' : 'rgba(245,158,11,0.1)'
const accentBorder = colorScheme === 'blue' ? 'rgba(59,130,246,0.25)' : 'rgba(245,158,11,0.25)'
return (
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{kindLabel}</h3>
<p className="mt-0.5 text-[11px] text-muted"></p>
</div>
<div className="p-3 border-b border-border/60">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
placeholder={`搜索${kindLabel}`}
className="h-8 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/50"
/>
</div>
</div>
<div className="max-h-[560px] overflow-auto p-2 space-y-1">
{filtered.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted"></div>
) : filtered.slice(0, 200).map((group, i) => {
const active = selectedKey === group.key
const qm = computeQuoteMetrics(group.stocks, quoteMap)
return (
<button
key={group.key}
onClick={() => onSelect(group.key)}
className={cn(
'w-full rounded-lg px-3 py-2 text-left transition-colors',
active ? 'border' : 'border border-transparent hover:bg-elevated/60',
)}
style={active ? { backgroundColor: accentBg, borderColor: accentBorder } : undefined}
>
<div className="flex items-center gap-2">
<span className="w-5 text-[10px] font-mono text-muted">#{i + 1}</span>
<span className="flex-1 truncate text-xs font-medium text-foreground">{group.key}</span>
<span className="font-mono text-xs text-secondary">{group.count}</span>
</div>
{/* 覆盖量进度条 */}
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.max(6, (group.count / (filtered[0]?.count || 1)) * 100)}%`,
backgroundColor: accentColor,
}}
/>
</div>
{/* 行情摘要 */}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-muted">
{qm.avgPct != null && (
<span className={priceColorClass(qm.avgPct)}>
{fmtPct(qm.avgPct)}
</span>
)}
{qm.upCount + qm.downCount > 0 && (
<span>
<span className="text-bull">{qm.upCount}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{qm.downCount}</span>
</span>
)}
</div>
</button>
)
})}
</div>
</section>
)
}
// ===== 行情摘要条 =====
export function QuoteSummaryBar({
groups,
quoteMap,
}: {
groups: DimensionGroup[]
quoteMap: Map<string, QuoteMap>
}) {
const stats = useMemo(() => {
let upTotal = 0, downTotal = 0, flatTotal = 0
for (const g of groups) {
const qm = computeQuoteMetrics(g.stocks, quoteMap)
upTotal += qm.upCount
downTotal += qm.downCount
flatTotal += qm.flatCount
}
return { upTotal, downTotal, flatTotal }
}, [groups, quoteMap])
const total = stats.upTotal + stats.downTotal + stats.flatTotal
if (total === 0) return null
const upPct = (stats.upTotal / total) * 100
const downPct = (stats.downTotal / total) * 100
return (
<div className="flex items-center gap-3 text-[11px]">
<div className="flex-1 h-2 rounded-full overflow-hidden bg-elevated flex">
<div className="h-full bg-bull/70 rounded-l-full" style={{ width: `${upPct}%` }} />
<div className="flex-1" />
<div className="h-full bg-bear/70 rounded-r-full" style={{ width: `${downPct}%` }} />
</div>
<span className="text-bull font-mono">{stats.upTotal}</span>
<span className="text-muted">/</span>
<span className="text-bear font-mono">{stats.downTotal}</span>
</div>
)
}
// ===== 配置按钮 =====
export function ConfigButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="p-1.5 hover:bg-surface text-muted hover:text-accent transition-colors"
title="配置数据源"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
)
}
/**
* 内置预设 (概念/行业) 数据获取空状态。
*
* 当检测到内置预设存在但无数据时, 展示图标 + 提示 + 「获取数据」按钮,
* 让用户手动触发拉取 (POST /api/ext-data/presets/{id}/fetch), 而非自动拉取。
*/
export function PresetFetchState({
title,
hint,
isLoading,
error,
onFetch,
}: {
title: string
hint: string
isLoading: boolean
error: unknown
onFetch: () => void
}) {
const errMsg = error instanceof Error ? error.message : error ? String(error) : ''
return (
<div className="h-full grid place-items-center px-8 py-16">
<div className="text-center max-w-md">
<DownloadCloud className="mx-auto h-10 w-10 text-muted" strokeWidth={1.5} />
<h2 className="mt-4 text-base font-medium text-foreground">{title}</h2>
<p className="mt-2 text-sm text-secondary leading-relaxed">{hint}</p>
<button
onClick={onFetch}
disabled={isLoading}
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-110 disabled:opacity-60"
>
{isLoading ? (
<><RefreshCw className="h-4 w-4 animate-spin" /> ...</>
) : (
<><DownloadCloud className="h-4 w-4" /> </>
)}
</button>
{errMsg && (
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-bear">
<AlertCircle className="h-3.5 w-3.5" /> {errMsg}
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,135 @@
import { useRef, useMemo, useEffect } from 'react'
import { motion } from 'framer-motion'
import { Loader2, CheckCircle2, XCircle } from 'lucide-react'
import { formatDuration, formatLogTime } from '@/lib/format'
import { Pill } from './StatCard'
import type { PipelineJob } from '@/lib/api'
export const STAGE_LABELS: Record<string, string> = {
init: '初始化',
resolve_universe: '解析标的池',
sync_instruments: '同步个股维表',
sync_daily: '同步日 K',
sync_adj: '同步除权因子',
compute_enriched: '计算技术指标',
sync_minute: '同步分钟 K',
extend_history: '扩展日K历史',
extend_minute: '扩展分钟K历史',
rebuild_enriched: '全量计算',
refresh_views: '刷新视图',
done: '完成',
}
function LogViewer({ log }: { log: PipelineJob['log'] }) {
const containerRef = useRef<HTMLDivElement>(null)
const displayLog = useMemo(() => {
if (!log.length) return []
return log.filter((line, i) => {
const next = log[i + 1]
if (
next &&
line.stage === next.stage &&
/\d+\/\d+/.test(line.msg) &&
/\d+\/\d+/.test(next.msg)
) {
return false
}
return true
})
}, [log])
useEffect(() => {
const el = containerRef.current
if (el) el.scrollTop = el.scrollHeight
}, [displayLog])
return (
<div ref={containerRef} className="rounded-btn bg-base/60 border border-border max-h-48 overflow-y-auto px-3 py-2 font-mono text-[11px] space-y-0.5">
{displayLog.map((line, i) => (
<div key={`${line.ts}-${i}`} className="flex gap-2 text-secondary">
<span className="text-muted shrink-0">{formatLogTime(line.ts)}</span>
<span className="text-accent/70 shrink-0">[{STAGE_LABELS[line.stage] ?? line.stage}]</span>
<span>{line.msg}</span>
</div>
))}
{displayLog.length === 0 && <div className="text-muted"></div>}
</div>
)
}
export function ActiveJobCard({ job }: { job: PipelineJob }) {
const statusMap = {
running: { icon: Loader2, color: 'text-accent', label: '运行中', spinning: true, border: 'border-accent/40', bg: 'bg-accent/5' },
pending: { icon: Loader2, color: 'text-muted', label: '排队中', spinning: true, border: 'border-border', bg: 'bg-surface' },
succeeded: { icon: CheckCircle2, color: 'text-bear', label: '完成', spinning: false, border: 'border-bear/30', bg: 'bg-bear/5' },
failed: { icon: XCircle, color: 'text-danger', label: '失败', spinning: false, border: 'border-danger/40', bg: 'bg-danger/5' },
} as const
const meta = statusMap[job.status]
const Icon = meta.icon
const isDone = job.status === 'succeeded' || job.status === 'failed'
const stageLabel = isDone ? meta.label : (STAGE_LABELS[job.stage] ?? job.stage)
return (
<div className={`rounded-card border ${meta.border} ${meta.bg} p-5`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Icon className={`h-5 w-5 ${meta.color} ${meta.spinning ? 'animate-spin' : ''}`} />
<div>
<div className="text-sm font-medium text-foreground">
{meta.label}{!isDone && ` · ${stageLabel}`}
</div>
<div className="text-xs text-secondary font-mono mt-0.5">
{job.id} · {job.duration_s != null ? formatDuration(job.duration_s) : '进行中'}
</div>
</div>
</div>
{!isDone && (
<div className="font-mono text-2xl font-bold tracking-tight">
{job.progress}<span className="text-base text-muted">%</span>
</div>
)}
</div>
{!isDone && (
<div className="mb-2">
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
<motion.div
className="h-full bg-accent"
animate={{ width: `${job.progress}%` }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
/>
</div>
{job.stage_pct > 0 && (
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-muted"></span>
<span className="text-[10px] font-mono text-secondary">{job.stage_pct}%</span>
</div>
)}
</div>
)}
<LogViewer log={job.log} />
{job.status === 'succeeded' && job.result && (() => {
const skipped = new Set(job.result.skipped_stages ?? [])
const cell = (stage: string | null, v: string) =>
stage && skipped.has(stage) ? '跳过' : v
return (
<div className="mt-3 grid grid-cols-2 md:grid-cols-5 gap-3 text-xs">
<Pill label="标的池" value={job.result.universe_size ?? '—'} />
<Pill label="日 K" value={cell(null, `${job.result.daily_days ?? 0}`)} />
<Pill label="除权因子" value={cell('sync_adj', `${job.result.adj_factor_symbols ?? 0}`)} />
<Pill label="enriched" value={cell(null, `${job.result.enriched_days ?? 0}`)} />
<Pill label="分钟K" value={cell('sync_minute', `${job.result.minute_rows ?? 0}`)} />
</div>
)
})()}
{job.status === 'failed' && job.error && (
<div className="mt-3 rounded-btn border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger">
{job.error}
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences, useCapabilities } from '@/lib/useSharedQueries'
import { isExpertOrAbove } from '@/lib/capability-labels'
/**
* 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。
*
* - 轮询间隔: Pro 10~120s / Expert 3~300s
* - 盘后定版时间: 15:01~18:00, 默认 15:02
* - disabled 时(监控关闭)输入框禁用
*/
// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框)
export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
const qc = useQueryClient()
const prefs = usePreferences()
const caps = useCapabilities()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const tierLabel = caps.data?.label ?? ''
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 }
const interval = prefs.data?.depth_polling_interval ?? 20
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
const [intervalInput, setIntervalInput] = useState(String(Math.round(interval)))
const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour))
const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute))
useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval])
useEffect(() => {
setFinalizeHour(String(finalizeTime.hour))
setFinalizeMinute(String(finalizeTime.minute))
}, [finalizeTime.hour, finalizeTime.minute])
const saveInterval = useMutation({
mutationFn: (v: number) => api.updateDepthPollingInterval(v),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const saveFinalize = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateDepthFinalizeTime(hour, minute),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
// 无能力: 显示升级提示
if (!hasDepth) {
return (
<p className="text-xs text-muted leading-relaxed">
, <span className="text-accent">Pro </span>
()()
</p>
)
}
const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`
return (
<div className="space-y-3">
{/* 盘中轮询间隔 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> {range.lo}~{range.hi} · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={range.lo}
max={range.hi}
value={intervalInput}
disabled={disabled}
onChange={e => setIntervalInput(e.target.value)}
onBlur={() => {
if (disabled) return
let v = Number(intervalInput)
if (!Number.isFinite(v)) v = range.lo
v = Math.max(range.lo, Math.min(range.hi, v))
saveInterval.mutate(v)
}}
className={inputCls}
/>
<span className="text-xs text-muted"></span>
</div>
</div>
{/* 盘后定版时间 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> 15:01~18:00 · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={15}
max={18}
value={finalizeHour}
disabled={disabled}
onChange={e => setFinalizeHour(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
<span className="text-xs text-muted">:</span>
<input
type="number"
min={0}
max={59}
value={finalizeMinute}
disabled={disabled}
onChange={e => setFinalizeMinute(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
export function EnrichedRebuildPanel({ isRunning, onStart }: { isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
const prefs = usePreferences()
const batchSize = prefs.data?.enriched_batch_size ?? 1000
const [editing, setEditing] = useState(false)
const [draftSize, setDraftSize] = useState(String(batchSize))
const [hint, setHint] = useState<string | null>(null)
function clampAndSave(raw: number) {
if (isNaN(raw) || 1 > raw) { setHint('已自动设为最小值 1'); saveBatch.mutate(1); return }
if (raw > 10000) { setHint('已自动设为上限 10000'); saveBatch.mutate(10000); return }
setHint(null); saveBatch.mutate(raw)
}
const saveBatch = useMutation({
mutationFn: (size: number) => api.updateEnrichedBatchSize(size),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
setEditing(false)
},
})
const rebuild = useMutation({
mutationFn: api.rebuildEnriched,
onSuccess: () => {
onStart()
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
},
})
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-foreground"></div>
<div className="text-[10px] text-muted"></div>
</div>
{editing ? (
<div className="flex items-center gap-1.5">
<input
type="number"
value={draftSize}
onChange={e => setDraftSize(e.target.value)}
className="w-20 px-2 py-1 text-xs font-mono rounded-btn border border-border bg-surface text-foreground text-right tabular-nums focus:outline-none focus:border-accent"
min={1}
max={10000}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') clampAndSave(parseInt(draftSize))
if (e.key === 'Escape') { setEditing(false); setHint(null) }
}}
/>
<button
onClick={() => clampAndSave(parseInt(draftSize))}
disabled={saveBatch.isPending}
className="px-2 py-1 text-[10px] rounded-btn bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50 transition-colors"
>
{saveBatch.isPending ? '…' : '保存'}
</button>
<button
onClick={() => setEditing(false)}
className="px-2 py-1 text-[10px] rounded-btn bg-elevated text-muted hover:text-foreground transition-colors"
>
</button>
</div>
) : (
<button
onClick={() => { setDraftSize(String(batchSize)); setEditing(true) }}
className="px-2.5 py-1 rounded-btn border border-border bg-surface text-xs font-mono text-foreground hover:border-accent/50 transition-colors tabular-nums"
>
{batchSize} /
</button>
)}
</div>
<div className="flex items-start gap-1.5 px-3 py-1.5 rounded-btn bg-warning/10 border border-warning/20">
<span className="text-[10px] text-warning leading-relaxed">
= × K历史天数K历史越长
</span>
</div>
{hint && (
<div className="px-3 py-1 rounded-btn bg-accent/10 border border-accent/20 text-[10px] text-accent">
{hint}
</div>
)}
</div>
<div>
<div className="text-[10px] text-muted mb-2"> kline_daily + adj_factor + + </div>
<button
onClick={() => rebuild.mutate()}
disabled={isRunning || rebuild.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{rebuild.isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /></>
) : (
<></>
)}
</button>
</div>
</div>
)
}
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
export function ExtendHistoryPanel({ caps, isRunning, earliestDate, onStart }: {
caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined
isRunning: boolean
earliestDate: string | null
onStart: () => void
}) {
const qc = useQueryClient()
const [value, setValue] = useState(6)
const [unit, setUnit] = useState<'month' | 'year'>('month')
const hasBatchCap = !!caps?.capabilities?.['kline.daily.batch']
const extend = useMutation({
mutationFn: () => api.extendHistory(value, unit),
onSuccess: () => {
onStart()
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
},
})
const offsetDays = unit === 'month' ? value * 30 : value * 365
const estimate = earliestDate
? (() => {
const d = new Date(earliestDate)
d.setDate(d.getDate() - offsetDays)
return d.toISOString().slice(0, 10)
})()
: null
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
<div className="text-[10px] text-secondary"></div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setValue(Math.max(1, value - 1))}
disabled={!hasBatchCap || isRunning}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{value}
</div>
<button
onClick={() => setValue(Math.min(unit === 'year' ? 10 : 36, value + 1))}
disabled={!hasBatchCap || isRunning}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
<div className="flex rounded-btn border border-border overflow-hidden">
{(['month', 'year'] as const).map(u => (
<button
key={u}
onClick={() => { setUnit(u); if (u === 'year' && value > 10) setValue(1); if (u === 'month' && value > 36) setValue(6) }}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'month' ? '月' : '年'}</button>
))}
</div>
</div>
{estimate && (
<div className="text-[10px] text-muted">
<span className="font-mono text-secondary">{estimate}</span>
{earliestDate && <span> (: <span className="font-mono text-secondary">{earliestDate}</span>)</span>}
</div>
)}
<button
onClick={() => extend.mutate()}
disabled={!hasBatchCap || isRunning || extend.isPending || !earliestDate}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{extend.isPending ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
</>
) : (
<></>
)}
</button>
{!hasBatchCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Pro+
</span>
)}
</div>
)
}
@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { isExpertOrAbove } from '@/lib/capability-labels'
export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
const prefs = useQuery({
queryKey: QK.preferences,
queryFn: api.preferences,
})
const update = useMutation({
mutationFn: ({ enabled, days }: { enabled: boolean; days: number }) =>
api.updateMinuteSync(enabled, days),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch']
const enabled = prefs.data?.minute_sync_enabled ?? false
const days = prefs.data?.minute_sync_days ?? 5
const [localDays, setLocalDays] = useState(days)
useEffect(() => { setLocalDays(days) }, [days])
const handleToggle = () => {
if (!hasMinuteCap) return
update.mutate({ enabled: !enabled, days: localDays })
}
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<button
onClick={handleToggle}
disabled={!hasMinuteCap}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 shrink-0 ${
enabled ? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.3)]' : 'bg-elevated'
} ${!hasMinuteCap ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
enabled ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</button>
<span className="text-xs text-foreground font-medium">
{enabled ? '自动同步' : '已关闭'}
</span>
</div>
{!hasMinuteCap && (
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
Pro+
</span>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-secondary"></span>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => { const v = Math.max(1, localDays - 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
disabled={!hasMinuteCap || !enabled || localDays <= 1}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>
</button>
<div
className={`h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums ${
enabled ? 'text-foreground bg-base' : 'text-muted bg-elevated/50'
}`}
>
{localDays}
</div>
<button
onClick={() => { const v = Math.min(15, localDays + 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
disabled={!hasMinuteCap || !enabled || localDays >= 15}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>
+
</button>
</div>
<span className="text-[10px] text-muted"></span>
</div>
</div>
<div className="pt-2 border-t border-border space-y-2.5">
<div className="text-[10px] text-secondary"></div>
<MinuteExtendControls hasMinuteCap={hasMinuteCap} tierLabel={caps?.label ?? ''} isRunning={isRunning} onStart={onStart} />
</div>
<div className="text-[10px] text-muted">
A股标的 · ()
</div>
</div>
)
}
function MinuteExtendControls({ hasMinuteCap, tierLabel, isRunning, onStart }: { hasMinuteCap: boolean; tierLabel: string; isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
// 月单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用"天"(1~15 天)
const canUseMonth = isExpertOrAbove(tierLabel)
const [unit, setUnit] = useState<'day' | 'month'>('day')
const [value, setValue] = useState(5)
const [confirmOpen, setConfirmOpen] = useState(false)
const dataStatus = useQuery({
queryKey: QK.dataStatus,
queryFn: api.dataStatus,
})
// 判断本地是否已有分钟K数据:后端 _safe_aggregate_minute 为避免全表扫描,
// rows 恒为 0,改用 trading_days(分区目录数,真实统计)判断。
const hasMinuteData = !!(dataStatus.data?.minute?.trading_days)
const extend = useMutation({
mutationFn: () => api.extendMinuteHistory(value, unit),
onSuccess: () => {
onStart()
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
qc.invalidateQueries({ queryKey: QK.dataStatus })
},
})
// 各单位上限:day 15 天,month 6 月(180 天)
const maxValue = unit === 'month' ? 6 : 15
const handleFetch = () => {
if (!hasMinuteData) {
setConfirmOpen(true)
} else {
extend.mutate()
}
}
// 切换单位时把 value clamp 到新单位的上限
const switchUnit = (u: 'day' | 'month') => {
if (u === unit) return
setUnit(u)
const max = u === 'month' ? 6 : 15
setValue(v => Math.min(v, max))
}
return (
<>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setValue(Math.max(1, value - 1))}
disabled={!hasMinuteCap || isRunning || extend.isPending}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{value}
</div>
<button
onClick={() => setValue(Math.min(maxValue, value + 1))}
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= maxValue}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
{canUseMonth ? (
<div className="flex rounded-btn border border-border overflow-hidden">
{(['day', 'month'] as const).map(u => (
<button
key={u}
onClick={() => switchUnit(u)}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'day' ? '天' : '月'}</button>
))}
</div>
) : (
<span className="text-[10px] text-muted"></span>
)}
</div>
<button
onClick={handleFetch}
disabled={!hasMinuteCap || isRunning || extend.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{extend.isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /></>
) : (
<></>
)}
</button>
{confirmOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmOpen(false)} />
<div className="relative rounded-card border border-border bg-surface shadow-2xl mx-4 px-6 py-5 max-w-sm w-full space-y-4">
<div className="text-sm text-foreground text-center">K数据 {value} {unit === 'month' ? '月' : '天'}K</div>
<div className="flex items-center justify-center gap-3">
<button
onClick={() => { setConfirmOpen(false); extend.mutate() }}
disabled={extend.isPending}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150"
>
</button>
<button
onClick={() => setConfirmOpen(false)}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"
>
</button>
</div>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,253 @@
import { useState } from 'react'
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 { Check, GripVertical } from 'lucide-react'
import { storage } from '@/lib/storage'
export type CardKey =
| 'instruments' | 'daily' | 'adj_factor' | 'enriched'
| 'index' | 'etf' | 'minute' | 'financials'
interface CardDef {
key: CardKey
label: string
desc: string
/** 档位能力不足时该卡片是否默认隐藏(减少干扰) */
defaultHiddenIfNoCap: boolean
/** 无条件默认隐藏(用户可在设置里手动开启) */
defaultHidden?: boolean
}
/** 数据画像卡片定义 —— 默认顺序即此数组顺序 */
export const DATA_CARD_DEFS: CardDef[] = [
{ key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false },
{ key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false },
{ key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true },
{ key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false },
{ key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false },
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
]
const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key)
/** 恢复默认时显示的卡片数量(按默认顺序取前 N 张) */
const DEFAULT_VISIBLE_COUNT = 5
const CAP_KEY_MAP: Partial<Record<CardKey, string>> = {
adj_factor: 'adj_factor',
minute: 'kline.minute.batch',
financials: 'financial',
}
/**
* 读取卡片显隐状态。结合档位能力决定默认值:
* - 用户显式设置过 → 用设置值
* - 未设置 + defaultHidden → 隐藏(无条件默认隐藏)
* - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏
* - 其他 → 显示
*/
export function getCardVisibility(
caps: Record<string, unknown> | undefined,
): Record<string, boolean> {
const has = (capKey: string) => !capKey || !!caps?.[capKey]
const override = storage.dataCardVisible.get({})
const result: Record<string, boolean> = {}
for (const def of DATA_CARD_DEFS) {
if (def.key in override) {
result[def.key] = override[def.key]
} else if (def.defaultHidden) {
result[def.key] = false
} else {
result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true
}
}
return result
}
/**
* 读取卡片显示顺序。
* - 用户拖拽设置过 → 用设置值(过滤掉已不存在的 key, 补齐新增的 key)
* - 未设置 → 用 DATA_CARD_DEFS 默认顺序
*/
export function getCardOrder(): CardKey[] {
const saved = storage.dataCardOrder.get([])
if (!saved.length) return [...DEFAULT_ORDER]
const known = new Set<CardKey>(DEFAULT_ORDER)
const ordered = saved.filter(k => known.has(k as CardKey)) as CardKey[]
// 补齐新增的 key(默认顺序里新增的卡片追加到末尾)
for (const k of DEFAULT_ORDER) {
if (!ordered.includes(k)) ordered.push(k)
}
return ordered
}
export function PageSettingsModal({
caps,
}: {
caps: Record<string, unknown> | undefined
}) {
const [visible, setVisible] = useState<Record<string, boolean>>(() => getCardVisibility(caps))
const [order, setOrder] = useState<CardKey[]>(() => getCardOrder())
const persistVisible = (next: Record<string, boolean>) => {
setVisible(next)
storage.dataCardVisible.set(next)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const persistOrder = (next: CardKey[]) => {
setOrder(next)
storage.dataCardOrder.set(next)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
const toggle = (key: CardKey) => persistVisible({ ...visible, [key]: !(visible[key] ?? true) })
const reset = () => {
// 恢复默认: 默认顺序 + 仅勾选前 5 张卡片, 其余隐藏
const defaultOrder = [...DEFAULT_ORDER]
const defaultVisible: Record<string, boolean> = {}
defaultOrder.forEach((k, i) => { defaultVisible[k] = i < DEFAULT_VISIBLE_COUNT })
storage.dataCardVisible.set(defaultVisible)
storage.dataCardOrder.set(defaultOrder)
setVisible(defaultVisible)
setOrder(defaultOrder)
window.dispatchEvent(new CustomEvent('data-card-visible-change'))
}
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 oldIdx = order.indexOf(active.id as CardKey)
const newIdx = order.indexOf(over.id as CardKey)
if (oldIdx < 0 || newIdx < 0) return
persistOrder(arrayMove(order, oldIdx, newIdx))
}
// 按 order 排序卡片定义
const defByKey = new Map(DATA_CARD_DEFS.map(d => [d.key, d]))
const orderedDefs = order.map(k => defByKey.get(k)!).filter(Boolean)
return (
<div className="space-y-2.5">
<p className="text-xs text-secondary leading-relaxed">
,,
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="space-y-1.5">
{orderedDefs.map((def) => {
const on = visible[def.key] ?? true
return (
<SortableCardRow
key={def.key}
id={def.key}
label={def.label}
desc={def.desc}
on={on}
onToggle={() => toggle(def.key)}
/>
)
})}
</div>
</SortableContext>
</DndContext>
<div className="flex items-center justify-end pt-1">
<button
onClick={reset}
className="px-2 py-0.5 rounded-btn text-[10px] text-secondary hover:text-foreground transition-colors"
>
</button>
</div>
</div>
)
}
// ── 可拖拽的卡片行 ──
function SortableCardRow({
id, label, desc, on, onToggle,
}: {
id: CardKey
label: string
desc: string
on: boolean
onToggle: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ 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={`flex items-center gap-2 rounded-card border px-3 py-2 transition-colors ${
isDragging ? 'bg-elevated shadow-lg' : ''
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30'}`}
>
{/* 拖拽手柄 */}
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
title="拖动排序"
>
<GripVertical className="h-4 w-4" />
</button>
{/* 显隐勾选 */}
<button
type="button"
onClick={onToggle}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-foreground">{label}</div>
<div className="text-[10px] text-muted leading-snug">{desc}</div>
</div>
</div>
)
}
@@ -0,0 +1,86 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
type PullKey = 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index'
interface ScopeItem {
key: PullKey
label: string
desc: string
defaultOn: boolean
}
const ITEMS: ScopeItem[] = [
{ key: 'pipeline_pull_a_share', label: 'A股', desc: '沪深京 A 股日K(约 5500 只)', defaultOn: true },
{ key: 'pipeline_pull_index', label: '指数', desc: '主要市场指数(默认全量约 600 只)', defaultOn: true },
{ key: 'pipeline_pull_etf', label: 'ETF', desc: '场内交易基金(约 1500 只,首次较慢)', defaultOn: false },
]
export function PipelineScopeConfig() {
const qc = useQueryClient()
const prefs = useQuery({ queryKey: QK.preferences, queryFn: api.preferences })
const updateToggle = useMutation({
mutationFn: (cfg: Partial<Record<PullKey, boolean>>) => api.updatePipelinePullTypes(cfg),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.dataStatus })
},
})
const getValue = (key: PullKey, def: boolean) => prefs.data?.[key] ?? def
return (
<div className="space-y-2.5">
<p className="text-xs text-secondary leading-relaxed">
,
</p>
<div className="space-y-1.5">
{ITEMS.map((item) => {
const locked = item.key === 'pipeline_pull_a_share'
const on = locked || getValue(item.key, item.defaultOn)
return (
<div key={item.key}>
<label
className={`flex items-start gap-2.5 rounded-card border px-3 py-2.5 transition-colors ${
locked ? 'cursor-default' : 'cursor-pointer'
} ${on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'}`}
>
<button
type="button"
onClick={() => {
if (!locked) updateToggle.mutate({ [item.key]: !on } as never)
}}
disabled={locked || updateToggle.isPending}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
} ${locked ? 'opacity-80' : ''}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground">{item.label}</span>
</div>
<div className="text-[10px] text-muted leading-snug mt-0.5">{item.desc}</div>
</div>
</label>
</div>
)
})}
</div>
{updateToggle.isPending && (
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
)}
<div className="text-[10px] text-muted leading-relaxed pt-1">
,
</div>
</div>
)
}
@@ -0,0 +1,165 @@
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Activity, Settings } from 'lucide-react'
import { Skeleton } from './Skeleton'
export function QuoteConfigCard({ enabled, running, isTrading, lastFetchMs, intervalS, intervalMin, intervalMax, loading, onToggle, toggling, showIntervalEdit, onShowIntervalEdit, onIntervalChange }: {
enabled: boolean
running: boolean
isTrading: boolean
lastFetchMs: number | null
intervalS: number
intervalMin: number
intervalMax: number
loading: boolean
onToggle: (enabled: boolean) => void
toggling: boolean
showIntervalEdit: boolean
onShowIntervalEdit: () => void
onIntervalChange: (v: number) => void
}) {
const statusColor = running && isTrading
? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.5)]'
: enabled && running
? 'bg-warning/60'
: 'bg-muted'
const statusText = !enabled
? '已关闭'
: !isTrading
? '非交易时段'
: running
? '行情运行中'
: '已停止'
const lastFetchTime = lastFetchMs
? new Date(lastFetchMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
: null
return (
<div className="rounded-card border border-border bg-surface p-4 relative">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<button
onClick={() => onToggle(!enabled)}
disabled={toggling}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
enabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggling ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
enabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
{loading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-8" /><Skeleton w="w-16" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-20" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-10" /><Skeleton w="w-14" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-12" /></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<div className="flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${statusColor} ${running && isTrading ? 'animate-pulse' : ''}`} />
<span className="font-mono text-secondary">{statusText}</span>
</div>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className={`font-mono ${isTrading ? 'text-accent' : 'text-muted'}`}>{isTrading ? '交易中' : '休市'}</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"></span>
<button
onClick={() => onShowIntervalEdit()}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showIntervalEdit ? 'text-accent' : 'text-secondary'}`}
title="设置轮询间隔"
>
<Settings className="h-3 w-3" />
</button>
</div>
<span className="font-mono text-secondary">{intervalS}s</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">{lastFetchTime ?? '—'}</span>
</div>
</div>
)}
<AnimatePresence>
{showIntervalEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<IntervalEditor
min={intervalMin}
max={intervalMax}
value={intervalS}
onChange={onIntervalChange}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function IntervalEditor({ min, max, value, onChange }: {
min: number; max: number; value: number; onChange: (v: number) => void
}) {
const [draft, setDraft] = useState(value)
const clamped = Math.max(min, Math.min(max, draft))
const step = min < 1 ? 0.1 : min < 3 ? 0.5 : 1
const presets = min <= 3 ? [3, 5, 10, 30, 60] : [5, 10, 15, 30, 60]
return (
<div className="mt-2 pt-2 border-t border-border/50">
<div className="text-[10px] text-muted mb-1.5">
<span className="text-muted/60">({min}s ~ {max}s)</span>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{presets.map(p => (
<button
key={p}
onClick={() => { setDraft(p); onChange(p) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
Math.abs(clamped - p) < 0.01
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary hover:text-foreground border border-transparent'
}`}
>
{p}s
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={min} max={max} step={step}
value={clamped}
onChange={e => { const v = parseFloat(e.target.value); setDraft(v); onChange(v) }}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] font-mono text-foreground w-8 text-right">
{clamped < 1 ? clamped.toFixed(1) : clamped.toFixed(0)}s
</span>
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react'
export function ScheduleEditor({ value, onSave, loading, hint }: {
value: { hour: number; minute: number }
onSave: (hour: number, minute: number) => void
loading: boolean
hint?: string
}) {
const [h, setH] = useState(value.hour)
const [m, setM] = useState(value.minute)
useEffect(() => { setH(value.hour); setM(value.minute) }, [value.hour, value.minute])
const handleSave = () => {
if (h === value.hour && m === value.minute) return
onSave(h, m)
}
return (
<div className="flex items-center gap-2 pt-1.5">
<span className="text-[10px] text-muted"></span>
<input
type="number" min={0} max={23} value={h}
onChange={e => setH(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"
/>
<span className="text-xs text-muted">:</span>
<input
type="number" min={0} max={59} value={m}
onChange={e => setM(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"
/>
<button
onClick={handleSave}
disabled={loading || (h === value.hour && m === value.minute)}
className="px-2.5 py-1 rounded-btn bg-accent/15 text-accent text-[11px] font-medium hover:bg-accent/25 disabled:opacity-40 transition-colors"
>
{loading ? '保存中…' : '保存'}
</button>
<span className="text-[10px] text-muted">{hint ? ` · ${hint}` : ''}</span>
</div>
)
}
@@ -0,0 +1,105 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import { api, type EnrichedField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
const TABLE_TITLES: Record<string, string> = {
instruments: '个股维表',
daily: '日 K',
adj_factor: '除权因子',
enriched: 'Enriched',
minute: '分钟 K',
index_instruments: '指数维表',
index_daily: '指数日 K',
index_enriched: '指数 Enriched',
etf_instruments: 'ETF 维表',
etf_daily: 'ETF 日 K',
etf_enriched: 'ETF Enriched',
}
function categorize(name: string): string {
if (['symbol', 'date'].includes(name)) return '基础'
if (['open', 'high', 'low', 'close', 'volume', 'amount'].includes(name)) return '行情'
if (name.startsWith('raw_') || name.startsWith('ex_') || name === 'close_pre_adj') return '复权'
if (name.startsWith('ma')) return '均线 MA'
if (name.startsWith('ema')) return '指数均线 EMA'
if (name.startsWith('macd')) return 'MACD'
if (name.startsWith('boll')) return '布林带'
if (name.startsWith('kdj')) return 'KDJ'
if (name.startsWith('rsi')) return 'RSI'
if (name.startsWith('signal_') || name.startsWith('consecutive_')) return '信号'
if (['atr_14', 'vol_ratio_5d', 'vol_ma5', 'vol_ma10', 'momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d', 'annual_vol_20d', 'change_pct', 'change_amount', 'amplitude', 'turnover_rate'].includes(name)) return '波动/动量'
if (['high_60d', 'low_60d'].includes(name)) return '极值'
return '其他'
}
export function EnrichedSchemaModal({ table, onClose }: { table: string | null; onClose: () => void }) {
const open = !!table
const schema = useQuery({
queryKey: QK.tableSchema(table!),
queryFn: () => api.enrichedSchema(table!),
enabled: open,
staleTime: Infinity,
})
const fields = schema.data ?? []
const groups: Record<string, EnrichedField[]> = {}
for (const f of fields) {
const cat = categorize(f.name)
if (!groups[cat]) groups[cat] = []
groups[cat].push(f)
}
const title = table ? (TABLE_TITLES[table] ?? table) : ''
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<motion.div
className="relative w-full max-w-xl max-h-[70vh] rounded-card border border-border bg-surface shadow-xl overflow-hidden mx-4"
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{title} </h3>
<span className="text-[10px] text-muted font-mono">{fields.length} </span>
</div>
<div className="px-5 py-3 overflow-y-auto max-h-[calc(70vh-48px)]">
{schema.isLoading ? (
<div className="text-xs text-muted animate-pulse py-4 text-center"></div>
) : (
<div className="space-y-3">
{Object.entries(groups).map(([cat, items]) => (
<div key={cat}>
<div className="text-[10px] font-medium text-accent/70 uppercase tracking-wider mb-1.5">{cat}</div>
<div className="space-y-1">
{items.map((f) => (
<div key={f.name} className="flex items-baseline gap-2 text-[11px]">
<span className="font-mono text-foreground shrink-0 min-w-[160px]">{f.name}</span>
<span className="text-secondary">{f.desc}</span>
<span className="text-muted ml-auto shrink-0">{f.type}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,61 @@
import { CheckCircle2, XCircle, Loader2, AlertCircle } from 'lucide-react'
import { formatDuration } from '@/lib/format'
export function SectionTitle({ icon: Icon, children }: { icon: React.ComponentType<{ className?: string }>; children: React.ReactNode }) {
return (
<h2 className="flex items-center gap-2 text-xs font-medium uppercase tracking-widest text-secondary">
<Icon className="h-3.5 w-3.5" />
{children}
</h2>
)
}
export function HistoryRow({ job, onClick }: { job: any; onClick: () => void }) {
const statusIcon = {
succeeded: { icon: CheckCircle2, color: 'text-bear' },
failed: { icon: XCircle, color: 'text-danger' },
running: { icon: Loader2, color: 'text-accent', spinning: true },
pending: { icon: Loader2, color: 'text-muted', spinning: true },
}[job.status as 'succeeded'] ?? { icon: AlertCircle, color: 'text-muted' }
const Icon = statusIcon.icon
return (
<button
onClick={onClick}
className="w-full px-5 py-3 hover:bg-elevated/50 transition-colors duration-150 ease-smooth text-left flex items-center justify-between gap-4"
>
<div className="flex items-center gap-3 min-w-0">
<Icon className={`h-4 w-4 shrink-0 ${statusIcon.color} ${(statusIcon as any).spinning ? 'animate-spin' : ''}`} />
<div className="min-w-0">
<div className="font-mono text-xs text-foreground">{job.id}</div>
<div className="text-[11px] text-muted">
{job.started_at ? new Date(job.started_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
{' · '}
{job.duration_s != null ? formatDuration(job.duration_s) : '...'}
</div>
</div>
</div>
<div className="text-right shrink-0">
{job.result && (() => {
const r = job.result as Record<string, any>
const parts: string[] = []
if (r.daily_days != null) parts.push(`日K ${r.daily_days}`)
if (r.enriched_days != null) parts.push(`enriched ${r.enriched_days}`)
if (r.minute_rows != null) parts.push(`分钟K ${r.minute_rows}`)
if (r.earliest_after && r.earliest_before) {
const a = String(r.earliest_after).slice(0, 10)
const b = String(r.earliest_before).slice(0, 10)
const days = r.daily_days ?? r.minute_days ?? 0
parts.push(days <= 1 ? a : `${a}~${b}`)
}
return parts.length > 0 ? (
<div className="text-xs text-secondary font-mono">{parts.join(' · ')}</div>
) : null
})()}
{job.error && (
<div className="text-xs text-danger truncate max-w-xs">{job.error}</div>
)}
</div>
</button>
)
}
@@ -0,0 +1,27 @@
import { motion } from 'framer-motion'
import { X } from 'lucide-react'
export function SettingsModal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div 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 rounded-card border border-border bg-surface shadow-2xl mx-4 w-full max-w-md overflow-hidden"
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<button onClick={onClose} className="p-0.5 rounded hover:bg-elevated text-secondary">
<X className="h-4 w-4" />
</button>
</div>
<div className="p-5">
{children}
</div>
</motion.div>
</div>
)
}
@@ -0,0 +1,7 @@
export function Skeleton({ w = 'w-full', h = 'h-3.5', rounded = 'rounded-sm', className = '' }: {
w?: string; h?: string; rounded?: string; className?: string
}) {
return (
<div className={`${w} ${h} ${rounded} bg-elevated/60 animate-pulse ${className}`} />
)
}
@@ -0,0 +1,318 @@
import { motion } from 'framer-motion'
import { Loader2, CheckCircle2, Settings, Table2 } from 'lucide-react'
import { formatNumber } from '@/lib/format'
import { fmtDate } from '@/lib/format'
import { Skeleton } from './Skeleton'
// 卡片能力定义:capKey → 查 capability limitstierReq → 无权限时显示的档位要求
// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。
export const CARD_META: Record<string, {
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
tierReq: string // 最低档位要求(无权限时显示)
}> = {
// 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力
instruments: { capKey: '', tierReq: '' },
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
enriched: { capKey: '', tierReq: '' },
// ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章
etf: { capKey: 'kline.daily.batch', tierReq: '' },
minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' },
financials: { capKey: 'financial', tierReq: 'Expert' },
}
export function Pill({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-btn bg-base/40 border border-border px-3 py-1.5">
<div className="text-[10px] uppercase tracking-wider text-muted">{label}</div>
<div className="font-mono text-sm font-medium tabular-nums mt-0.5">{value}</div>
</div>
)
}
function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix }: {
hasCap: boolean
isLocal: boolean
tierLabel?: string
tierReq?: string
capInfo?: { rpm: number | null; batch: number | null; subscribe: number | null } | undefined
localSuffix?: string
}) {
if (isLocal) {
return (
<span className="text-[10px] text-secondary bg-elevated rounded px-1.5 py-px font-medium">
{localSuffix ? ` · ${localSuffix}` : ''}
</span>
)
}
if (hasCap && capInfo && tierLabel) {
const parts = [tierLabel, `${capInfo.rpm}/min`]
if (capInfo.batch != null && capInfo.batch > 1) parts.push(`${capInfo.batch}股/批`)
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-mono font-medium">
{parts.join(' · ')}
</span>
)
}
if (!hasCap && tierReq && tierReq !== 'Free') {
// 缺权限且非 Free 档(付费档位才提示升级);Free 档人人可用,
// 若显示"需 Free"会造成 Expert 等用户困惑(通常是探测瞬时失败丢能力)
return (
<span className="text-[10px] text-warning/90 bg-warning/8 rounded px-1.5 py-px font-medium">
{tierReq}
</span>
)
}
if (hasCap) {
return (
<span className="text-[10px] text-accent/80 bg-accent/8 rounded px-1.5 py-px font-medium">
{tierLabel ?? '已授权'}
</span>
)
}
return null
}
export type FieldTab = { label: string; table: string }
export function StatCard({
title, hint, stats, isInstrument = false, loading = false,
active = false, done = false, skipped = false, stagePct = 0,
tierKey, capLimits, tierLabel,
auto, onSettings, onShowFields, settingsOpen, subLabel, localBadgeSuffix, fieldTabs,
}: {
title: string
hint: string
stats: any | null | undefined
isInstrument?: boolean
loading?: boolean
active?: boolean
done?: boolean
skipped?: boolean
stagePct?: number
tierKey?: string
capLimits?: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }>
tierLabel?: string
onSettings?: () => void
onShowFields?: (table?: string) => void
settingsOpen?: boolean
auto?: boolean
subLabel?: string
localBadgeSuffix?: string
// 多表字段入口: [{label: '维表', table: 'index_instruments'}, ...]
// 提供时渲染多个图标按钮(每个对应一张表的字段说明); 否则回退到单个 onShowFields
fieldTabs?: FieldTab[]
}) {
const empty = loading || !stats || (stats.rows === 0 && !stats.trading_days && !stats.fields)
const borderCls = active
? 'border-accent/50'
: done
? 'border-bear/30'
: 'border-border'
const bgCls = active ? 'bg-accent/[0.03]' : 'bg-surface'
const meta = tierKey ? CARD_META[tierKey] : undefined
const isLocal = meta?.capKey === ''
const capInfo = meta?.capKey ? capLimits?.[meta.capKey] : undefined
const hasCap = isLocal || !!capInfo
// 渲染字段说明入口图标
// - fieldTabs 提供时: 返回 null (图标由 renderSubLabelInline 内联到文字后)
// - 否则: 单个图标按钮 (onShowFields)
const renderFieldButtons = () => {
if (fieldTabs && fieldTabs.length > 0) return null
if (onShowFields) {
return (
<button
onClick={(e) => { e.stopPropagation(); onShowFields() }}
className="inline-flex align-middle ml-1 p-0.5 rounded hover:bg-elevated transition-colors text-secondary hover:text-accent"
title="查看字段说明"
>
<Table2 className="h-3 w-3" />
</button>
)
}
return null
}
// 单个图标按钮 (复用样式)
const fieldIconButton = (tab: FieldTab) => (
<button
key={tab.table}
onClick={(e) => { e.stopPropagation(); onShowFields?.(tab.table) }}
className="inline-flex align-middle -mt-px p-0.5 rounded hover:bg-elevated transition-colors text-secondary hover:text-accent"
title={`查看${tab.label}字段说明`}
>
<Table2 className="h-3 w-3" />
</button>
)
// subLabel 文本内容 (不含图标)
const subLabelText = subLabel
?? (isInstrument
? `标的 · ${((stats?.named ?? stats?.rows) ?? 0).toLocaleString()} 个含名称`
: stats?.fields
? '字段 · 复权 · 技术指标'
: title === '日 K' && stats?.trading_days
? '日 · A股标的 · 日线'
: stats?.trading_days && !stats?.rows
? '日 · A股标的 · 分钟级'
: (() => {
const parts = [`行 · ${(stats?.symbols_covered ?? 0)} 只标的`]
if (stats?.trading_days) parts.push(`· ${stats.trading_days}`)
return parts.join(' ')
})())
// 有 fieldTabs 时: 把 subLabel 按分隔符拆开, 每个匹配词后面内联图标
// 例如 "日 · 维表 · 日K · 指标" → 日 · 维表[icon] · 日K[icon] · 指标[icon]
const renderSubLabelInline = () => {
if (!fieldTabs || fieldTabs.length === 0) {
return <>{subLabelText}{renderFieldButtons()}</>
}
const labels = fieldTabs.map(t => t.label)
// 按非字母数字汉字的分隔符拆分, 保留分隔符
const tokens = subLabelText.split(/(\s*·\s*|\s+)/).filter(t => t !== '')
const used = new Set<string>()
return (
<>
{tokens.map((tok, i) => {
const trimmed = tok.trim()
// 跳过纯分隔符
if (trimmed === '' || trimmed === '·') return <span key={i}>{tok}</span>
// 匹配某个 tab label (整体匹配, 避免部分子串误命中)
const idx = labels.indexOf(trimmed)
if (idx >= 0 && !used.has(trimmed)) {
used.add(trimmed)
return <span key={i}>{tok}{fieldIconButton(fieldTabs[idx])}</span>
}
return <span key={i}>{tok}</span>
})}
</>
)
}
return (
<div className={`rounded-card border ${borderCls} ${bgCls} flex flex-col transition-all duration-300 ${active ? 'shadow-[0_0_16px_rgba(61,214,140,0.08)]' : ''}`}>
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<div className="flex items-center gap-1.5">
{auto !== undefined && !loading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${auto ? 'bg-accent shadow-[0_0_4px_rgba(61,214,140,0.5)]' : 'bg-muted'}`} />
<span className={auto ? 'text-accent/70' : 'text-muted'}>{auto ? '自动' : '关闭'}</span>
</span>
)}
{active && <Loader2 className="h-3.5 w-3.5 text-accent animate-spin" />}
{done && !active && !skipped && <CheckCircle2 className="h-3.5 w-3.5 text-bear" />}
{skipped && !active && (
<span className="text-[10px] text-muted bg-elevated rounded px-1.5 py-px font-medium">
</span>
)}
{onSettings && (
<button
onClick={(e) => { e.stopPropagation(); onSettings() }}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${
settingsOpen ? 'text-accent' : 'text-secondary'
}`}
>
<Settings className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="px-4 pb-1 text-[10px] text-muted">{hint}</div>
<div className="px-4 pb-2">
{loading ? (
<Skeleton w="w-16" h="h-4" />
) : (
<CapBadge
hasCap={hasCap}
isLocal={isLocal}
tierLabel={tierLabel}
tierReq={meta?.tierReq}
capInfo={capInfo}
localSuffix={localBadgeSuffix}
/>
)}
</div>
<div className="px-4 pb-1">
{loading ? (
<>
<Skeleton w="w-20" h="h-8" />
<Skeleton w="w-24" h="h-3" className="mt-1" />
</>
) : empty ? (
<>
<div className="font-mono text-2xl font-bold tracking-tight tabular-nums text-foreground"></div>
<div className="text-[11px] text-muted mt-0.5">
{renderFieldButtons()}
</div>
</>
) : (
<>
<div className="font-mono text-2xl font-bold tracking-tight tabular-nums text-foreground">
{stats.fields
? stats.fields
: stats.trading_days && !stats.rows
? stats.trading_days.toLocaleString()
: formatNumber(stats.rows)}
</div>
<div className="text-[11px] text-muted mt-0.5">
{renderSubLabelInline()}
</div>
</>
)}
</div>
<div className="mt-auto px-4 pb-4 pt-2 border-t border-border space-y-0.5">
{loading ? (
<>
<div className="flex justify-between"><Skeleton w="w-6" h="h-3" /><Skeleton w="w-16" h="h-3" /></div>
<div className="flex justify-between"><Skeleton w="w-4" h="h-3" /><Skeleton w="w-16" h="h-3" /></div>
</>
) : empty ? (
<>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '快照日' : '起'}</span>
<span className="font-mono text-secondary"></span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '标的数' : '止'}</span>
<span className="font-mono text-secondary"></span>
</div>
</>
) : (
<>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '快照日' : '起'}</span>
<span className="font-mono text-secondary">{fmtDate(isInstrument ? stats.latest_as_of : stats.earliest_date)}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-muted">{isInstrument ? '标的数' : '止'}</span>
<span className="font-mono text-secondary">{isInstrument ? String(stats.rows) : fmtDate(stats.latest_date)}</span>
</div>
</>
)}
</div>
{active && stagePct > 0 && (
<div className="h-1 bg-elevated overflow-hidden rounded-b-card">
<motion.div
className="h-full bg-accent"
initial={{ width: 0 }}
animate={{ width: `${stagePct}%` }}
transition={{ duration: 0.3, ease: 'easeOut' }}
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,383 @@
import { useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { X, Loader2, Upload, Plus, AlertCircle, Tag, Clock } from 'lucide-react'
import { api, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
export function CreateExtDialog({ onClose }: { onClose: () => void }) {
const qc = useQueryClient()
const [id, setId] = useState('')
const [label, setLabel] = useState('')
const [description, setDescription] = useState('')
const [mode, setMode] = useState<'snapshot' | 'timeseries'>('snapshot')
const [fields, setFields] = useState<ExtDataField[]>([])
const [error, setError] = useState('')
const detectFileRef = useRef<HTMLInputElement>(null)
const [detecting, setDetecting] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [symbolMap, setSymbolMap] = useState<Record<string, string>>({})
const [codeMap, setCodeMap] = useState<Record<string, string>>({})
const [matchStatus, setMatchStatus] = useState<'none' | 'partial' | 'full'>('none')
const [selectMapping, setSelectMapping] = useState<{ fields: { name: string; dtype: string; label: string }[]; need: 'symbol' | 'code' | 'both' } | null>(null)
const create = useMutation({
mutationFn: () => {
const userF = fields.filter((f) => f.name.trim() && f.name !== 'symbol' && f.name !== 'code')
return api.extDataCreate({
id,
label,
mode,
fields: [
{ name: 'symbol', dtype: 'string', label: '标的代码' },
{ name: 'code', dtype: 'string', label: '代码' },
...userF,
],
description: description.trim() || undefined,
symbol_map: symbolMap,
code_map: codeMap,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.extData })
onClose()
},
onError: (err) => setError(String(err)),
})
const addField = () =>
setFields([...fields, { name: '', dtype: 'string', label: '' }])
const removeField = (i: number) =>
setFields(fields.filter((_, idx) => idx !== i))
const updateField = (i: number, key: keyof ExtDataField, val: string) =>
setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f)))
const valid = id.trim() && label.trim() && fields.some((f) => f.name.trim()) && matchStatus !== 'none'
const processDetection = (
detected: { name: string; dtype: string; label: string }[],
symCands: string[],
codeCands: string[],
) => {
let sm: Record<string, string> = {}
let cm: Record<string, string> = {}
let status: 'none' | 'partial' | 'full' = 'none'
if (symCands.length === 1 && codeCands.length === 1) {
sm = { type: 'mapped', col: symCands[0] }
cm = { type: 'mapped', col: codeCands[0] }
status = 'full'
} else if (symCands.length === 1 && codeCands.length === 0) {
sm = { type: 'mapped', col: symCands[0] }
cm = { type: 'computed', from: 'symbol', method: 'strip_exchange' }
status = 'full'
} else if (symCands.length === 0 && codeCands.length === 1) {
cm = { type: 'mapped', col: codeCands[0] }
sm = { type: 'computed', from: 'code', method: 'append_exchange' }
status = 'full'
} else if (symCands.length > 1 || codeCands.length > 1) {
setSelectMapping({
fields: detected,
need: symCands.length > 0 && codeCands.length > 0 ? 'both' : symCands.length > 0 ? 'symbol' : 'code',
})
setFields(detected)
return
} else {
setSelectMapping({ fields: detected, need: 'both' })
setFields(detected)
return
}
setSymbolMap(sm)
setCodeMap(cm)
setMatchStatus(status)
setFields(detected)
setSelectMapping(null)
}
const handleDetectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
setDetecting(true); setError(''); setSelectMapping(null)
api.extDataDetectFields(file)
.then((res) => {
processDetection(res.fields, res.symbol_candidates, res.code_candidates)
})
.catch((err) => setError(String(err)))
.finally(() => setDetecting(false))
}
const userFields = fields.filter(f => f.name !== 'symbol' && f.name !== 'code')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div 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 rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-xl max-h-[85vh] flex flex-col overflow-hidden"
>
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-foreground"></h3>
<p className="text-[11px] mt-1 inline-flex items-center gap-1 bg-amber-500/10 text-amber-400 px-2 py-0.5 rounded-md font-medium">
CSV/Excel
</p>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-elevated text-secondary transition-colors">
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6 space-y-5">
<div>
<div className="text-[11px] font-medium text-secondary mb-2"></div>
<div className="grid grid-cols-2 gap-2">
{(['snapshot', 'timeseries'] as const).map((m) => {
const active = mode === m
return (
<button
key={m}
onClick={() => setMode(m)}
className={`relative flex items-start gap-3 px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
active
? 'border-amber-500/40 bg-amber-500/[0.08] shadow-sm shadow-amber-500/10'
: 'border-border bg-elevated/30 hover:bg-elevated/60'
}`}
>
<div className={`mt-0.5 p-1.5 rounded-lg ${
active
? 'bg-amber-500/15 text-amber-400'
: 'bg-elevated text-muted'
}`}>
{m === 'snapshot' ? <Tag className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium ${active ? 'text-foreground' : 'text-secondary'}`}>
{m === 'snapshot' ? '快照型' : '时序型'}
</div>
<div className="text-[10px] text-muted mt-0.5 leading-relaxed">
{m === 'snapshot' ? '每个标的一条,如概念、行业、人气排名' : '按日期记录,如资金流、情绪指数'}
</div>
</div>
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div>
<div className="text-[11px] font-medium text-secondary mb-1.5"></div>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={mode === 'snapshot' ? '例: 概念' : '例: 资金流'}
className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow"
/>
</div>
<div>
<div className="text-[11px] font-medium text-secondary mb-1.5"></div>
<input
value={id}
onChange={(e) => setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder={mode === 'snapshot' ? '例: concept' : '例: money_flow'}
className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground font-mono placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow"
/>
</div>
</div>
<div>
<div className="text-[11px] font-medium text-secondary mb-1.5"></div>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="扩展数据 · 与标的 JOIN(可选自定义描述)"
className="w-full h-9 px-3 rounded-lg bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent/30 focus:border-accent/50 transition-shadow"
/>
</div>
<div>
<div className="text-[11px] font-medium text-secondary mb-2"></div>
<input
ref={detectFileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleDetectFile}
/>
{selectMapping && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.06] px-3 py-2.5 space-y-2"
>
<div className="text-[10px] text-amber-400 font-medium">
symbol
</div>
<div className="flex flex-wrap gap-1.5">
{selectMapping.fields.map(f => (
<button
key={f.name}
onClick={() => {
const sm = { type: 'mapped', col: f.name }
const cm = { type: 'computed', from: 'symbol', method: 'strip_exchange' }
setSymbolMap(sm)
setCodeMap(cm)
setMatchStatus('full')
setSelectMapping(null)
}}
className="px-2.5 py-1 rounded-md bg-accent/10 text-accent text-[10px] font-medium hover:bg-accent/20 transition-colors"
>
{f.name}
</button>
))}
<button
onClick={() => setSelectMapping(null)}
className="px-2.5 py-1 rounded-md bg-elevated text-muted text-[10px] hover:text-secondary transition-colors"
>
</button>
</div>
</motion.div>
)}
{userFields.length === 0 ? (
<div
onClick={() => detectFileRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={e => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) { setDetecting(true); setError(''); setSelectMapping(null); api.extDataDetectFields(file).then(res => { processDetection(res.fields, res.symbol_candidates, res.code_candidates); }).catch(err => setError(String(err))).finally(() => setDetecting(false)) }
}}
className={`rounded-xl border-2 border-dashed py-6 flex flex-col items-center justify-center gap-2 cursor-pointer transition-colors ${
dragOver ? 'border-accent bg-accent/[0.06]' : detecting ? 'border-border/40 pointer-events-none' : 'border-border/30 hover:border-accent/40 hover:bg-accent/[0.02]'
}`}
>
{detecting ? (
<><Loader2 className="h-5 w-5 text-accent animate-spin" /><span className="text-[11px] text-muted"></span></>
) : (
<>
<Upload className="h-5 w-5 text-muted/60" />
<span className="text-[11px] text-secondary">CSV / Excel</span>
<span className="text-[10px] text-amber-400/70">symbol </span>
</>
)}
</div>
) : (
<>
<div className="flex items-center justify-end gap-1.5 mb-1.5">
<button
onClick={() => detectFileRef.current?.click()}
disabled={detecting}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] text-muted hover:text-accent hover:bg-accent/[0.06] disabled:opacity-40 transition-colors"
>
{detecting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
</button>
<button
onClick={addField}
className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-md text-[10px] text-muted hover:text-accent hover:bg-accent/[0.06] transition-colors"
>
<Plus className="h-3 w-3" />
</button>
</div>
<div className="space-y-1">
<div className={`flex items-center gap-2 px-2.5 py-1.5 rounded-md border ${matchStatus !== 'none' ? 'border-border/40 bg-elevated/20' : 'border-danger/30 bg-danger/[0.04]'}`}>
<span className="w-[72px] shrink-0 text-[11px] text-muted"></span>
<span className="flex-1 text-[11px] font-mono text-muted">symbol</span>
<span className="w-[52px] text-center text-[10px] text-muted/40"></span>
{matchStatus !== 'none'
? <span className="text-[9px] text-green-500/70 shrink-0">
{symbolMap.type === 'mapped' ? `${symbolMap.col}` : '← 计算'}
</span>
: <AlertCircle className="h-3.5 w-3.5 text-danger/60 shrink-0" />}
</div>
<div className={`flex items-center gap-2 px-2.5 py-1.5 rounded-md border ${matchStatus !== 'none' ? 'border-border/40 bg-elevated/20' : 'border-danger/30 bg-danger/[0.04]'}`}>
<span className="w-[72px] shrink-0 text-[11px] text-muted"></span>
<span className="flex-1 text-[11px] font-mono text-muted">code</span>
<span className="w-[52px] text-center text-[10px] text-muted/40"></span>
{matchStatus !== 'none'
? <span className="text-[9px] text-green-500/70 shrink-0">
{codeMap.type === 'mapped' ? `${codeMap.col}` : codeMap.method === 'strip_exchange' ? '← symbol截取' : '← 推算'}
</span>
: <AlertCircle className="h-3.5 w-3.5 text-danger/60 shrink-0" />}
</div>
{userFields.map((f) => {
const idx = fields.indexOf(f)
return (
<div key={idx} className="flex items-center gap-1.5 group">
<input
value={f.label}
onChange={(e) => updateField(idx, 'label', e.target.value)}
placeholder="显示名"
className="w-[72px] h-7 px-2 rounded-md border border-border bg-base text-[11px] text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40"
/>
<input
value={f.name}
onChange={(e) => updateField(idx, 'name', e.target.value)}
placeholder="字段名"
className="flex-1 h-7 px-2 rounded-md border border-border bg-base text-[11px] font-mono text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/40"
/>
<select
value={f.dtype}
onChange={(e) => updateField(idx, 'dtype', e.target.value)}
className="h-7 px-2 rounded-md border border-border bg-base text-[11px] text-foreground"
>
<option value="string"></option>
<option value="int"></option>
<option value="float"></option>
<option value="bool"></option>
</select>
<button
onClick={() => removeField(idx)}
className="p-1 rounded text-muted/40 hover:text-danger hover:bg-danger/10 opacity-0 group-hover:opacity-100 transition-all"
>
<X className="h-3 w-3" />
</button>
</div>
)
})}
</div>
</>
)}
</div>
{error && (
<div className="text-[11px] text-danger bg-danger/5 rounded-lg px-3 py-2">{error}</div>
)}
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border/50 bg-elevated/20">
<div className="text-[10px] text-muted/60"></div>
<div className="flex items-center gap-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors">
</button>
<button
onClick={() => create.mutate()}
disabled={!valid || create.isPending}
className="px-5 py-2 rounded-lg bg-accent text-base text-xs font-medium hover:bg-accent/90 disabled:opacity-40 transition-colors shadow-sm shadow-accent/20"
>
{create.isPending ? '创建中…' : '创建'}
</button>
</div>
</div>
</motion.div>
</div>
)
}
@@ -0,0 +1,252 @@
import { useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { X, Loader2, Upload } from 'lucide-react'
import { api, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onClose: () => void }) {
const qc = useQueryClient()
const [label, setLabel] = useState(config.label)
const [description, setDescription] = useState(config.description ?? '')
const [fields, setFields] = useState<ExtDataField[]>([...config.fields])
const [error, setError] = useState('')
const detectFileRef = useRef<HTMLInputElement>(null)
const [detecting, setDetecting] = useState(false)
const [symbolMapping, setSymbolMapping] = useState<{ candidates: string[]; file: File } | null>(null)
const update = useMutation({
mutationFn: () =>
api.extDataUpdate(config.id, {
label: label.trim(),
fields: fields.filter((f) => f.name.trim()),
description: description.trim() || '',
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.extData })
onClose()
},
onError: (err) => setError(String(err)),
})
const addField = () =>
setFields([...fields, { name: '', dtype: 'string', label: '' }])
const removeField = (i: number) =>
setFields(fields.filter((_, idx) => idx !== i))
const updateField = (i: number, key: keyof ExtDataField, val: string) =>
setFields(fields.map((f, idx) => (idx === i ? { ...f, [key]: val } : f)))
const valid = label.trim() && fields.some((f) => f.name.trim())
const applyDetectedFields = (detected: { name: string; dtype: string; label: string }[]) => {
const rest = detected.filter(f => f.name !== 'symbol')
setFields([
{ name: 'symbol', dtype: 'string', label: '标的代码' },
...rest,
])
}
const handleDetectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
setDetecting(true); setError(''); setSymbolMapping(null)
api.extDataDetectFields(file)
.then((res) => {
const hasSym = res.symbol_candidates.length > 0
const hasCode = res.code_candidates.length > 0
if (hasSym || hasCode) {
applyDetectedFields(res.fields)
} else {
const candidates = res.fields.map(f => f.name)
setSymbolMapping({ candidates, file })
}
})
.catch((err) => setError(String(err)))
.finally(() => setDetecting(false))
}
const handleSymbolMap = (_col: string) => {
if (!symbolMapping) return
setDetecting(true); setError('')
const rest = fields.filter(f => f.name !== 'symbol')
setFields([{ name: 'symbol', dtype: 'string', label: '标的代码' }, ...rest])
setSymbolMapping(null)
setDetecting(false)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div 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 rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden"
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground"></h3>
<button onClick={onClose} className="p-0.5 rounded hover:bg-elevated text-secondary">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<div className="h-8 px-3 rounded-btn bg-elevated/50 border border-border text-xs text-muted font-mono flex items-center">
{config.id}
</div>
</div>
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<div className={`h-8 px-3 rounded-btn border text-xs flex items-center ${
config.mode === 'snapshot'
? 'bg-blue-500/10 border-blue-500/30 text-blue-400'
: 'bg-amber-500/10 border-amber-500/30 text-amber-400'
}`}>
{config.mode === 'snapshot' ? '快照型' : '时序型'}
</div>
</div>
</div>
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full h-8 px-3 rounded-btn bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="text-[10px] text-muted mb-1 block"></label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="可选,简要说明数据的用途"
className="w-full h-8 px-3 rounded-btn bg-base border border-border text-xs text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted"></label>
<div className="flex items-center gap-2">
<input
ref={detectFileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleDetectFile}
/>
<button
onClick={() => detectFileRef.current?.click()}
disabled={detecting}
className="text-[10px] text-accent/80 hover:text-accent inline-flex items-center gap-0.5 disabled:opacity-40"
>
{detecting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
</button>
<button onClick={addField} className="text-[10px] text-accent hover:text-accent/80">+ </button>
</div>
</div>
{symbolMapping && (
<div className="mb-2 rounded-md border border-amber-500/30 bg-amber-500/[0.06] px-3 py-2 space-y-1.5">
<div className="text-[10px] text-amber-400"> symbol </div>
<div className="flex flex-wrap gap-1.5">
{symbolMapping.candidates.map(col => (
<button
key={col}
onClick={() => handleSymbolMap(col)}
className="px-2 py-1 rounded-btn bg-accent/10 text-accent text-[10px] font-medium hover:bg-accent/20 transition-colors"
>
{col}
</button>
))}
<button
onClick={() => setSymbolMapping(null)}
className="px-2 py-1 rounded-btn bg-elevated text-muted text-[10px] hover:text-secondary transition-colors"
>
</button>
</div>
</div>
)}
<div className="space-y-2">
{fields.map((f, i) => {
const isBuiltin = f.name === 'symbol' || f.name === 'name'
return (
<div key={i} className="flex items-center gap-2">
<input
value={f.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
placeholder="显示名"
disabled={isBuiltin}
className={`w-20 h-7 px-2 rounded-btn border text-[11px] text-foreground placeholder:text-muted/40 focus:outline-none focus:border-accent/50 ${
isBuiltin
? 'bg-elevated/50 border-border text-muted cursor-not-allowed'
: 'bg-base border-border'
}`}
/>
<input
value={f.name}
onChange={(e) => updateField(i, 'name', e.target.value)}
placeholder="字段名 (英文)"
disabled={isBuiltin}
className={`flex-1 h-7 px-2 rounded-btn border text-[11px] font-mono placeholder:text-muted/40 focus:outline-none focus:border-accent/50 ${
isBuiltin
? 'bg-elevated/50 border-border text-muted cursor-not-allowed'
: 'bg-base border-border'
}`}
/>
<select
value={f.dtype}
onChange={(e) => updateField(i, 'dtype', e.target.value)}
disabled={isBuiltin}
className={`h-7 px-2 rounded-btn border border-border text-[11px] text-foreground ${
isBuiltin ? 'bg-elevated/50 text-muted cursor-not-allowed' : 'bg-base'
}`}
>
<option value="string"></option>
<option value="int"></option>
<option value="float"></option>
<option value="bool"></option>
</select>
{!isBuiltin && fields.length > 3 && (
<button onClick={() => removeField(i)} className="p-1 text-muted hover:text-danger">
<X className="h-3 w-3" />
</button>
)}
{isBuiltin && <div className="w-[18px]" />}
</div>
)
})}
</div>
</div>
{error && (
<div className="text-xs text-danger bg-danger/5 rounded-btn px-3 py-1.5">{error}</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors">
</button>
<button
onClick={() => update.mutate()}
disabled={!valid || update.isPending}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{update.isPending ? '保存中…' : '保存'}
</button>
</div>
</motion.div>
</div>
)
}
@@ -0,0 +1,79 @@
import { Check, Copy } from 'lucide-react'
import type { ExtDataConfig } from '@/lib/api'
export function ExtDataApiPanel({ config, copied, setCopied }: {
config: ExtDataConfig
copied: boolean
setCopied: (v: boolean) => void
}) {
const endpoint = `POST /api/ext-data/${config.id}/ingest`
const exampleRow: Record<string, unknown> = { symbol: '000001.SZ' }
for (const f of config.fields) {
if (f.name === 'symbol' || f.name === 'code') continue
exampleRow[f.name] = f.dtype === 'int' ? 100 : f.dtype === 'float' ? 1.5 : f.dtype === 'bool' ? true : '示例'
}
if (config.fields.some(f => f.name === 'code')) {
exampleRow['code'] = '000001'
}
const exampleBody = {
date: config.mode === 'timeseries' ? '2025-01-15' : undefined,
rows: [exampleRow],
}
const exampleJson = JSON.stringify(exampleBody, null, 2)
const curlCmd = `curl -X POST http://localhost:${window.location.port || '3018'}/api/ext-data/${config.id}/ingest \\
-H 'Content-Type: application/json' \\
-d '${JSON.stringify(exampleBody)}'`
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="space-y-2.5">
<div>
<div className="text-[10px] text-muted mb-1"></div>
<div className="flex items-center gap-1.5 bg-elevated rounded-md px-2.5 py-1.5">
<code className="text-[11px] font-mono text-accent flex-1 select-all">{endpoint}</code>
<button onClick={() => handleCopy(endpoint)} className="text-muted hover:text-secondary transition-colors">
{copied ? <Check className="h-3 w-3 text-accent" /> : <Copy className="h-3 w-3" />}
</button>
</div>
</div>
<div>
<div className="text-[10px] text-muted mb-1"></div>
<pre className="bg-elevated rounded-md px-2.5 py-2 text-[10px] font-mono text-secondary overflow-x-auto whitespace-pre leading-relaxed">
{exampleJson}
</pre>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted">cURL </span>
<button
onClick={() => handleCopy(curlCmd)}
className="text-[10px] text-accent hover:text-accent/80 flex items-center gap-0.5 transition-colors"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</div>
<pre className="bg-elevated rounded-md px-2.5 py-2 text-[10px] font-mono text-secondary overflow-x-auto whitespace-pre-wrap leading-relaxed break-all">
{curlCmd}
</pre>
</div>
<div className="text-[10px] text-muted leading-relaxed">
{config.mode === 'snapshot' ? (
<><span className="text-secondary">date</span> </>
) : (
<><span className="text-secondary">date</span> </>
)}
</div>
</div>
)
}
@@ -0,0 +1,326 @@
import { useState } from 'react'
import { Loader2, Search, Check, Clock, Zap, Settings2, AlertCircle, CheckCircle2, Calendar } from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
import { toast } from '@/components/Toast'
export function ExtDataPullPanel({ config, onSaved }: {
config: ExtDataConfig
onSaved: () => void
}) {
const pull = config.pull
const [url, setUrl] = useState(pull?.url ?? '')
const [method, setMethod] = useState(pull?.method ?? 'GET')
const [headerStr, setHeaderStr] = useState(
pull?.headers ? JSON.stringify(pull.headers, null, 2) : ''
)
const [body, setBody] = useState(pull?.body ?? '')
const [responsePath, setResponsePath] = useState(pull?.response_path ?? '')
const [fieldMapStr, setFieldMapStr] = useState(
pull?.field_map ? JSON.stringify(pull.field_map, null, 2) : ''
)
const [schedule, setSchedule] = useState(pull?.schedule_minutes ?? 1440)
const [enabled, setEnabled] = useState(pull?.enabled ?? false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [running, setRunning] = useState(false)
const [runResult, setRunResult] = useState<{ rows: number; date: string } | null>(null)
const [testResult, setTestResult] = useState<{ total_rows: number; preview: Record<string, unknown>[]; has_symbol: boolean } | null>(null)
const [error, setError] = useState('')
// 解析 JSON 输入, 失败时设置 error 并返回 null
const parseJson = (str: string, label: string): Record<string, string> | undefined | null => {
if (!str.trim()) return undefined
try { return JSON.parse(str) }
catch { setError(`${label} 不是有效 JSON`); return null }
}
// 构建保存 payload (复用当前编辑态), enabledOverride 用于开关自动保存
const buildPayload = (enabledOverride?: boolean) => {
const headers = parseJson(headerStr, 'Headers')
if (headers === null) return null
const field_map = parseJson(fieldMapStr, '字段映射')
if (field_map === null) return null
return {
url, method, headers, body: body || undefined,
response_path: responsePath, field_map,
schedule_minutes: schedule, enabled: enabledOverride ?? enabled,
}
}
const handleSave = (silent = false) => {
const payload = buildPayload()
if (!payload) return
setSaving(true); setError('')
api.extDataPullConfig(config.id, payload)
.then(() => {
onSaved()
if (!silent) toast('配置已保存', 'success')
})
.catch(e => setError(e.message || '保存失败'))
.finally(() => setSaving(false))
}
const handleTest = () => {
setTesting(true); setError(''); setTestResult(null)
const payload = buildPayload()
if (!payload) { setTesting(false); return }
api.extDataPullConfig(config.id, payload)
.then(() => api.extDataPullTest(config.id))
.then(r => { setTestResult(r); onSaved() })
.catch(e => setError(e.message || '测试失败'))
.finally(() => setTesting(false))
}
const handleRun = () => {
setRunning(true); setError(''); setRunResult(null)
api.extDataPullRun(config.id)
.then(r => {
setRunResult({ rows: r.rows, date: r.date })
onSaved()
toast(`拉取成功 · ${r.rows}`, 'success')
})
.catch(e => setError(e.message || '执行失败'))
.finally(() => setRunning(false))
}
// 开关 toggle: 自动保存全量配置 (切换 enabled), 后端 refresh 后立即首次拉取
const [toggling, setToggling] = useState(false)
const handleToggle = (next: boolean) => {
if (toggling) return
if (next && !url.trim()) {
toast('请先填写拉取 URL', 'error')
return
}
const payload = buildPayload(next)
if (!payload) return
setToggling(true); setError(''); setEnabled(next)
api.extDataPullConfig(config.id, payload)
.then(() => {
onSaved()
toast(next ? '定时拉取已启用 · 立即执行首次拉取' : '定时拉取已关闭', 'success')
})
.catch(e => {
setEnabled(!next) // 回滚
setError(e.message || '切换失败')
})
.finally(() => setToggling(false))
}
// 格式化时间显示
const fmtTime = (iso: string | null | undefined) => {
if (!iso) return null
const d = new Date(iso)
if (isNaN(d.getTime())) return null
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}`
}
return (
<div className="space-y-3">
{/* ===== 分区 ①: 请求配置 ===== */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<Settings2 className="h-3 w-3 text-muted" />
<span></span>
</div>
<div className="flex gap-1.5">
<select
value={method} onChange={e => setMethod(e.target.value)}
className="shrink-0 rounded-btn border border-border bg-elevated px-2 py-1.5 text-[11px] text-foreground"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input
value={url} onChange={e => setUrl(e.target.value)}
placeholder="https://api.example.com/data"
className="flex-1 min-w-0 rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/50"
/>
</div>
<div>
<div className="text-[10px] text-muted mb-1">Headers (JSON)</div>
<textarea
value={headerStr} onChange={e => setHeaderStr(e.target.value)}
placeholder='{"Authorization": "Bearer xxx"}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
{method === 'POST' && (
<div>
<div className="text-[10px] text-muted mb-1"> (JSON)</div>
<textarea
value={body} onChange={e => setBody(e.target.value)}
placeholder='{"page": 1}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[10px] text-muted mb-1"></div>
<input
value={responsePath} onChange={e => setResponsePath(e.target.value)}
placeholder="data.list"
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40"
/>
</div>
<div>
<div className="text-[10px] text-muted mb-1"> ()</div>
<input
type="number" min={1} value={schedule} onChange={e => setSchedule(Number(e.target.value))}
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground"
/>
</div>
</div>
<div>
<div className="text-[10px] text-muted mb-1"> ( JSON)</div>
<textarea
value={fieldMapStr} onChange={e => setFieldMapStr(e.target.value)}
placeholder='{"code": "symbol", "val": "score"}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
</div>
{/* ===== 分区 ②: 定时拉取状态 ===== */}
<div className="rounded-card border border-border/60 bg-elevated/30 p-2.5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<Clock className="h-3 w-3 text-muted" />
<span></span>
</div>
{/* 自定义 Toggle 开关 */}
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={toggling}
onClick={() => handleToggle(!enabled)}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors duration-200 disabled:opacity-50 ${
enabled ? 'bg-accent' : 'bg-border'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform duration-200 ${
enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{/* 状态文案 */}
<div className="text-[10px] leading-relaxed">
{enabled ? (
<div className="space-y-0.5">
<div className="flex items-center gap-1 text-accent">
<span className="h-1 w-1 rounded-full bg-accent animate-pulse" />
<span> · {schedule} </span>
</div>
{pull?.next_run && fmtTime(pull.next_run) && (
<div className="flex items-center gap-1 text-muted">
<Calendar className="h-2.5 w-2.5" />
<span>{fmtTime(pull.next_run)}</span>
</div>
)}
</div>
) : (
<div className="text-muted"> · </div>
)}
</div>
{/* 上次执行结果 */}
{pull?.last_run && (
<div className="flex items-start gap-1.5 pt-1.5 border-t border-border/40">
{pull.last_status === 'success' ? (
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0 mt-px" />
) : (
<AlertCircle className="h-3 w-3 text-danger shrink-0 mt-px" />
)}
<div className="min-w-0 flex-1">
<div className={`text-[10px] font-medium ${pull.last_status === 'success' ? 'text-emerald-500' : 'text-danger'}`}>
{pull.last_message || (pull.last_status === 'success' ? '成功' : '失败')}
</div>
{fmtTime(pull.last_run) && (
<div className="text-[9px] text-muted">{fmtTime(pull.last_run)}</div>
)}
</div>
</div>
)}
</div>
{/* ===== 分区 ③: 操作按钮 ===== */}
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleTest}
disabled={testing || !url}
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn border border-border bg-elevated text-xs text-foreground hover:bg-border/30 disabled:opacity-40 transition-colors"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
</button>
<button
onClick={handleRun}
disabled={running || !url}
className="inline-flex items-center justify-center gap-1 px-2 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
</button>
</div>
<button
onClick={() => handleSave(false)}
disabled={saving || !url}
className="w-full inline-flex items-center justify-center gap-1 py-2 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
</div>
{/* ===== 结果展示 ===== */}
{runResult && (
<div className="rounded-card border border-emerald-500/30 bg-emerald-500/[0.06] p-2.5 flex items-center justify-between text-[10px]">
<span className="text-emerald-500 font-medium flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
</span>
<span className="text-secondary">{runResult.rows} · {runResult.date}</span>
</div>
)}
{testResult && (
<div className="rounded-card border border-accent/30 bg-accent/[0.04] p-2.5 space-y-1.5">
<div className="flex items-center justify-between text-[10px]">
<span className="text-accent font-medium"></span>
<span className="text-secondary">{testResult.total_rows} </span>
</div>
{!testResult.has_symbol && (
<div className="text-[10px] text-amber-500"> symbol </div>
)}
{testResult.preview.length > 0 && (
<pre className="text-[9px] font-mono text-muted bg-elevated rounded px-2 py-1.5 overflow-x-auto max-h-32">
{JSON.stringify(testResult.preview, null, 2)}
</pre>
)}
</div>
)}
{error && (
<div className="text-[10px] text-danger text-center bg-danger/[0.06] rounded-btn py-1.5">
{error}
</div>
)}
</div>
)
}
@@ -0,0 +1,315 @@
import { useRef, useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { Settings, Tag, Upload, Code, RefreshCw, CheckCircle2, Loader2, Pencil, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
import { api, type ExtDataConfig } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SettingsModal } from '@/components/data/SettingsModal'
import { ExtDataPullPanel } from './ExtDataPullPanel'
import { ExtDataApiPanel } from './ExtDataApiPanel'
export function ExtDataStatCard({ config, onDelete, deleting, onEdit }: {
config: ExtDataConfig
onDelete: () => void
deleting: boolean
onEdit?: () => void
}) {
const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [uploadResult, setUploadResult] = useState<{ rows: number; date: string } | null>(null)
const [showDelete, setShowDelete] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [ingestTab, setIngestTab] = useState<'file' | 'api' | 'pull'>('file')
const [copied, setCopied] = useState(false)
const [fieldsExpanded, setFieldsExpanded] = useState(false)
const fieldsRef = useRef<HTMLDivElement>(null)
const [fieldsOverflow, setFieldsOverflow] = useState(false)
useEffect(() => {
const el = fieldsRef.current
if (!el) return
const prev = el.style.maxHeight
el.style.maxHeight = 'none'
const full = el.scrollHeight
el.style.maxHeight = prev
setFieldsOverflow(full > 68)
}, [config.fields])
const upload = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => api.extDataUpload(id, file),
onMutate: () => { setUploading(true); setUploadResult(null) },
onSuccess: (data) => {
setUploadResult({ rows: data.rows, date: data.date })
qc.invalidateQueries({ queryKey: QK.extData })
qc.invalidateQueries({ queryKey: QK.dataStatus })
setTimeout(() => {
setUploadResult(null)
setSettingsOpen(false)
}, 1500)
},
onSettled: () => setUploading(false),
})
const doUpload = (file: File) => upload.mutate({ id: config.id, file })
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) doUpload(file)
e.target.value = ''
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) doUpload(file)
}
return (
<div className={`rounded-card border flex flex-col transition-all duration-300 ${
config.mode === 'snapshot'
? 'border-blue-500/30 bg-blue-500/[0.03]'
: 'border-amber-500/30 bg-amber-500/[0.03]'
}`}>
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h3 className="text-sm font-medium text-foreground">{config.label}</h3>
<div className="flex items-center gap-1.5">
<span className={`text-[10px] px-1.5 py-px rounded font-medium ${
config.mode === 'snapshot'
? 'bg-blue-500/10 text-blue-400'
: 'bg-amber-500/10 text-amber-400'
}`}>
{config.mode === 'snapshot' ? '快照' : '时序'}
</span>
<button
onClick={() => setSettingsOpen(v => !v)}
className="p-0.5 rounded hover:bg-elevated transition-colors text-secondary"
>
<Settings className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="px-4 pb-1.5 text-[10px] text-muted/70 leading-relaxed line-clamp-2">
{config.description || `扩展数据 · ${config.mode === 'snapshot' ? '与标的 JOIN' : '与日K JOIN'}`}
</div>
<div className="flex-1 min-h-[81px] px-4 pb-2">
<div className={`relative overflow-hidden transition-all duration-300 ${fieldsExpanded ? '' : 'h-[75px]'}`}>
<div
ref={fieldsRef}
className="flex flex-wrap gap-1"
>
{config.fields
.filter(f => f.name !== 'symbol' && f.name !== 'code')
.map((f) => (
<span key={f.name} className="inline-flex items-center gap-1 text-[10px] bg-elevated rounded px-1.5 py-0.5">
<Tag className="h-2.5 w-2.5 text-muted" />
<span className="text-secondary">{f.label || f.name}</span>
</span>
))
}
</div>
{fieldsOverflow && !fieldsExpanded && (
<div
className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-surface to-transparent cursor-pointer flex items-end justify-center"
onClick={() => setFieldsExpanded(true)}
>
<ChevronDown className="h-3 w-3 text-muted" />
</div>
)}
{fieldsExpanded && fieldsOverflow && (
<button
onClick={() => setFieldsExpanded(false)}
className="w-full flex items-center justify-center pt-1 text-[10px] text-muted hover:text-secondary transition-colors"
>
<ChevronUp className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="mt-auto px-4 pb-4 pt-2 border-t border-border/50">
<div className="flex justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">{config.id}</span>
</div>
<div className="flex justify-between text-[11px] mt-1">
<span className="text-muted"></span>
<span className="text-secondary">{config.latest_sync_date ?? '—'}</span>
</div>
</div>
<AnimatePresence>
{settingsOpen && (
<SettingsModal title={`${config.label} · 设置`} onClose={() => setSettingsOpen(false)}>
<div className="space-y-3">
{onEdit && (
<button
onClick={() => { setSettingsOpen(false); onEdit() }}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-border bg-elevated text-foreground text-xs font-medium hover:bg-border/30 transition-colors"
>
<Pencil className="h-3 w-3" />
</button>
)}
<button
onClick={() => {
api.extDataFixSymbol(config.id).then((res) => {
setUploadResult({ rows: res.fixed_files, date: '格式修复完成' })
qc.invalidateQueries({ queryKey: QK.extData })
setTimeout(() => setUploadResult(null), 3000)
})
}}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-border bg-elevated text-foreground text-xs font-medium hover:bg-border/30 transition-colors"
>
<Tag className="h-3 w-3" />
</button>
<div className="flex gap-1 rounded-lg bg-elevated/60 p-0.5">
<button
onClick={() => setIngestTab('file')}
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
ingestTab === 'file' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<Upload className="h-3 w-3" />
</button>
<button
onClick={() => setIngestTab('api')}
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
ingestTab === 'api' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<Code className="h-3 w-3" />
</button>
<button
onClick={() => setIngestTab('pull')}
className={`flex-1 inline-flex items-center justify-center gap-1 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
ingestTab === 'pull' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'
}`}
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
{ingestTab === 'file' ? (
<>
<input
ref={fileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={handleFile}
/>
<div
onClick={() => fileRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`relative cursor-pointer rounded-lg border-2 border-dashed transition-colors py-5 flex flex-col items-center justify-center gap-1.5 ${
dragOver
? 'border-accent bg-accent/10'
: uploading
? 'border-border/50 bg-elevated/30 pointer-events-none'
: 'border-border/40 hover:border-accent/50 hover:bg-accent/[0.03]'
}`}
>
{uploading ? (
<>
<Loader2 className="h-5 w-5 text-accent animate-spin" />
<span className="text-[11px] text-muted"></span>
</>
) : (
<>
<Upload className={`h-5 w-5 ${dragOver ? 'text-accent' : 'text-muted'}`} />
<span className={`text-[11px] ${dragOver ? 'text-accent' : 'text-secondary'}`}>
</span>
<span className="text-[10px] text-muted"> CSV / Excel symbol </span>
</>
)}
</div>
</>
) : ingestTab === 'api' ? (
<ExtDataApiPanel config={config} copied={copied} setCopied={setCopied} />
) : (
<ExtDataPullPanel config={config} onSaved={() => qc.invalidateQueries({ queryKey: QK.extData })} />
)}
{uploadResult && (
<div className="text-[11px] text-green-400 bg-green-500/10 rounded-lg px-3 py-2 text-center flex items-center justify-center gap-1.5 font-medium">
<CheckCircle2 className="h-3.5 w-3.5" /> · {uploadResult.rows} · {uploadResult.date}
</div>
)}
<button
onClick={() => setShowDelete(true)}
className="w-full text-center text-[10px] text-danger/60 hover:text-danger transition-colors"
>
</button>
</div>
</SettingsModal>
)}
</AnimatePresence>
{/* 删除二次确认弹窗 */}
<AnimatePresence>
{showDelete && (
<div className="fixed inset-0 z-[60] 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={() => !deleting && setShowDelete(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6"
>
<div className="flex items-start gap-3">
<div className="shrink-0 h-10 w-10 rounded-full bg-danger/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-1.5">{config.label}</h3>
<p className="text-xs text-secondary leading-relaxed">
<span className="text-danger font-medium"></span>/
</p>
<p className="mt-2 text-[11px] text-danger/90">
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setShowDelete(false)}
disabled={deleting}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors disabled:opacity-50"
>
</button>
<button
onClick={() => { onDelete(); setShowDelete(false) }}
disabled={deleting}
className="px-3 py-1.5 rounded-btn bg-danger/90 text-base text-sm font-medium hover:bg-danger disabled:opacity-50 transition-colors"
>
{deleting ? '删除中…' : '确认删除'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,266 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
Database, Settings2, Send, Wand2, Minimize2, History,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { MarkdownRenderer } from './MarkdownRenderer'
import {
type ActiveTask, type HistoryReport,
minimizeDialog, closeDialog, startAnalysis,
} from '@/lib/aiReportStore'
interface Props {
/** 当前展示的任务;活跃任务或历史报告 */
task: ActiveTask | HistoryReport | null
mode: 'active' | 'history' | null
minimized: boolean
}
type Phase = 'loading' | 'streaming' | 'done' | 'error'
// 统一字段读取:活跃任务有 phase/createdAt,历史报告没有(按 done 处理)
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
if (!task) return 'loading'
if ('phase' in task) return task.phase
return 'done' // 历史报告视为已完成
}
function getContent(task: ActiveTask | HistoryReport | null): string {
return task?.content ?? ''
}
function getMeta(task: ActiveTask | HistoryReport | null) {
if (!task) return null
if ('meta' in task) return task.meta
// 历史报告
return { summary: task.summary, periods: task.periods }
}
export function AiAnalysisDialog({ task, mode, minimized }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const focusInputRef = useRef<HTMLInputElement>(null)
const [focus, setFocus] = useState('')
const [copied, setCopied] = useState(false)
const phase = getPhase(task)
const content = getContent(task)
const meta = getMeta(task)
const isHistory = mode === 'history'
const isWorking = phase === 'loading' || phase === 'streaming'
const open = !!task && !minimized
// 流式时自动滚动到底部
useEffect(() => {
if (open && phase === 'streaming' && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [content, phase, open])
// 切换任务时回填 focus
useEffect(() => {
setFocus(task && 'focus' in task ? task.focus : '')
}, [task])
const handleStartNew = useCallback(async () => {
if (!task) return
const name = 'name' in task ? task.name : ''
await startAnalysis(task.symbol, name, focus.trim())
}, [task, focus])
const handleCopy = async () => {
if (!content) return
try {
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* ignore */ }
}
if (!open) return null
const error = task && 'error' in task ? task.error : ''
return (
<AnimatePresence>
<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 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* ===== 头部 ===== */}
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-purple-500/[0.06] via-fuchsia-500/[0.04] to-transparent">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 shrink-0">
{isHistory
? <History className="h-4.5 w-4.5 text-purple-300" />
: <Sparkles className="h-4.5 w-4.5 text-purple-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">
{isHistory ? '历史分析报告' : 'AI 财务分析'}
</span>
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
{meta?.summary ? (
<span className="flex items-center gap-1 truncate">
<Database className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{meta.summary}</span>
</span>
) : isWorking ? <span></span> : null}
{phase === 'streaming' && (
<span className="flex items-center gap-1 text-purple-300 shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-purple-400 animate-pulse" />
</span>
)}
{isHistory && task && 'created_at' in task && (
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
)}
</div>
</div>
{/* 右侧操作按钮 */}
<div className="flex items-center gap-1 shrink-0">
{/* 复制:仅在内容就绪且非生成中显示 */}
{content && !isWorking && (
<button onClick={handleCopy} title="复制全文"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
)}
{/* 生成中:仅最小化(后台继续生成),无关闭按钮 */}
{!isHistory && isWorking && (
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<Minimize2 className="h-4 w-4" />
</button>
)}
{/* 完成态/历史报告:显示关闭按钮 */}
{(!isWorking || isHistory) && (
<button onClick={closeDialog} title="关闭"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
{/* ===== 内容区 ===== */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
{/* 加载态 */}
{phase === 'loading' && !content && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="relative">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 flex items-center justify-center">
<Sparkles className="h-4.5 w-4.5 text-purple-300 animate-pulse" />
</div>
<Loader2 className="absolute -inset-1 h-12 w-12 text-purple-400/40 animate-spin" style={{ animationDuration: '3s' }} />
</div>
<div className="text-xs text-secondary">AI </div>
<div className="text-[10px] text-muted"> / / / ,</div>
</div>
)}
{/* 错误态 */}
{phase === 'error' && (
<div className="flex flex-col items-center justify-center py-14 gap-3">
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
)}
<button onClick={handleStartNew}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-purple-500/15 border border-purple-400/30 text-xs text-purple-300 hover:bg-purple-500/20 transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* 报告内容 */}
{(content || phase === 'streaming') && (
<div className="relative">
<MarkdownRenderer content={content} />
{phase === 'streaming' && (
<span className="inline-block w-1.5 h-3.5 bg-purple-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
)}
</div>
{/* ===== 底部:自定义关注点输入 ===== */}
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
<Wand2 className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</div>
<input
ref={focusInputRef}
type="text"
value={focus}
onChange={e => setFocus(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
disabled={isWorking}
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看债务风险…回车重新分析' : '可留空,留空则全面分析')}
className={cn(
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
'focus:outline-none focus:ring-2 focus:ring-purple-400/30 transition-shadow disabled:opacity-50',
)}
/>
{isHistory ? (
<button
onClick={handleStartNew}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 transition-all shrink-0"
title="以此关注点重新生成新报告"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleStartNew}
disabled={isWorking}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
>
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
{phase === 'done' ? '重新分析' : '分析'}
</button>
)}
</div>
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
{isHistory
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
: '报告由项目已配置的 AI 模型基于本地财务数据生成;可在输入框追加关注点后重新生成。报告仅供参考,不构成投资建议。'}
</p>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
// ===== 小工具 =====
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 '' }
}
@@ -0,0 +1,14 @@
import { useDialogTask, useDialogState } from '@/lib/aiReportStore'
import { AiAnalysisDialog } from './AiAnalysisDialog'
/**
* AI 分析对话框宿主 —— 单点挂载在 Layout。
*
* 从 store 读取当前对话框状态(任务 + 最小化),把 AiAnalysisDialog 作为纯视图渲染。
* 一次挂载,全局生效:任意页面发起的分析都会显示在这个对话框里。
*/
export function AiAnalysisHost() {
const { task, mode } = useDialogTask()
const { minimized } = useDialogState()
return <AiAnalysisDialog task={task} mode={mode} minimized={minimized} />
}
@@ -0,0 +1,254 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Loader2, Check, AlertCircle } from 'lucide-react'
import { useActiveTasks, restoreDialog } from '@/lib/aiReportStore'
import type { ActiveTask } from '@/lib/aiReportStore'
/**
* AI 分析任务全局气泡容器 —— 玻璃拟态卡片,挂在网页右侧。
*
* 拖拽丝滑的关键(60fps):
* - 位置用 transform: translate3d 存储(走 GPU 合成层,不触发 layout/paint)
* - 拖动期间直接操作 DOM.style.transform,完全绕开 React setState 重渲染
* - 拖动结束才同步一次 state + 持久化 localStorage
* - 拖动时给容器加 .dragging 类,禁用所有 transition,消除回弹延迟
*
* 视觉:
* - 玻璃拟态(frosted glass):半透明 + backdrop-blur + 细边框 + 内发光
* - 固定宽度,内容居中,多任务竖向堆叠
* - 生成中:柔和呼吸光环(非刺眼 ping)
* - hover:展开操作区,带平滑过渡
*/
const BUBBLE_W = 148 // 卡片固定宽度(紧凑单行版)
const EDGE_MARGIN = 12 // 距视口边缘最小间距
export function AiReportBubble() {
const activeTasks = useActiveTasks()
const containerRef = useRef<HTMLDivElement>(null)
// pos 只在拖拽结束时更新一次(用于初始化/持久化),拖拽过程不触发它
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
// ===== 拖拽(纯 DOM 操作,60fps) =====
const draggingRef = useRef(false)
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 }) // 鼠标起点 + 元素起点
const movedRef = useRef(false) // 本次是否真的移动了(区分点击)
// 记录 pointerdown 时命中的卡片回调(松手时若未拖动则触发它 = 点击)
const clickTargetRef = useRef<(() => void) | null>(null)
const applyTransform = useCallback((x: number, y: number) => {
const el = containerRef.current
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
}, [])
const clamp = useCallback((x: number, y: number) => {
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight - 80
return {
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
}
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
draggingRef.current = true
movedRef.current = false
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
const el = containerRef.current
if (el) el.classList.add('dragging')
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}, [pos.x, pos.y])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!draggingRef.current) return
const dx = e.clientX - dragData.current.mx
const dy = e.clientY - dragData.current.my
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
const nx = dragData.current.ox + dx
const ny = dragData.current.oy + dy
const c = clamp(nx, ny)
applyTransform(c.x, c.y) // ← 直接改 DOM,不走 React,丝滑
}, [clamp, applyTransform])
const onPointerUp = useCallback(() => {
if (!draggingRef.current) return
draggingRef.current = false
const el = containerRef.current
if (el) el.classList.remove('dragging')
if (movedRef.current) {
// 拖动结束 → 持久化位置
setPos(prev => {
const transform = el?.style.transform ?? ''
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
savePos(finalPos)
return finalPos
})
} else {
// 未移动 → 视为点击,触发卡片回调
const fn = clickTargetRef.current
clickTargetRef.current = null
fn?.()
}
}, [])
// 窗口尺寸变化时确保不越界
useEffect(() => {
const onResize = () => {
setPos(prev => {
const c = clamp(prev.x, prev.y)
if (c.x !== prev.x || c.y !== prev.y) {
applyTransform(c.x, c.y)
return c
}
return prev
})
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [clamp, applyTransform])
// 初始化 transform(pos 变化时同步,如 resize / 首次挂载)
useEffect(() => {
applyTransform(pos.x, pos.y)
}, [pos.x, pos.y, applyTransform])
if (activeTasks.length === 0) return null
return (
<div
ref={containerRef}
className="ai-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
style={{
width: `${BUBBLE_W}px`,
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
touchAction: 'none',
// 拖动时禁用过渡(通过 .dragging 类控制);静止时用 transition 让 resize/吸附有动画
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<AnimatePresence mode="popLayout">
{activeTasks.map((task, i) => (
<BubbleItem
key={task.id}
task={task}
isLast={i === activeTasks.length - 1}
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
/>
))}
</AnimatePresence>
{/* 内联样式:拖动时禁用过渡,确保 1:1 跟手 */}
<style>{`
.ai-bubble-root.dragging { transition: none !important; }
`}</style>
</div>
)
}
// ===== 单个胶囊卡片(紧凑玻璃拟态) =====
function BubbleItem({ task, isLast, onPointerDown }: {
task: ActiveTask
isLast: boolean
onPointerDown: () => void
}) {
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
const isError = task.phase === 'error'
// 状态配色
const accent = isWorking
? 'from-purple-500/25 to-fuchsia-500/20 text-purple-300 border-purple-300/40 shadow-[0_6px_24px_-10px_rgba(168,85,247,0.5)]'
: isError
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
return (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -8 }}
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
className={isLast ? '' : 'mb-1.5'}
>
<div
onPointerDown={onPointerDown}
role="button"
tabIndex={0}
title={isWorking ? '生成中,点击恢复对话框' : isError ? '分析失败,点击重试' : '点击查看报告'}
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
>
{/* 生成中:顶部进度流光 */}
{isWorking && (
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-purple-200 to-transparent animate-bubble-progress" />
</div>
)}
{/* 状态图标 */}
<span className="flex h-4 w-4 items-center justify-center shrink-0">
{isWorking ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isError ? (
<AlertCircle className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span>
{/* 标的名(单行) */}
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
{task.name || task.symbol}
</span>
{/* 状态后缀 */}
<span className="shrink-0 text-[9px] leading-none">
{isWorking ? (
<span className="text-purple-300/80"></span>
) : isError ? (
<span className="text-red-300/80"></span>
) : (
<span className="text-emerald-300/80"></span>
)}
</span>
</div>
{/* 内联关键帧:进度条流动 */}
<style>{`
@keyframes bubble-progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
.animate-bubble-progress { animation: bubble-progress 1.6s ease-in-out infinite; }
`}</style>
</motion.div>
)
}
// ===== 位置持久化 =====
const POS_KEY = 'ai_bubble_pos'
function loadPos(): { x: number; y: number } {
// 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素)
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200)
try {
const v = localStorage.getItem(POS_KEY)
if (v) {
const p = JSON.parse(v)
if (typeof p.x === 'number' && typeof p.y === 'number') {
// 钳制到当前视口(防止保存的位置在缩小后的窗口外)
return {
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
}
}
}
} catch { /* ignore */ }
return { x: defaultX, y: defaultY }
}
function savePos(p: { x: number; y: number }) {
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
}
@@ -0,0 +1,225 @@
import { Fragment, type ReactNode } from 'react'
/**
* 轻量 Markdown 渲染器 — 零依赖,专为 AI 财务分析报告设计。
*
* 支持的语法(AI 财务分析提示词约束的子集,足够用):
* - 标题 # ## ### ####
* - 加粗 **text**
* - 行内代码 `code`
* - 无序列表 - / *
* - 有序列表 1.
* - 表格 | a | b |
* - 引用 >
* - 分隔线 --- / ***
* - 段落
*
* 不追求完整 GFM,只覆盖 AI 报告会产出的结构。
*/
// ===== 行内格式:加粗 / 行内代码 / 星号评级 =====
function renderInline(text: string, keyBase: string): ReactNode[] {
const nodes: ReactNode[] = []
// 正则:匹配 **加粗** 或 `代码` 或 ★ 评级
const re = /(\*\*([^*]+)\*\*)|(`([^`]+)`)/g
let last = 0
let m: RegExpExecArray | null
let i = 0
while ((m = re.exec(text)) !== null) {
if (m.index > last) nodes.push(<Fragment key={`${keyBase}-t-${i}`}>{text.slice(last, m.index)}</Fragment>)
if (m[1]) {
// 加粗
nodes.push(<strong key={`${keyBase}-b-${i}`} className="font-semibold text-foreground">{m[2]}</strong>)
} else if (m[3]) {
// 行内代码
nodes.push(
<code key={`${keyBase}-c-${i}`} className="px-1 py-0.5 rounded bg-elevated text-[0.85em] font-mono text-accent">
{m[4]}
</code>,
)
}
last = m.index + m[0].length
i++
}
if (last < text.length) nodes.push(<Fragment key={`${keyBase}-t-end`}>{text.slice(last)}</Fragment>)
return nodes
}
// ===== 表格解析 =====
function parseTable(lines: string[], start: number): { rows: string[][]; consumed: number } | null {
// 找到连续的表格行(以 | 开头)
const tableLines: string[] = []
let idx = start
while (idx < lines.length && lines[idx].trim().startsWith('|')) {
tableLines.push(lines[idx].trim())
idx++
}
if (tableLines.length < 2) return null
// 第二行必须是分隔行 |---|---|
if (!/^|[\s-:|]+$/.test(tableLines[1]) && !tableLines[1].split('|').every(c => /^[\s-:]*$/.test(c))) {
return null
}
const parseRow = (line: string) =>
line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
const header = parseRow(tableLines[0])
const body = tableLines.slice(2).map(parseRow)
return { rows: [header, ...body], consumed: tableLines.length }
}
// ===== 主渲染 =====
export function MarkdownRenderer({ content }: { content: string }) {
const lines = content.replace(/\r\n/g, '\n').split('\n')
const blocks: ReactNode[] = []
let i = 0
let key = 0
while (i < lines.length) {
const line = lines[i]
const trimmed = line.trim()
// 空行
if (!trimmed) {
i++
continue
}
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
blocks.push(<hr key={key++} className="my-6 border-border" />)
i++
continue
}
// 标题
const hMatch = trimmed.match(/^(#{1,4})\s+(.+)$/)
if (hMatch) {
const level = hMatch[1].length
const text = hMatch[2]
const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs'
const mtCls = level <= 2 ? 'mt-6' : 'mt-5'
blocks.push(
<div key={key++} className={`${sizeCls} ${mtCls} mb-3 font-semibold text-foreground flex items-center gap-1.5`}>
{renderInline(text, `h-${key}`)}
</div>,
)
i++
continue
}
// 引用
if (trimmed.startsWith('>')) {
const quoteLines: string[] = []
while (i < lines.length && lines[i].trim().startsWith('>')) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''))
i++
}
blocks.push(
<blockquote key={key++} className="my-4 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
{renderInline(quoteLines.join(' '), `q-${key}`)}
</blockquote>,
)
continue
}
// 表格
if (trimmed.startsWith('|')) {
const table = parseTable(lines, i)
if (table) {
const [header, ...body] = table.rows
const ncol = header.length
blocks.push(
<div key={key++} className="my-5 overflow-hidden rounded-btn border border-border/30">
<table className="w-full text-xs border-collapse table-fixed">
<colgroup>
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
<col className="w-auto" />
{Array.from({ length: ncol - 1 }).map((_, ci) => (
<col key={ci} className={ci === ncol - 2 ? 'w-1/2' : 'w-auto'} />
))}
</colgroup>
<thead>
<tr className="bg-elevated/50">
{header.map((cell, ci) => (
<th key={ci} className="px-2.5 py-1.5 text-left font-medium text-foreground border-b border-border/40 whitespace-nowrap">
{renderInline(cell, `th-${key}-${ci}`)}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, ri) => (
<tr key={ri} className="border-b border-border/20 last:border-0 hover:bg-elevated/20">
{row.map((cell, ci) => (
<td key={ci} className="px-2.5 py-1.5 text-foreground align-top break-words">
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
)
i += table.consumed
continue
}
}
// 无序列表
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = []
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*]\s+/, ''))
i++
}
blocks.push(
<ul key={key++} className="my-4 space-y-2">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-sm text-foreground leading-relaxed">
<span className="mt-[7px] h-1 w-1 rounded-full bg-accent/60 shrink-0" />
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
</li>
))}
</ul>,
)
continue
}
// 有序列表
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = []
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
i++
}
blocks.push(
<ol key={key++} className="my-4 space-y-2">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
{ii + 1}
</span>
<span className="flex-1 text-foreground">{renderInline(item, `ol-${key}-${ii}`)}</span>
</li>
))}
</ol>,
)
continue
}
// 普通段落
blocks.push(
<p key={key++} className="my-3 text-sm text-foreground leading-relaxed">
{renderInline(trimmed, `p-${key}`)}
</p>,
)
i++
}
// 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。
// 让各块的 my-* 自然叠加(margin collapse),间距更可控。
return <div>{blocks}</div>
}
@@ -0,0 +1,125 @@
import { useEffect } from 'react'
import { History, Trash2, FileText, Clock, Sparkles, Loader2 } from 'lucide-react'
import { useHistoryReports, openHistoryReport, deleteReport, loadHistory } from '@/lib/aiReportStore'
import { useActiveTasks } from '@/lib/aiReportStore'
/**
* AI 财务分析历史报告面板 —— 显示在财务页底部。
*
* - 列出最近 20 条报告(后端裁剪)
* - 点击查看 → 打开到对话框(历史模式)
* - 显示正在生成中的对应标的(若该标的有活跃任务,标注)
* - 支持删除单条
*/
export function ReportHistoryPanel() {
const { reports, loaded } = useHistoryReports()
const activeTasks = useActiveTasks()
// 首次挂载拉取一次
useEffect(() => { loadHistory() }, [])
// 活跃任务的 symbol 集合(用于在历史列表里标注"生成中")
const activeSymbols = new Set(activeTasks.map(t => t.symbol))
if (!loaded) {
return (
<div className="rounded-card border border-border/40 bg-surface px-4 py-6 text-center">
<Loader2 />
</div>
)
}
if (reports.length === 0) {
return (
<div className="rounded-card border border-dashed border-border/50 bg-surface/50 px-6 py-8 text-center">
<History className="mx-auto h-6 w-6 text-muted/40" />
<div className="mt-2 text-xs text-muted"></div>
<div className="mt-0.5 text-[10px] text-muted/60">AI ,</div>
</div>
)
}
return (
<div className="rounded-card border border-border bg-surface overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/50 bg-elevated/20">
<div className="flex items-center gap-2">
<History className="h-3.5 w-3.5 text-secondary" />
<span className="text-xs font-medium text-foreground"></span>
<span className="text-[10px] text-muted">{reports.length}/20</span>
</div>
<span className="text-[10px] text-muted/60"> · 20 </span>
</div>
{/* 列表 */}
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
{reports.map(r => {
const isGenerating = activeSymbols.has(r.symbol)
return (
<div
key={r.id}
className="group flex items-center gap-3 px-4 py-2.5 hover:bg-elevated/30 transition-colors cursor-pointer"
onClick={() => openHistoryReport(r.id)}
>
{/* 图标 */}
<div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${
isGenerating
? 'bg-purple-400/10 text-purple-300'
: 'bg-elevated text-secondary group-hover:text-accent'
}`}>
{isGenerating
? <Sparkles className="h-3.5 w-3.5 animate-pulse" />
: <FileText className="h-3.5 w-3.5" />}
</div>
{/* 主信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
{r.focus && (
<span className="hidden sm:inline-block px-1.5 py-px rounded bg-purple-400/10 text-purple-300 text-[9px] shrink-0">
{r.focus}
</span>
)}
</div>
{/* 摘要 */}
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted/70 flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{fmtRelative(r.created_at)}
</span>
{r.summary && (
<span className="text-[10px] text-muted/50 truncate">{r.summary}</span>
)}
</div>
</div>
{/* 删除按钮 */}
<button
onClick={e => { e.stopPropagation(); deleteReport(r.id) }}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-danger/10 text-muted hover:text-danger transition-all shrink-0"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</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', { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
@@ -0,0 +1,344 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles, AlertTriangle, Loader2 } from 'lucide-react'
import {
useFinancialMetrics,
useFinancialIncome,
useFinancialBalanceSheet,
useFinancialCashFlow,
} from '@/lib/useFinancials'
import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format'
import { Skeleton } from '@/components/data/Skeleton'
import { startAnalysis, findLatestHistoryReport, openHistoryReport } from '@/lib/aiReportStore'
import { toast } from '@/components/Toast'
interface Props {
symbol: string
name: string
}
type TabKey = 'metrics' | 'income' | 'balance_sheet' | 'cash_flow'
const TABS: { key: TabKey; label: string; icon: typeof TrendingUp }[] = [
{ key: 'metrics', label: '核心指标', icon: TrendingUp },
{ key: 'income', label: '利润表', icon: FileText },
{ key: 'balance_sheet', label: '资产负债表', icon: Wallet },
{ key: 'cash_flow', label: '现金流量表', icon: Activity },
]
// 字段定义:键 → (中文名, 格式化类型)
// pct=百分点(存的是 12.3 表示 12.3%); amount=金额(元,转亿/万亿); perShare=每股; num=普通数值(保留2位)
type FmtType = 'pct' | 'amount' | 'perShare' | 'num'
type FieldDef = { label: string; fmt: FmtType; group?: string }
const FIELD_DEFS: Record<TabKey, FieldDef[]> = {
metrics: [
{ label: '基本每股收益 EPS', fmt: 'perShare', key: 'eps_basic' } as any,
{ label: '稀释每股收益 EPS', fmt: 'perShare', key: 'eps_diluted' } as any,
{ label: '每股净资产 BPS', fmt: 'perShare', key: 'bps' } as any,
{ label: '每股经营现金流', fmt: 'perShare', key: 'ocfps' } as any,
{ label: '净资产收益率 ROE', fmt: 'pct', key: 'roe' } as any,
{ label: '稀释 ROE', fmt: 'pct', key: 'roe_diluted' } as any,
{ label: '总资产收益率 ROA', fmt: 'pct', key: 'roa' } as any,
{ label: '销售毛利率', fmt: 'pct', key: 'gross_margin' } as any,
{ label: '销售净利率', fmt: 'pct', key: 'net_margin' } as any,
{ label: '资产负债率', fmt: 'pct', key: 'debt_to_asset_ratio' } as any,
{ label: '营业收入同比增长', fmt: 'pct', key: 'revenue_yoy' } as any,
{ label: '净利润同比增长', fmt: 'pct', key: 'net_income_yoy' } as any,
{ label: '经营现金/营收', fmt: 'pct', key: 'operating_cash_to_revenue' } as any,
{ label: '存货周转率', fmt: 'num', key: 'inventory_turnover' } as any,
],
income: [
{ label: '营业收入', fmt: 'amount', key: 'revenue' } as any,
{ label: '营业成本', fmt: 'amount', key: 'operating_cost' } as any,
{ label: '营业利润', fmt: 'amount', key: 'operating_profit' } as any,
{ label: '销售费用', fmt: 'amount', key: 'selling_expense' } as any,
{ label: '管理费用', fmt: 'amount', key: 'admin_expense' } as any,
{ label: '研发费用', fmt: 'amount', key: 'rd_expense' } as any,
{ label: '财务费用', fmt: 'amount', key: 'financial_expense' } as any,
{ label: '营业外收入', fmt: 'amount', key: 'non_operating_income' } as any,
{ label: '营业外支出', fmt: 'amount', key: 'non_operating_expense' } as any,
{ label: '利润总额', fmt: 'amount', key: 'total_profit' } as any,
{ label: '所得税', fmt: 'amount', key: 'income_tax' } as any,
{ label: '净利润', fmt: 'amount', key: 'net_income' } as any,
{ label: '归母净利润', fmt: 'amount', key: 'net_income_attributable' } as any,
{ label: '扣非净利润', fmt: 'amount', key: 'net_income_deducted' } as any,
{ label: '基本每股收益', fmt: 'perShare', key: 'basic_eps' } as any,
{ label: '稀释每股收益', fmt: 'perShare', key: 'diluted_eps' } as any,
],
balance_sheet: [
{ label: '资产总计', fmt: 'amount', key: 'total_assets' } as any,
{ label: '流动资产合计', fmt: 'amount', key: 'total_current_assets' } as any,
{ label: '非流动资产合计', fmt: 'amount', key: 'total_non_current_assets' } as any,
{ label: '货币资金', fmt: 'amount', key: 'cash_and_equivalents' } as any,
{ label: '应收账款', fmt: 'amount', key: 'accounts_receivable' } as any,
{ label: '存货', fmt: 'amount', key: 'inventory' } as any,
{ label: '固定资产', fmt: 'amount', key: 'fixed_assets' } as any,
{ label: '无形资产', fmt: 'amount', key: 'intangible_assets' } as any,
{ label: '商誉', fmt: 'amount', key: 'goodwill' } as any,
{ label: '负债合计', fmt: 'amount', key: 'total_liabilities' } as any,
{ label: '流动负债合计', fmt: 'amount', key: 'total_current_liabilities' } as any,
{ label: '非流动负债合计', fmt: 'amount', key: 'total_non_current_liabilities' } as any,
{ label: '短期借款', fmt: 'amount', key: 'short_term_borrowing' } as any,
{ label: '长期借款', fmt: 'amount', key: 'long_term_borrowing' } as any,
{ label: '应付账款', fmt: 'amount', key: 'accounts_payable' } as any,
{ label: '所有者权益合计', fmt: 'amount', key: 'total_equity' } as any,
{ label: '归母所有者权益', fmt: 'amount', key: 'equity_attributable' } as any,
{ label: '未分配利润', fmt: 'amount', key: 'retained_earnings' } as any,
{ label: '少数股东权益', fmt: 'amount', key: 'minority_interest' } as any,
],
cash_flow: [
{ label: '经营活动现金流净额', fmt: 'amount', key: 'net_operating_cash_flow' } as any,
{ label: '投资活动现金流净额', fmt: 'amount', key: 'net_investing_cash_flow' } as any,
{ label: '筹资活动现金流净额', fmt: 'amount', key: 'net_financing_cash_flow' } as any,
{ label: '固定资产/无形资产投资', fmt: 'amount', key: 'capex' } as any,
{ label: '现金及等价物净增加额', fmt: 'amount', key: 'net_cash_change' } as any,
],
}
function formatValue(v: number | null | undefined, fmt: FmtType): string {
if (v == null || Number.isNaN(v)) return '—'
switch (fmt) {
case 'pct':
// 存储的是百分点(12.3 表示 12.3%),直接保留2位 + %
return `${v.toFixed(2)}%`
case 'amount':
// 金额(元)→ 亿/万亿;保留负号
return fmtBigNum(v)
case 'perShare':
return fmtPrice(v, 2)
case 'num':
default:
return v.toFixed(2)
}
}
export function StockFinancialDetail({ symbol, name }: Props) {
const [tab, setTab] = useState<TabKey>('metrics')
// AI 分析:点击时检查历史,若已有同标的报告则二次确认
const [checking, setChecking] = useState(false)
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
const handleAiClick = async () => {
if (checking) return
setChecking(true)
try {
const latest = await findLatestHistoryReport(symbol)
if (latest) {
// 有历史报告 → 弹二次确认
setConfirmReport({ id: latest.id, created_at: latest.created_at, focus: latest.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')
}
const metrics = useFinancialMetrics(symbol)
const income = useFinancialIncome(symbol)
const balance = useFinancialBalanceSheet(symbol)
const cashFlow = useFinancialCashFlow(symbol)
const queryMap = {
metrics: metrics,
income: income,
balance_sheet: balance,
cash_flow: cashFlow,
} as const
const current = queryMap[tab]
// 按 period_end 降序(最新在前);同步默认 latest_only,通常只有1期
const rows = (current.data?.data ?? []).slice().sort((a, b) =>
(b.period_end ?? '').localeCompare(a.period_end ?? '')
)
const fieldDefs = FIELD_DEFS[tab]
// 头部报告期信息取最新一期(优先用当前 tab,兜底用 metrics)
const latestPeriod = rows[0]?.period_end ?? metrics.data?.data?.[0]?.period_end ?? null
const latestAnnounce = rows[0]?.announce_date ?? metrics.data?.data?.[0]?.announce_date ?? null
return (
<div className="rounded-card border border-border bg-surface overflow-hidden">
{/* 头部:标的 + 报告期 */}
<div className="px-5 py-4 border-b border-border flex items-center gap-3 flex-wrap">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-lg font-semibold text-foreground">{name}</span>
<span className="text-xs font-mono text-muted">{symbol}</span>
</div>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={handleAiClick}
disabled={checking}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 hover:border-purple-400/40 transition-all shrink-0 disabled:opacity-50"
title="AI 财务分析"
>
{checking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
AI
</button>
{latestPeriod && (
<div className="flex items-center gap-1.5 text-xs text-secondary">
<CalendarDays className="h-3.5 w-3.5" />
<span> <span className="font-mono">{latestPeriod}</span></span>
{latestAnnounce && (
<span className="text-muted">· {fmtDate(latestAnnounce)}</span>
)}
</div>
)}
</div>
</div>
{/* 标签页 */}
<div className="flex items-center gap-1 px-3 pt-2 border-b border-border/60">
{TABS.map(t => {
const Icon = t.icon
const isActive = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
isActive
? 'border-accent text-accent'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<Icon className="h-3.5 w-3.5" />
{t.label}
</button>
)
})}
</div>
{/* 表格内容 */}
<div className="p-4">
{current.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton w="w-32" h="h-4" />
<Skeleton w="w-20" h="h-4" />
</div>
))}
</div>
) : rows.length === 0 ? (
<div className="py-10 text-center text-xs text-muted">
{TABS.find(t => t.key === tab)?.label}
</div>
) : (
<div className="space-y-5">
{/* 多期时为每期渲染一组;单期时只有一组 */}
{rows.map((row, ri) => (
<div key={row.period_end ?? ri}>
{rows.length > 1 && (
<div className="text-[11px] text-muted mb-2 flex items-center gap-1.5">
<CalendarDays className="h-3 w-3" />
<span className="font-mono text-secondary">{row.period_end}</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-0">
{fieldDefs.map((def: any) => {
const val = row[def.key]
return (
<div
key={def.key}
className="flex items-baseline justify-between gap-3 py-2 border-b border-border/40"
>
<span className="text-xs text-secondary shrink-0">{def.label}</span>
<span className="text-sm font-mono tabular-nums text-foreground text-right">
{formatValue(val, def.fmt)}
</span>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</div>
{/* AI 分析二次确认:已有该标的历史报告 */}
<AnimatePresence>
{confirmReport && (
<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/50 backdrop-blur-sm"
onClick={() => setConfirmReport(null)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[90vw] max-w-[400px] rounded-card border border-border bg-base shadow-2xl p-6"
>
<div className="flex items-start gap-3">
<div className="shrink-0 h-10 w-10 rounded-full bg-purple-400/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-purple-300" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-secondary leading-relaxed">
<span className="font-medium text-foreground">{name}</span>
<span className="font-mono text-muted"> {symbol}</span>
<span className="text-purple-300 font-medium"> {fmtReportTime(confirmReport.created_at)} </span>
AI
</p>
<p className="mt-2 text-[11px] text-muted">
,
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setConfirmReport(null)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-xs transition-colors"
>
</button>
<button
onClick={() => { if (confirmReport) openHistoryReport(confirmReport.id); setConfirmReport(null) }}
className="px-3 py-1.5 rounded-btn border border-border text-secondary hover:text-foreground text-xs font-medium transition-colors"
>
</button>
<button
onClick={() => { doAnalysis(); setConfirmReport(null) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-purple-500/80 to-fuchsia-500/80 text-white text-xs font-medium hover:from-purple-500 hover:to-fuchsia-500 transition-all"
>
<Sparkles className="h-3.5 w-3.5" />
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
)
}
// 历史报告时间友好显示
function fmtReportTime(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).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return '' }
}
@@ -0,0 +1,128 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
interface Props {
onSelect: (symbol: string, name: string) => void
}
/**
* 个股模糊搜索框 —— 财务页主入口。
* 复用 instrumentSearch 后端(代码 / 名称模糊匹配),单选即跳转该股财务详情。
* 模式对齐 Watchlist.StockSearchBox:useQuery + 外部点击关闭 + 键盘导航。
*/
export function StockFinancialSearch({ onSelect }: Props) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const search = useQuery({
queryKey: QK.instrumentSearch(query),
queryFn: () => api.instrumentSearch(query),
enabled: query.trim().length > 0,
staleTime: 30_000,
})
const results = search.data?.results ?? []
// 外部点击关闭下拉
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
function handleSelect(r: { symbol: string; name: string }) {
onSelect(r.symbol, r.name)
setQuery('')
setOpen(false)
setActiveIdx(-1)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') { setOpen(false); return }
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx(i => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx(i => Math.max(i - 1, -1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (activeIdx >= 0) handleSelect(results[activeIdx])
else if (results.length > 0) handleSelect(results[0])
}
}
const trimmed = query.trim()
return (
<div ref={containerRef} className="relative w-full max-w-xl mx-auto">
<div className="relative flex items-center">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder="输入股票代码或名称,如 600000 / 浦发"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }}
onFocus={() => { if (trimmed) setOpen(true) }}
onKeyDown={handleKeyDown}
// 较宽、更醒目 —— 作为财务页主入口
className="w-full h-11 pl-11 pr-10 rounded-card bg-surface border border-border text-sm text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:bg-base transition-colors"
/>
{search.isFetching && (
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted animate-spin" />
)}
</div>
<AnimatePresence>
{open && trimmed && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
className="absolute left-0 right-0 top-full mt-1.5 z-50 max-h-[360px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
>
{search.isLoading ? (
<div className="px-4 py-6 flex items-center justify-center gap-2 text-xs text-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : results.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-muted">
</div>
) : (
results.map((r, i) => (
<button
key={r.symbol}
type="button"
onClick={() => handleSelect(r)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors duration-100 ${
i === activeIdx ? 'bg-accent/10 text-accent' : 'hover:bg-elevated text-foreground'
}`}
>
<span className="font-mono shrink-0 text-xs w-[88px]">{r.symbol}</span>
<span className="truncate text-sm flex-1">{r.name}</span>
{r.code && <span className="text-[10px] text-muted font-mono shrink-0">{r.code}</span>}
</button>
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,444 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save, X, Plus, Search } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { usePreferences } from '@/lib/useSharedQueries'
interface Props {
/** 编辑现有规则;null=新建 */
rule: MonitorRule | null
/** 新建时的预填值 (如个股弹窗传入 symbol/scope) */
preset?: Partial<MonitorRule>
/** 极简模式: 个股场景, 隐藏 type/scope/阈值等, 只显示信号点选 */
simple?: boolean
onClose: () => void
onSaved?: () => void
}
const TYPE_DEFAULT_NAME: Record<string, string> = {
signal: '个股信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控',
}
const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
id: genRuleId(),
name: '',
enabled: true,
type: 'signal',
scope: 'symbols',
symbols: [],
sector: null,
strategy_id: null,
direction: 'entry',
conditions: [],
logic: 'or',
cooldown_seconds: 3600,
severity: 'info',
message: '',
...preset,
})
export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
)
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
const symbolSearch = useQuery({
queryKey: QK.instrumentSearch(symbolQuery),
queryFn: () => api.instrumentSearch(symbolQuery, 20),
enabled: symbolQuery.length > 0,
})
const save = useMutation({
mutationFn: () => {
const d = { ...draft }
// name 为空时用默认名
if (!d.name.trim()) {
const base = TYPE_DEFAULT_NAME[d.type] ?? '监控规则'
d.name = d.scope === 'symbols' && d.symbols.length > 0
? `${base} · ${d.symbols[0]}${d.symbols.length > 1 ? `${d.symbols.length}` : ''}`
: base
}
if (d.type === 'strategy') {
if (!d.strategy_id) throw new Error('策略监控必须选择一个策略')
} else {
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
for (const c of d.conditions) {
if (!c.field || !c.op) throw new Error('条件填写不完整')
if (c.op !== 'truth' && (c.value === null || c.value === undefined)) throw new Error('阈值条件需要数值')
}
}
if (d.scope === 'symbols' && d.symbols.length === 0) throw new Error('请选择至少一只股票')
return api.monitorRuleSave(d)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.monitorRules })
onSaved?.()
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
// 条件编辑
const updateCond = (idx: number, patch: Partial<MonitorCondition>) =>
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
const addCond = (op: 'truth' | 'threshold') =>
setDraft(d => ({
...d,
conditions: [...d.conditions, op === 'truth'
? { field: 'signal_volume_surge', op: 'truth' }
// simple 模式(个股弹窗)默认现价; 完整模式默认 RSI 超卖
: { field: simple ? 'close' : 'rsi_14', op: '<', value: simple ? 0 : 30 }],
}))
const removeCond = (idx: number) =>
setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
const addSymbol = (sym: string) => {
if (!draft.symbols.includes(sym)) {
setDraft(d => ({ ...d, symbols: [...d.symbols, sym] }))
}
setSymbolQuery('')
}
const thresholdFields = options.data?.threshold_fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const selectedSignals = draft.conditions.filter(c => c.op === 'truth').map(c => c.field)
const thresholdConds = draft.conditions.filter(c => c.op !== 'truth')
const onSignalPickerChange = (next: string[]) => {
const nonTruthConds = draft.conditions.filter(c => c.op !== 'truth')
const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' }))
setDraft(d => ({ ...d, conditions: [...nonTruthConds, ...truthConds] }))
}
// ── 极简模式: 只显示信号点选 + 可选描述 ──
if (simple) {
return (
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑监控' : '加入监控'}</h3>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{draft.symbols.length > 0 && (
<div className="flex flex-wrap gap-1">
{draft.symbols.map(s => (
<span key={s} className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary font-mono">{s}</span>
))}
</div>
)}
<div>
<div className="mb-1.5 text-[11px] text-muted"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"> ()</span>
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
{thresholdConds.length > 0 && (
<div className="space-y-1.5">
{thresholdConds.map((c, i) => {
const realIdx = draft.conditions.indexOf(c)
return (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="flex-1 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input type="number" value={c.value ?? 0} onChange={e => updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)}
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="给这条监控加个备注" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
{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={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></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 text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
// ── 完整模式: 监控页新建/编辑 ──
return (
<div 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">{editing ? '编辑监控规则' : '新建监控规则'}</h3>
<p className="mt-1 text-[11px] text-muted">,</p>
</div>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{/* 描述 (可选) + 类型 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: 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={draft.type} onChange={e => setDraft(d => ({ ...d, type: e.target.value as MonitorRule['type'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.types ?? []).map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</label>
</div>
{/* 作用范围 */}
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.scope} onChange={e => setDraft(d => ({ ...d, scope: e.target.value as MonitorRule['scope'] }))} className="h-9 w-32 rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.scopes ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
{draft.scope === 'symbols' && (
<div className="flex-1 flex flex-wrap items-center gap-1.5">
{draft.symbols.map(sym => (
<span key={sym} className="inline-flex items-center gap-1 rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary">
{sym}
<button onClick={() => setDraft(d => ({ ...d, symbols: d.symbols.filter(s => s !== sym) }))} className="text-muted hover:text-danger cursor-pointer">
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<div className="relative">
<input
value={symbolQuery}
onChange={e => setSymbolQuery(e.target.value)}
placeholder="搜索股票..."
className="h-7 w-32 rounded border border-border bg-base pl-6 pr-2 text-[11px] text-foreground focus:outline-none focus:border-accent/50"
/>
<Search className="absolute left-1.5 top-1.5 h-3.5 w-3.5 text-muted" />
{symbolSearch.data && symbolSearch.data.results.length > 0 && (
<div className="absolute z-10 mt-1 max-h-48 w-48 overflow-auto rounded border border-border bg-surface shadow-lg">
{symbolSearch.data.results.map(r => (
<button key={r.symbol} onClick={() => addSymbol(r.symbol)} className="block w-full px-2 py-1 text-left text-[11px] hover:bg-elevated cursor-pointer">
<span className="font-mono text-foreground/80">{r.symbol}</span>
<span className="ml-1 text-muted">{r.name}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
{draft.scope === 'all' && <span className="text-[11px] text-muted"></span>}
{draft.scope === 'sector' && <span className="text-[11px] text-muted/60">(,)</span>}
</div>
</div>
{/* 触发条件 (非 strategy) */}
{draft.type !== 'strategy' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.logic} onChange={e => setDraft(d => ({ ...d, logic: e.target.value as MonitorRule['logic'] }))} className="h-7 rounded border border-border bg-base px-1.5 text-[11px] text-foreground">
{(options.data?.logics ?? []).map(l => <option key={l.key} value={l.key}>{l.label}</option>)}
</select>
<button onClick={() => addCond('truth')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
</div>
{selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
<div>
<div className="mb-1.5 text-[10px] text-muted/70"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
) : null}
{thresholdConds.length > 0 && (
<div className="space-y-1.5">
{thresholdConds.map((c, i) => {
const realIdx = draft.conditions.indexOf(c)
return (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input type="number" value={c.value ?? 0} onChange={e => updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3 w-3" />
</button>
</div>
)
})}
</div>
)}
{draft.conditions.length === 0 && (
<div className="rounded border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted">
</div>
)}
</div>
)}
{/* strategy 类型: 选策略 + 方向 */}
{draft.type === 'strategy' && (
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.strategy_id ?? ''}
onChange={e => setDraft(d => ({ ...d, strategy_id: e.target.value || null }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
<option value=""> </option>
{(strategies.data?.presets ?? []).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.direction}
onChange={e => setDraft(d => ({ ...d, direction: e.target.value as MonitorRule['direction'] }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{(options.data?.directions ?? []).map(d => <option key={d.key} value={d.key}>{d.label}</option>)}
</select>
</label>
</div>
<p className="text-[10px] leading-4 text-muted/70">
entry=,exit=,both=
</p>
</div>
)}
{/* 通知设置 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">()</span>
<input type="number" value={draft.cooldown_seconds} onChange={e => setDraft(d => ({ ...d, cooldown_seconds: parseInt(e.target.value) || 0 }))} min={0} 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={draft.severity} onChange={e => setDraft(d => ({ ...d, severity: e.target.value as MonitorRule['severity'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.severities ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</label>
<label className="space-y-1.5 md:col-span-1">
<span className="text-[11px] text-muted">()</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="留空用默认文案" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
</div>
{/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */}
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-foreground">Webhook </span>
<span className="text-[9px] text-muted"></span>
</div>
{/* 渠道列表 */}
<div className="space-y-1.5">
{/* 飞书 (可用) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!draft.webhook_enabled}
onChange={e => setDraft(d => ({ ...d, webhook_enabled: e.target.checked }))}
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{draft.webhook_enabled && (
<span className={`ml-auto text-[9px] ${feishuConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{feishuConfigured ? '已配置' : '未配置'}
</span>
)}
</label>
{/* QMT (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">QMT</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</label>
{/* ptrade (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">ptrade</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</label>
</div>
{/* 飞书勾选但全局未配置 → 提示前往设置 */}
{draft.webhook_enabled && !feishuConfigured && (
<p className="text-[10px] leading-relaxed text-warning/80">
Webhook ,
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80"> </Link>
</p>
)}
{draft.webhook_enabled && feishuConfigured && (
<p className="text-[10px] leading-relaxed text-muted">
,
</p>
)}
</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={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></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 text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,162 @@
import { X } from 'lucide-react'
// ===== 筛选类型 =====
export interface ScreenerFilter {
priceMin: string
priceMax: string
changePctMin: string
changePctMax: string
momentum5dMin: string
momentum5dMax: string
amountMin: string // 成交额最小(亿)
marketCapMin: string // 市值最小(亿)
marketCapMax: string // 市值最大(亿)
floatCapMin: string // 流通市值最小(亿)
floatCapMax: string // 流通市值最大(亿)
volRatioMin: string // 量比最小
rsiMin: string
rsiMax: string
}
export const defaultFilter: ScreenerFilter = {
priceMin: '', priceMax: '',
changePctMin: '', changePctMax: '',
momentum5dMin: '', momentum5dMax: '',
amountMin: '',
marketCapMin: '', marketCapMax: '',
floatCapMin: '', floatCapMax: '',
volRatioMin: '',
rsiMin: '', rsiMax: '',
}
export function filterActive(f: ScreenerFilter): boolean {
return Object.values(f).some(v => v !== '')
}
export function countActiveFilters(f: ScreenerFilter): number {
let n = 0
if (f.priceMin || f.priceMax) n++
if (f.changePctMin || f.changePctMax) n++
if (f.momentum5dMin || f.momentum5dMax) n++
if (f.amountMin) n++
if (f.marketCapMin || f.marketCapMax) n++
if (f.floatCapMin || f.floatCapMax) n++
if (f.volRatioMin) n++
if (f.rsiMin || f.rsiMax) n++
return n
}
export function applyFilter(rows: any[], f: ScreenerFilter): any[] {
if (!filterActive(f)) return rows
const num = (v: string) => v === '' ? null : Number(v)
return rows.filter((r) => {
const close = Number(r.close ?? 0)
const v = (field: string) => num(field)
// 现价
if (v(f.priceMin) != null && close < v(f.priceMin)!) return false
if (v(f.priceMax) != null && close > v(f.priceMax)!) return false
// 涨跌幅(%)
const chg = (r.change_pct ?? 0) * 100
if (v(f.changePctMin) != null && chg < v(f.changePctMin)!) return false
if (v(f.changePctMax) != null && chg > v(f.changePctMax)!) return false
// 5日涨幅(%)
const m5 = (r.momentum_5d ?? 0) * 100
if (v(f.momentum5dMin) != null && m5 < v(f.momentum5dMin)!) return false
if (v(f.momentum5dMax) != null && m5 > v(f.momentum5dMax)!) return false
// 成交额(亿)
const amount = (r.amount ?? 0) / 1e8
if (v(f.amountMin) != null && amount < v(f.amountMin)!) return false
// 市值(亿)
const cap = close * (r.total_shares ?? 0) / 1e8
if (v(f.marketCapMin) != null && cap < v(f.marketCapMin)!) return false
if (v(f.marketCapMax) != null && cap > v(f.marketCapMax)!) return false
// 流通市值(亿)
const fcap = close * (r.float_shares ?? 0) / 1e8
if (v(f.floatCapMin) != null && fcap < v(f.floatCapMin)!) return false
if (v(f.floatCapMax) != null && fcap > v(f.floatCapMax)!) return false
// 量比
if (v(f.volRatioMin) != null && (r.vol_ratio_5d ?? 0) < v(f.volRatioMin)!) return false
// RSI
const rsi = r.rsi_14 ?? 0
if (v(f.rsiMin) != null && rsi < v(f.rsiMin)!) return false
if (v(f.rsiMax) != null && rsi > v(f.rsiMax)!) return false
return true
})
}
// ===== 筛选面板 =====
export function FilterPanel({ value, onChange, onClose, onReset }: {
value: ScreenerFilter
onChange: (f: ScreenerFilter) => void
onClose: () => void
onReset: () => void
}) {
const set = (key: keyof ScreenerFilter, v: string) => onChange({ ...value, [key]: v })
const fields: { label: string; min: keyof ScreenerFilter; max: keyof ScreenerFilter; unit: string; step?: string }[] = [
{ label: '现价', min: 'priceMin', max: 'priceMax', unit: '元', step: '0.1' },
{ label: '涨跌幅', min: 'changePctMin', max: 'changePctMax', unit: '%' },
{ label: '5日涨幅', min: 'momentum5dMin', max: 'momentum5dMax', unit: '%' },
{ label: '成交额', min: 'amountMin', max: 'amountMin', unit: '亿', step: '0.5' },
{ label: '总市值', min: 'marketCapMin', max: 'marketCapMax', unit: '亿', step: '10' },
{ label: '流通市值', min: 'floatCapMin', max: 'floatCapMax', unit: '亿', step: '10' },
{ label: '量比', min: 'volRatioMin', max: 'volRatioMin', unit: '', step: '0.1' },
{ label: 'RSI14', min: 'rsiMin', max: 'rsiMax', unit: '', step: '1' },
]
return (
<div className="rounded-card border border-accent/30 bg-accent/[0.03] p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-accent"></span>
<button onClick={onClose} className="p-0.5 rounded text-secondary hover:text-foreground transition-colors">
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2.5">
{fields.map((f) => {
const isRange = f.min !== f.max
return (
<div key={f.label} className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary shrink-0 w-14 text-right">{f.label}</span>
<input
type="number"
placeholder="最小"
value={value[f.min]}
onChange={(e) => set(f.min, e.target.value)}
step={f.step}
className="w-16 px-1.5 py-1 rounded-btn bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
{isRange && (
<>
<span className="text-[10px] text-muted">~</span>
<input
type="number"
placeholder="最大"
value={value[f.max]}
onChange={(e) => set(f.max, e.target.value)}
step={f.step}
className="w-16 px-1.5 py-1 rounded-btn bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
</>
)}
{f.unit && <span className="text-[10px] text-muted shrink-0">{f.unit}</span>}
</div>
)
})}
</div>
{filterActive(value) && (
<div className="flex items-center gap-2 pt-1">
<button
onClick={onReset}
className="text-[11px] text-muted hover:text-danger transition-colors"
>
</button>
<span className="text-[10px] text-muted"> · </span>
</div>
)}
</div>
)
}
@@ -0,0 +1,320 @@
/**
* 策略结果表格。
*
* 表格骨架(表头排序/sticky/遍历)由共享的 StockDataTable 承担;本组件只负责
* 策略页特有的单元格内容:symbol 列(含加自选按钮 + 失效行灰显)、strategies、
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
*/
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Check, Plus, Eye, EyeOff } from 'lucide-react'
import type { KlineRow } from '@/lib/api'
import { fmtPrice } from '@/lib/format'
import type { ColumnConfig } from '@/lib/screener-columns'
import { getSignals, signalCls } from '@/lib/stock-table'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { resolveCandleConfig } from '@/lib/list-columns'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { StockDataTable, type SortState } from '@/components/stock-table/StockDataTable'
interface ScreenerTableProps {
rows: any[]
columns: ColumnConfig[]
strategyIdToName: Record<string, string>
symbolStrategyMap: Map<string, string[]>
activeStrategy: string | null
watchlistSet: Set<string>
onPreview: (symbol: string, name: string) => void
onToggleWatchlist: (symbol: string, inList: boolean) => void
watchlistPending: boolean
/** symbol → 日k 数据,仅当启用日k列时传入 */
klineData?: Record<string, KlineRow[]>
/** 日k蜡烛图是否显示(表头眼睛开关) */
dailyKChartVisible?: boolean
onToggleDailyKChart?: () => void
/** 表头排序(受控,由 Screener.tsx 传入) */
sort?: SortState | null
onSortToggle?: (colId: string) => void
}
/** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。 */
function renderTagList(
tags: string[],
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
tagClassName: string,
): ReactNode {
if (tags.length === 0) return <span className="text-muted"></span>
const cfg = col.extDisplay
const maxTags = cfg?.maxTags ?? 0
const showAll = maxTags <= 0 || expanded || tags.length <= maxTags
const sliced = showAll ? tags : tags.slice(0, maxTags)
const hiddenIndices = maxTags > 0 ? cfg?.hiddenIndices : undefined
const visibleTags = hiddenIndices?.length
? sliced.filter((_, i) => !hiddenIndices.includes(i))
: sliced
const hiddenCount = tags.length - visibleTags.length
const isVertical = cfg?.tagLayout === 'vertical' && !expanded
return (
<div className={isVertical ? 'flex flex-col items-start gap-0.5' : 'flex flex-wrap gap-0.5'}>
{visibleTags.map((tag, i) => (
<span key={i} className={tagClassName}>{tag}</span>
))}
{!showAll && hiddenCount > 0 && (
<button
onClick={onToggle}
className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
>
+{hiddenCount}
</button>
)}
{showAll && maxTags > 0 && tags.length > maxTags && (
<button
onClick={onToggle}
className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-muted hover:text-foreground transition-colors"
>
</button>
)}
</div>
)
}
const EXT_TAG_CLS = 'inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-yellow-500 bg-yellow-500/10'
const STRATEGY_TAG_CLS = 'inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight bg-amber-500/10 text-amber-600 border border-amber-500/20'
function renderExtValue(
val: any,
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
): ReactNode {
if (val == null || Number.isNaN(val)) return <span className="text-muted"></span>
if (typeof val === 'number') {
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {
return <span className={val ? 'text-bull' : 'text-muted'}>{val ? '是' : '否'}</span>
}
const cfg = col.extDisplay
const str = String(val)
if (cfg?.displayMode === 'text') return <span className="text-foreground">{str}</span>
const separator = cfg?.separator?.trim() || null
const tags = separator
? str.split(separator).map(s => s.trim()).filter(Boolean)
: str.split(/[、,;\-]/).map(s => s.trim()).filter(Boolean)
return renderTagList(tags, col, expanded, onToggle, EXT_TAG_CLS)
}
export function ScreenerTable({
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
dailyKChartVisible = true, onToggleDailyKChart,
sort, onSortToggle,
}: ScreenerTableProps) {
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
// 日k列渲染尺寸(按眼睛开关取开启/收起尺寸)
const candleCol = columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible)
const candleResolved = resolveCandleConfig(candleCol?.candleConfig)
const candleSize = dailyKChartVisible
? { width: candleResolved.enabledWidth, height: candleResolved.enabledHeight }
: { width: candleResolved.disabledWidth, height: candleResolved.disabledHeight }
const toggleExpand = (key: string) => {
setExpandedCells(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const renderCell = (r: any, col: ColumnConfig): ReactNode => {
// ext 列
if (col.source.type === 'ext') {
const { configId, fieldName } = col.source
const val = r[`${configId}__${fieldName}`]
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
const tdClass = val == null || Number.isNaN(val)
? 'px-3 py-2 text-center text-muted'
: typeof val === 'number'
? 'px-3 py-2 text-right num tabular-nums'
: 'px-3 py-2 text-center'
const style: CSSProperties = {}
if (col.extDisplay?.maxWidth) style.maxWidth = col.extDisplay.maxWidth
return (
<td key={col.id} className={tdClass} style={style}>
{renderExtValue(val, col, expanded, () => toggleExpand(cellKey))}
</td>
)
}
const isExpired = !!r._expired
const key = col.source.key
// 策略页特有 / 需上下文的列
switch (key) {
case 'symbol': {
const board = boardTag(r.symbol)
const inWatchlist = watchlistSet.has(r.symbol)
return (
<td key={col.id} className="px-4 py-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onPreview(r.symbol, r.name ?? '')}
className={`flex items-center gap-2 text-left ${isExpired ? 'cursor-default' : ''}`}
>
{board ? (
<span className={`shrink-0 inline-flex items-center justify-center w-[18px] h-[18px] rounded text-[9px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
) : (
<span className="shrink-0 w-[18px]" />
)}
<span className="font-mono text-secondary group-hover:text-accent transition-colors duration-150 leading-snug">
{r.symbol}
</span>
{r.name && (
<span className="text-[11px] text-muted truncate group-hover:text-secondary transition-colors duration-150 leading-snug">
{r.name}
</span>
)}
</button>
{isExpired ? (
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
</span>
) : (
<button
type="button"
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
disabled={watchlistPending}
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
disabled:opacity-50
${inWatchlist
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
}`}
title={inWatchlist ? '移出自选' : '加入自选'}
>
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</button>
)}
</div>
</td>
)
}
case 'strategies': {
const strats = symbolStrategyMap.get(r.symbol) ?? (activeStrategy ? [activeStrategy] : [])
const tags = strats.map(sid => strategyIdToName[sid] ?? sid)
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
return (
<td key={col.id} className="px-3 py-2">
{renderTagList(tags, col, expanded, () => toggleExpand(cellKey), STRATEGY_TAG_CLS)}
</td>
)
}
case 'score': {
const numCls = 'px-3 py-2 text-right num tabular-nums'
return (
<td key={col.id} className={numCls}>
{r.score != null ? (
<span className={r.score >= 70 ? 'text-accent font-medium' : r.score >= 50 ? 'text-amber-400' : 'text-secondary'}>
{Number(r.score).toFixed(1)}
</span>
) : (
<span className="text-muted"></span>
)}
</td>
)
}
case 'signals': {
const signals = getSignals(r)
return (
<td key={col.id} className="px-3 py-2">
{signals.length > 0 ? (
<div className="flex flex-wrap gap-0.5">
{signals.slice(0, 3).map((s) => (
<span key={s.label} className={`inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight ${signalCls(s.type)}`}>
{s.label}
</span>
))}
{signals.length > 3 && (
<span className="text-[10px] text-muted">+{signals.length - 3}</span>
)}
</div>
) : (
<span className="text-muted text-xs"></span>
)}
</td>
)
}
case 'candle': {
const candleRows = klineData[r.symbol] ?? []
// 锁定列宽与行高:minWidth=maxWidth 防止 kline 加载前后整列宽度跳动(闪烁)
return (
<td
key={col.id}
className="px-3 py-2"
style={{ width: candleSize.width, minWidth: candleSize.width, maxWidth: candleSize.width, height: candleSize.height }}
>
<MiniCandlestick rows={candleRows} width={candleSize.width} height={candleSize.height} />
</td>
)
}
default:
// 纯数据列 → 共享原语
return renderBuiltinDataCell(r, col)
}
}
return (
<StockDataTable
columns={columns}
rows={rows}
renderCell={renderCell}
sort={sort}
onSortToggle={onSortToggle}
minWidth={Math.max(900, columns.filter(c => c.visible).length * 110)}
rowKey={(r: any) => `${r.symbol}${r._expired ? '-expired' : ''}`}
rowClassName={(r: any) => r._expired
? 'border-border/50 opacity-40'
: 'border-border hover:bg-elevated/50'
}
// 日k列表头:标签 + 显示/隐藏蜡烛图眼睛按钮(与自选页一致)
renderHeaderContent={onToggleDailyKChart ? (col) => {
if (col.source.type === 'builtin' && col.source.key === 'candle') {
return (
<span className="inline-flex items-center justify-center gap-1.5">
<span>{col.label}</span>
<button
type="button"
onClick={(event) => { event.stopPropagation(); onToggleDailyKChart() }}
className={`inline-flex items-center justify-center w-5 h-5 rounded transition-colors ${
dailyKChartVisible
? 'text-accent bg-accent/10 hover:bg-accent/20'
: 'text-muted hover:text-foreground hover:bg-elevated'
}`}
title={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
aria-label={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
>
{dailyKChartVisible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
</span>
)
}
return undefined
} : undefined}
/>
)
}
@@ -0,0 +1,86 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
interface Props {
/** 当前选中的信号 ID 列表 */
signals: string[]
/** 选中变化回调 */
onChange: (next: string[]) => void
/** 买点 / 卖点 — 决定自定义信号的过滤与配色主题 */
kind: 'entry' | 'exit'
/** 渲染尺寸: dialog = 选股弹窗紧凑样式; panel = 回测页设置抽屉样式 */
variant?: 'dialog' | 'panel'
}
/**
* 买卖触发器信号选择 — 选股页弹窗 / 回测页共用。
*
* - 内置信号 (signal_*): 全部展示
* - 自定义信号 (csg_*): 按 kind 过滤 (entry / exit / both)
* - entry 蓝色主题, exit 橙色主题
*/
export function SignalPicker({ signals, onChange, kind, variant = 'panel' }: Props) {
const customSignalsQuery = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const customOptions = useMemo(() => {
const list = (customSignalsQuery.data?.signals ?? [])
.filter(s => s.enabled && (s.kind === kind || s.kind === 'both'))
const names: Record<string, string> = {}
for (const cs of list) names[`csg_${cs.id}`] = cs.name
return { list, names }
}, [customSignalsQuery.data, kind])
const toggle = (sig: string) => {
const next = signals.includes(sig) ? signals.filter(x => x !== sig) : [...signals, sig]
onChange(next)
}
// 配色: entry 蓝色 (accent), exit 橙色 (warning/amber)
const isEntry = kind === 'entry'
const active = isEntry
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-warning/50 bg-warning/10 text-warning'
const idle = variant === 'dialog'
? 'border-border bg-base text-muted hover:border-accent/40'
: 'border-border bg-base text-muted hover:border-accent/40'
const customActive = isEntry
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-warning/50 bg-warning/10 text-warning'
const customIdle = 'border-amber-400/30 bg-amber-400/5 text-secondary hover:border-amber-400/50 hover:text-amber-400'
const btnCls = variant === 'dialog'
? 'rounded px-1.5 py-0.5 text-[10px] font-medium border transition-colors cursor-pointer'
: 'rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors cursor-pointer'
return (
<div className="flex flex-wrap gap-1.5">
{SIGNAL_OPTIONS.map(sig => (
<button
key={sig}
type="button"
onClick={() => toggle(sig)}
className={`${btnCls} ${signals.includes(sig) ? active : idle}`}
>
{cnSignal(sig)}
</button>
))}
{customOptions.list.map(cs => {
const id = `csg_${cs.id}`
return (
<button
key={id}
type="button"
onClick={() => toggle(id)}
title="自定义信号"
className={`${btnCls} ${signals.includes(id) ? customActive : customIdle}`}
>
{customOptions.names[id]}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,513 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Sparkles, Save, Loader2, ChevronLeft, ChevronRight, AlertTriangle, Settings2, FileText, Copy, Check, Terminal } from 'lucide-react'
import { api } from '@/lib/api'
import { storage } from '@/lib/storage'
import { cn } from '@/lib/cn'
// ===== 工具函数 =====
function parsePyValue(v: string): any {
const s = v.trim()
if (s === 'True') return true
if (s === 'False') return false
if (s === 'None') return null
return JSON.parse(s)
}
function slugId(): string {
return 'ai_' + Date.now().toString(36)
}
function parseParams(code: string): any[] {
const m = code.match(/"params"\s*:\s*\[([^\]]*)\]/)
if (!m) return []
const inner = m[1]
const blocks = inner.match(/\{[^}]+\}/g) ?? []
return blocks.map(b => {
const get = (key: string) => {
const re = new RegExp('"' + key + '"\\s*:\\s*(.+?)\\s*[,}]')
const r = b.match(re)
return r ? r[1].trim().replace(/^"(.*)"$/, '$1') : ''
}
const id = get('id'); if (!id) return null
const type = get('type')
return { id, type, label: get('label'), default: parsePyValue(get('default') || 'null'), min: parsePyValue(get('min') || 'null'), max: parsePyValue(get('max') || 'null'), step: parsePyValue(get('step') || 'null') }
}).filter(Boolean)
}
function parseStringList(code: string, key: string): string[] {
const re = new RegExp(key + '\\s*=\\s*\\[([^\\]]+)\\]')
const m = code.match(re)
if (!m) return []
const items = m[1].match(/"([^"]+)"/g)
return items ? items.map(x => x.replace(/"/g, '')) : []
}
function parseScoring(code: string): Record<string, number> {
const m = code.match(/"scoring"\s*:\s*\{([^}]+)\}/)
if (!m) return {}
const items = m[1].match(/"([^"]+)"\s*:\s*([0-9.]+)/g)
if (!items) return {}
const result: Record<string, number> = {}
for (const item of items) {
const p = item.match(/"([^"]+)"\s*:\s*([0-9.]+)/)
if (p) result[p[1]] = parseFloat(p[2])
}
return result
}
function parseRules(code: string): string {
const m = code.match(/RULES\s*=\s*"""\s*([\s\S]*?)\s*"""/)
return m ? m[1].trim() : ''
}
function parseMetaField(code: string, field: string): string {
const m = code.match(new RegExp('"' + field + '"\\s*:\\s*"([^"]+)"'))
return m ? m[1] : ''
}
// ===== 常量 =====
const DIRECTIONS = [
{ value: 'long', label: '做多' },
{ value: 'short', label: '做空' },
{ value: 'monitor', label: '监控' },
]
// ===== 组件 =====
const CUSTOM_TEMPLATE = `"""策略简短描述"""
import polars as pl
META = {
"id": "custom_my_strategy",
"name": "我的策略",
"description": "策略描述",
"tags": ["自定义"],
"basic_filter": {
"price_min": 3, "price_max": 200,
"market_cap_min": 10e8, "amount_min": 0.5e8,
"exclude_st": True, "exclude_new_days": 30,
},
"params": [],
"scoring": {
"change_pct": 0.5, "vol_ratio_5d": 0.5,
},
"order_by": "score",
"descending": True,
"limit": 100,
}
ENTRY_SIGNALS = ["signal_n_day_high"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 规则一
2. 规则二
3. 规则三
"""
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return (
(pl.col("close") > pl.col("ma20"))
& (pl.col("volume") > pl.col("vol_ma5") * 1.5)
)
`
interface Props { open: boolean; onClose: () => void; onSavedId?: (id: string) => void | Promise<void>; mode?: 'create' | 'modify' }
export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create' }: Props) {
// 根据 mode 选择存储 key
const draftStore = mode === 'modify' ? storage.strategyModify : storage.strategyDraft
const [step, setStep] = useState(1)
const [tab, setTab] = useState<'ai' | 'custom'>('ai')
const [customCopied, setCustomCopied] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [direction, setDirection] = useState('long')
const [rules, setRules] = useState('')
const [code, setCode] = useState('')
const [instruction, setInstruction] = useState('')
const [previewTab, setPreviewTab] = useState<'params' | 'code'>('params')
const [strategyId, setStrategyId] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [aiStatus, setAiStatus] = useState<{ configured: boolean } | null>(null)
const [checkedAi, setCheckedAi] = useState(false)
const [loaded, setLoaded] = useState(false)
// 打开时恢复草稿
useEffect(() => {
if (!open) { setLoaded(false); return }
const d = draftStore.get(null)
if (d) {
setStep(d.step ?? 1); setName(d.name ?? ''); setDescription(d.description ?? '')
setDirection(d.direction ?? 'long')
setRules(d.rules ?? ''); setCode(d.code ?? ''); setStrategyId(d.strategyId ?? '')
}
setLoaded(true)
}, [open])
// 打开时检查 AI 状态
useEffect(() => {
if (!open || checkedAi) return
api.strategyAiStatus().then(s => { setAiStatus(s); setCheckedAi(true) }).catch(() => setAiStatus({ configured: false }))
}, [open, checkedAi])
// 持久化
const persist = useCallback(() => {
if (!name && !rules && !code) {
draftStore.set(null)
} else {
draftStore.set({ name, description, direction, rules, code, step, strategyId })
}
}, [name, description, direction, rules, code, step, strategyId])
useEffect(() => { if (loaded) persist() }, [loaded, persist])
const clearDraft = () => {
setName(''); setDescription(''); setDirection('long')
setRules(''); setCode(''); setStep(1); setError(''); setInstruction('')
setStrategyId('')
}
const handleClose = () => { if (name || rules || code) persist(); onClose() }
// Step 1: 生成
const handleGenerate = async () => {
if (!name.trim() || !rules.trim()) return
if (!aiStatus?.configured) { setError('AI 未配置,请在设置页面配置 API Key'); return }
setLoading(true); setError('')
try {
const id = strategyId || slugId()
setStrategyId(id)
const res = await api.strategyBuild(1, { name: name.trim(), description: description.trim(), direction, rules: rules.trim(), strategy_id: id })
if (!res.valid) { setError(res.error ?? '生成失败'); return }
setCode(res.code); setStep(2)
const genDesc = parseMetaField(res.code, 'description')
const genRules = parseRules(res.code)
if (genDesc) setDescription(genDesc)
if (genRules) setRules(genRules)
await api.strategySaveCode(id, res.code)
if (genRules) { const sr = storage.strategyRules.get({}); sr[id] = genRules; storage.strategyRules.set(sr) }
} catch (e: any) {
const msg = String(e?.message ?? '')
setError(msg.includes('API Key') || msg.includes('api_key') ? 'AI API Key 未配置或无效' : (msg || '生成失败'))
} finally { setLoading(false) }
}
// Step 2: AI 修改
const handleModify = async () => {
if (!instruction.trim() || !code) return
setLoading(true); setError('')
try {
const res = await api.strategyBuild(2, { current_code: code, instruction: instruction.trim() })
if (!res.valid) { setError(res.error ?? '修改失败'); return }
setCode(res.code); setInstruction('')
const genDesc = parseMetaField(res.code, 'description')
const updatedRules = parseRules(res.code)
if (genDesc) setDescription(genDesc)
if (updatedRules) setRules(updatedRules)
const idMatch = res.code.match(/"id"\s*:\s*"([^"]+)"/)
if (idMatch) {
await api.strategySaveCode(idMatch[1], res.code)
const sr = storage.strategyRules.get({}); sr[idMatch[1]] = updatedRules; storage.strategyRules.set(sr)
}
} catch (e: any) { setError(String(e?.message ?? '修改失败')) }
finally { setLoading(false) }
}
// 手动保存
const handleSave = async () => {
if (!code) return
setSaving(true)
try {
const idMatch = code.match(/"id"\s*:\s*"([^"]+)"/)
const id = idMatch?.[1] || strategyId || slugId()
await api.strategySaveCode(id, code)
const genRules = parseRules(code)
const finalRules = (genRules || rules).trim()
if (finalRules) { const saved = storage.strategyRules.get({}); saved[id] = finalRules; storage.strategyRules.set(saved) }
await onSavedId?.(id)
clearDraft()
setTimeout(() => onClose(), 1000)
} catch (e: any) { setError(String(e?.message ?? '保存失败')) }
setSaving(false)
}
if (!open) return null
const params = parseParams(code)
const entrySignals = parseStringList(code, 'ENTRY_SIGNALS')
const exitSignals = parseStringList(code, 'EXIT_SIGNALS')
const scoring = parseScoring(code)
const stopMatch = code.match(/STOP_LOSS\s*=\s*(-?[0-9.]+|None)/)
const codeStopLoss = stopMatch ? (stopMatch[1] === 'None' ? null : parseFloat(stopMatch[1])) : null
const holdMatch = code.match(/MAX_HOLD_DAYS\s*=\s*(-?[0-9.]+|None)/)
const codeHoldDays = holdMatch ? (holdMatch[1] === 'None' ? null : parseInt(holdMatch[1])) : null
const hasParams = params.length > 0 || entrySignals.length > 0 || exitSignals.length > 0 || Object.keys(scoring).length > 0
return (
<AnimatePresence>
<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"
onClick={e => { if (e.target === e.currentTarget) handleClose() }}>
<motion.div initial={{ opacity: 0, scale: 0.95, y: 10 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="w-[820px] max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
{/* 标题 */}
<div className="grid grid-cols-[1fr_auto_1fr] items-center px-5 py-2.5 border-b border-border/50">
{/* 左侧:Tab 切换 */}
<div className="flex rounded-lg bg-elevated p-0.5 w-fit">
<button onClick={() => setTab('ai')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'ai' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-foreground')}>
<Sparkles className="h-3 w-3 inline mr-1" />AI
</button>
<button onClick={() => setTab('custom')} className={cn('px-3 py-1 rounded-md text-xs font-medium transition-all cursor-pointer', tab === 'custom' ? 'bg-accent/15 text-accent' : 'text-muted hover:text-foreground')}>
<FileText className="h-3 w-3 inline mr-1" />
</button>
</div>
{/* 中间:标题 */}
<span className="text-sm font-semibold text-foreground">
{strategyId ? '修改策略' : '创建策略'}
</span>
{/* 右侧:步骤 + 关闭 */}
<div className="flex items-center justify-end gap-2">
{tab === 'ai' && (
<div className="flex items-center gap-1">
<span className={'w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 1 ? 'bg-amber-400/20 text-amber-400' : 'bg-emerald-400/20 text-emerald-400')}>1</span>
<span className="text-muted/20 text-[10px]"></span>
<span className={'w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ' + (step === 2 ? 'bg-amber-400/20 text-amber-400' : 'bg-border/50 text-muted')}>2</span>
</div>
)}
<button onClick={handleClose} className="p-1.5 rounded-lg hover:bg-elevated"><X className="h-4 w-4 text-muted" /></button>
</div>
</div>
{/* Tab 描述 */}
<div className="px-5 py-2 border-b border-border/30 bg-elevated/30">
{tab === 'ai' ? (
<div className="flex items-center gap-2 text-[11px]">
<Sparkles className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="text-amber-400/80"> 1 2 </span>
</div>
) : (
<div className="flex items-center gap-2 text-[11px]">
<Terminal className="h-3.5 w-3.5 text-accent shrink-0" />
<span className="text-muted"> Python </span>
</div>
)}
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-4">
{tab === 'ai' ? (<>
{aiStatus && !aiStatus.configured && (
<div className="rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 flex items-center gap-3">
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
<div className="flex-1 text-xs text-amber-400/80">AI API Key </div>
<button onClick={() => { persist(); window.location.href = '/settings?tab=ai' }}
className="h-7 px-3 rounded-lg bg-amber-400/15 border border-amber-400/30 text-amber-400 text-xs font-medium flex items-center gap-1.5 hover:bg-amber-400/20 shrink-0">
<Settings2 className="h-3 w-3" />
</button>
</div>
)}
{step === 1 ? (
<>
<div className="space-y-2">
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="策略名称,如:强势反包"
className="w-full h-9 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm font-medium text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
<input type="text" value={description} onChange={e => setDescription(e.target.value)} placeholder="一句话描述,如:筛选前日阴线下跌、今日放量阳线反包的短线强势股"
className="w-full h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
</div>
<div>
<span className="text-[10px] text-muted/50 uppercase tracking-wider mb-1.5 block"></span>
<div className="flex gap-1">
{DIRECTIONS.map(d => (
<button key={d.value} onClick={() => setDirection(d.value)} className={'px-2.5 py-1 rounded text-[11px] font-medium border transition-colors ' + (direction === d.value ? 'border-amber-400/40 bg-amber-400/10 text-amber-400' : 'border-border bg-base text-muted hover:border-amber-400/30')}>{d.label}</button>
))}
</div>
</div>
<div>
<span className="text-[10px] text-muted/50 uppercase tracking-wider mb-1.5 block"></span>
<textarea value={rules} onChange={e => setRules(e.target.value)}
placeholder="描述你的选股逻辑,AI 会自动提取参数。例如:\n前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。"
className="w-full h-28 px-3 py-2 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 resize-none focus:outline-none focus:ring-2 focus:ring-accent/30" />
</div>
{error && <div className="text-[11px] text-danger bg-danger/10 border border-danger/20 rounded-lg px-3 py-2">{error}</div>}
<button onClick={handleGenerate} disabled={loading || !name.trim() || !rules.trim()}
className="w-full h-10 rounded-xl bg-gradient-to-r from-amber-500/20 to-amber-500/10 border border-amber-400/30 text-amber-400 text-sm font-medium flex items-center justify-center gap-2 hover:from-amber-500/30 hover:to-amber-500/20 disabled:opacity-40 transition-all">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{loading ? 'AI 生成中...' : code ? '重新生成' : 'AI 生成策略'}
</button>
</>
) : (
<>
{/* Tab 切换 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-0.5">
<button onClick={() => setPreviewTab('params')} className={'px-3 py-1 rounded text-xs font-medium transition-colors ' + (previewTab === 'params' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-secondary')}></button>
<button onClick={() => setPreviewTab('code')} className={'px-3 py-1 rounded text-xs font-medium transition-colors ' + (previewTab === 'code' ? 'bg-amber-400/15 text-amber-400' : 'text-muted hover:text-secondary')}></button>
</div>
</div>
{previewTab === 'params' ? (
hasParams ? (
<div className="rounded-xl border border-border/30 bg-surface/30 overflow-hidden max-h-96 overflow-y-auto">
<div className="divide-y divide-border/10">
{params.length > 0 && (
<div className="px-4 py-3 space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
{params.map((p: any) => (
<div key={p.id} className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-24 shrink-0 text-right">{p.label}</span>
{p.type === 'bool' ? (
<span className={'text-[11px] font-mono ' + (p.default ? 'text-accent' : 'text-muted')}>{p.default ? '✓ 是' : '✗ 否'}</span>
) : (
<>
<span className="text-[11px] font-mono text-foreground">{p.default}</span>
<span className="text-[10px] text-muted">{p.min} ~ {p.max}{p.type === 'float' || p.type === 'int' ? ' · 步长 ' + p.step : ''}</span>
</>
)}
</div>
))}
</div>
)}
{(entrySignals.length > 0 || exitSignals.length > 0) && (
<div className="px-4 py-3 space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
{entrySignals.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-400 w-10 shrink-0"></span>
<div className="flex flex-wrap gap-0.5">
{entrySignals.map((s: string) => <span key={s} className="px-1.5 py-0.5 rounded bg-emerald-400/10 text-emerald-400 text-[10px] font-mono">{s}</span>)}
</div>
</div>
)}
{exitSignals.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-danger w-10 shrink-0"></span>
<div className="flex flex-wrap gap-0.5">
{exitSignals.map((s: string) => <span key={s} className="px-1.5 py-0.5 rounded bg-danger/10 text-danger text-[10px] font-mono">{s}</span>)}
</div>
</div>
)}
<div className="flex items-center gap-4 text-[10px] text-muted mt-1">
{codeStopLoss !== null && <span>: {(codeStopLoss * 100).toFixed(1)}%</span>}
{codeHoldDays !== null && <span>: {codeHoldDays} </span>}
</div>
</div>
)}
{Object.keys(scoring).length > 0 && (
<div className="px-4 py-3 space-y-2">
<div className="text-[10px] text-muted/50 uppercase tracking-wider"></div>
{Object.entries(scoring).map(([k, v]) => (
<div key={k} className="flex items-center gap-2">
<span className="text-[10px] text-muted w-24 shrink-0 text-right font-mono">{k}</span>
<div className="flex-1 h-1.5 bg-elevated rounded-full overflow-hidden">
<div className="h-full bg-amber-400/60 rounded-full" style={{ width: Math.min(v * 100, 100) + '%' }} />
</div>
<span className="w-8 text-right text-[10px] font-mono text-muted">{Math.round(v * 100)}%</span>
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="rounded-xl border border-border/30 bg-surface/30 px-4 py-6 text-[11px] text-muted text-center"></div>
)
) : (
<pre className="bg-base border border-border/30 rounded-lg p-3 text-[11px] font-mono text-foreground/80 overflow-auto max-h-96 whitespace-pre-wrap">{code}</pre>
)}
{error && <div className="text-[11px] text-danger bg-danger/10 border border-danger/20 rounded-lg px-3 py-2">{error}</div>}
<div className="flex gap-2">
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleModify()}
placeholder="调整策略逻辑,如:增加RSI超卖条件、要求今日放量、修改均线为30日..."
className="flex-1 h-9 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30" />
<button onClick={handleModify} disabled={loading || !instruction.trim()}
className="h-9 px-4 rounded-lg bg-amber-400/15 border border-amber-400/30 text-amber-400 text-xs font-medium flex items-center gap-1.5 hover:bg-amber-400/20 disabled:opacity-40 transition-all">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
AI
</button>
</div>
<p className="text-[10px] text-muted/40"></p>
</>
)}
</>
) : (
/* 自定义编写 */
<div className="space-y-4">
<div className="rounded-xl border border-border/40 bg-elevated/50 p-4 space-y-2.5">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<div className="space-y-1.5 text-[11px] text-secondary leading-relaxed">
<p> <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">data/strategies/custom/</code> <code className="px-1 py-0.5 rounded bg-base text-xs font-mono text-foreground/80">.py</code> </p>
<div className="space-y-1 pl-1">
<div className="flex items-start gap-1.5">
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-accent/60 shrink-0" />
<span><strong className="text-foreground/80"> A</strong> <code className="text-[10px] font-mono text-foreground/80">filter(df, params) pl.Expr</code></span>
</div>
<div className="flex items-start gap-1.5">
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-amber-400/60 shrink-0" />
<span><strong className="text-foreground/80"> B</strong> <code className="text-[10px] font-mono text-foreground/80">filter_history(df, params) pl.DataFrame</code> + <code className="text-[10px] font-mono text-foreground/80">LOOKBACK_DAYS</code></span>
</div>
</div>
<p> <span className="text-accent">docs/strategy-guide.md</span></p>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground"></span>
<button onClick={() => { navigator.clipboard.writeText(CUSTOM_TEMPLATE); setCustomCopied(true); setTimeout(() => setCustomCopied(false), 2000) }}
className={cn('inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium transition-all cursor-pointer', customCopied ? 'bg-emerald-400/10 text-emerald-400' : 'bg-elevated text-muted hover:text-foreground hover:bg-accent/10')}>
{customCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{customCopied ? '已复制' : '复制模板'}
</button>
</div>
<pre className="rounded-xl border border-border/40 bg-base p-4 text-[10px] leading-relaxed font-mono text-foreground/70 overflow-auto max-h-[400px]">{CUSTOM_TEMPLATE}</pre>
</div>
</div>
)}
</div>
{/* 底部 */}
{tab === 'ai' && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border/50 bg-surface/50">
<button onClick={clearDraft} className="text-[10px] text-muted/40 hover:text-danger transition-colors"></button>
<div className="flex items-center gap-2">
{step === 1 && code && name.trim() && (
<button onClick={() => setStep(2)} className="h-7 px-3 rounded-lg border border-border text-xs text-secondary hover:text-foreground flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
</button>
)}
{step === 2 && (
<>
<button onClick={() => setStep(1)} className="h-7 px-3 rounded-lg border border-border text-xs text-secondary hover:text-foreground flex items-center gap-1">
<ChevronLeft className="h-3 w-3" />
</button>
<button onClick={handleSave} disabled={saving || loading}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-50 transition-all">
<Save className="h-3 w-3" />
{saving ? '保存中...' : '保存策略'}
</button>
</>
)}
</div>
</div>
)}
</motion.div>
</motion.div>
</AnimatePresence>
)
}
@@ -0,0 +1,223 @@
import { Settings2, TrendingDown, RadioTower } from 'lucide-react'
import { motion } from 'framer-motion'
import { storage } from '@/lib/storage'
// ===== 卡片尺寸 =====
export type CardSize = 'mini' | 'normal' | 'large' | 'hidden'
export function loadCardSize(): CardSize {
const v = storage.screenerCardSize.get('normal')
if (v === 'mini' || v === 'normal' || v === 'large' || v === 'hidden') return v
return 'normal'
}
const CARD_STYLES: Record<CardSize, {
wrap: string
card: string
name: string
count: string
desc: string
icon: string
}> = {
mini: {
wrap: 'gap-1',
card: 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full',
name: 'text-[10px]',
count: 'text-[11px]',
desc: '',
icon: 'h-3 w-3',
},
normal: {
wrap: 'gap-2',
card: 'relative inline-flex items-center gap-2 pl-3 pr-12 py-1.5 rounded-lg',
name: 'text-xs',
count: 'text-xs',
desc: 'text-[10px] text-muted leading-tight mt-0.5 line-clamp-1 max-w-[120px]',
icon: 'h-3.5 w-3.5',
},
large: {
wrap: 'gap-2',
card: 'relative inline-flex flex-col items-start pl-3.5 pr-12 py-2.5 rounded-btn min-w-[100px]',
name: 'text-xs',
count: 'text-lg font-mono font-bold tabular-nums',
desc: 'text-[10px] text-muted leading-tight mt-0.5 line-clamp-2 max-w-[140px]',
icon: 'h-3.5 w-3.5',
},
hidden: {
wrap: '',
card: '',
name: '',
count: '',
desc: '',
icon: '',
},
}
export { CARD_STYLES }
/** 获取卡片容器的 flex-wrap gap 样式 */
export function cardWrapCls(size: CardSize): string {
return `flex flex-wrap ${CARD_STYLES[size].wrap}`
}
// ===== 来源标签 =====
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
const BADGE_CLS_MAP: Record<string, string> = {
builtin: 'bg-secondary/10 text-muted border-border',
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
}
// ===== 策略卡片 =====
interface StrategyCardProps {
name: string
description?: string
source?: string
active: boolean
count?: number
/** 今日曾命中总数 */
everMatched?: number
/** 今日已失效数 (曾命中 - 当前命中) */
expiredCount?: number
loading: boolean
cardSize: CardSize
onRun: () => void
disabled: boolean
onSettings: () => void
/** 是否已加入策略监控 */
monitored?: boolean
/** 切换策略监控 (点击 RadioTower 图标) */
onToggleMonitor?: () => void
}
export function StrategyCard({
name, description, source, active, count, expiredCount,
loading, cardSize,
onRun, disabled, onSettings, monitored, onToggleMonitor,
}: StrategyCardProps) {
const cs = CARD_STYLES[cardSize]
const activeCls = active
? 'border-accent/50 bg-accent/10 shadow-[0_0_10px_rgba(59,130,246,0.1)]'
: 'border-border bg-surface hover:border-accent/40 hover:bg-accent/[0.03]'
const countCls = count === 0
? 'text-muted'
: 'bg-gradient-to-r from-amber-400 to-orange-500 bg-clip-text text-transparent'
const srcLabel = cardSize === 'mini' ? (SRC_MAP[source ?? ''] ?? '内') : (SRC_MAP[source ?? ''] ?? '内置')
const badgeCls = BADGE_CLS_MAP[source ?? 'builtin'] ?? BADGE_CLS_MAP.builtin
// 失效数 > 0 时显示
const hasExpired = expiredCount != null && expiredCount > 0
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
className={`${cs.card} border transition-all duration-150 text-left group ${activeCls}`}
>
{cardSize === 'large' ? (
<>
<button onClick={onRun} disabled={disabled}
className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait w-full">
<div className="flex items-center gap-1.5 max-w-full">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight shrink-0 ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium truncate text-foreground">{name}</span>
</div>
{description && (
<span className="text-[10px] text-muted leading-tight mt-0.5 line-clamp-1">{description}</span>
)}
{count != null && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex items-center gap-1">
<span className={`text-sm font-mono font-bold tabular-nums ${countCls}`}>{count}</span>
<span className="text-[10px] text-muted"></span>
</div>
{hasExpired && (
<div className="flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-red-500/8 border border-red-500/15">
<TrendingDown className="h-2.5 w-2.5 text-red-400" />
<span className="text-[10px] font-mono font-medium text-red-400">{expiredCount}</span>
</div>
)}
</div>
)}
{loading && <div className="mt-1 h-4 w-10 rounded bg-elevated animate-pulse" />}
</button>
<button onClick={(e) => { e.stopPropagation(); onSettings() }}
className="absolute top-1.5 right-1.5 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="absolute top-1.5 right-7 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`relative h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
</>
) : cardSize === 'normal' ? (
<>
<button onClick={onRun} disabled={disabled}
className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight shrink-0 ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium truncate text-foreground">{name}</span>
{count != null && (
<span className={`text-xs font-mono font-bold tabular-nums shrink-0 ${countCls}`}>{count}</span>
)}
{loading && <span className="w-5 h-3 rounded bg-elevated animate-pulse shrink-0" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{description && (
<span className="text-[10px] text-muted leading-tight line-clamp-1 max-w-[120px]">{description}</span>
)}
{hasExpired && (
<span className="text-[9px] font-mono text-red-400/80">{'-' + expiredCount}</span>
)}
</div>
</button>
<button onClick={(e) => { e.stopPropagation(); onSettings() }}
className="absolute top-1.5 right-1.5 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="absolute top-1.5 right-7 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`relative h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
</>
) : (
/* mini */
<>
<button onClick={onRun} disabled={disabled}
className="flex items-center gap-1 cursor-pointer disabled:opacity-50 disabled:cursor-wait">
<span className="text-[8px] px-0.5 rounded bg-secondary/10 text-muted border border-border font-medium leading-tight">{srcLabel}</span>
<span className="text-[10px] font-medium whitespace-nowrap text-foreground">{name}</span>
{count != null && (
<span className={`text-xs font-mono font-bold tabular-nums ${countCls}`}>{count}</span>
)}
{hasExpired && (
<span className="text-[9px] font-mono text-red-400/70">{'-' + expiredCount}</span>
)}
{loading && <span className="w-4 h-2.5 rounded bg-elevated animate-pulse" />}
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="relative p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
<button onClick={(e) => { e.stopPropagation(); onSettings() }}
className="p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
</>
)}
</motion.div>
)
}
@@ -0,0 +1,240 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'
import { X, Plus, GripVertical } from 'lucide-react'
import { api, type StrategyDetail } from '@/lib/api'
interface Props {
pool: string[]
onConfirm: (newPool: string[]) => void
onClose: () => void
}
const SOURCE_CLS: Record<string, string> = {
builtin: 'bg-accent/10 text-accent border-accent/20',
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
invalid: 'bg-danger/10 text-danger border-danger/20',
}
const SOURCE_LABEL: Record<string, string> = {
builtin: '内置',
custom: '自定义',
ai: 'AI',
invalid: '失效',
}
type SourceTab = 'all' | 'builtin' | 'custom' | 'ai'
const TABS: { id: SourceTab; label: string }[] = [
{ id: 'all', label: '全部' },
{ id: 'builtin', label: '内置' },
{ id: 'custom', label: '自定义' },
{ id: 'ai', label: 'AI' },
]
export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
// 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交
const [draftPool, setDraftPool] = useState<string[]>(() => [...pool])
const [allStrategies, setAllStrategies] = useState<StrategyDetail[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<SourceTab>('all')
useEffect(() => {
api.strategyList()
.then(d => setAllStrategies(d.strategies))
.catch(() => setAllStrategies([]))
.finally(() => setLoading(false))
}, [])
const stratMap = useMemo(() => {
const m = new Map<string, StrategyDetail>()
allStrategies.forEach(s => m.set(s.id, s))
return m
}, [allStrategies])
const validDraft = useMemo(
() => draftPool.filter(id => stratMap.has(id)),
[draftPool, stratMap]
)
const invalidPoolCount = draftPool.length - validDraft.length
const available = useMemo(
() => allStrategies.filter(s => !draftPool.includes(s.id)),
[allStrategies, draftPool]
)
// 按 Tab 分组过滤待选
const filteredAvailable = useMemo(() => {
if (activeTab === 'all') return available
return available.filter(s => s.source === activeTab)
}, [available, activeTab])
const handleAdd = useCallback((id: string) => {
setDraftPool(prev => prev.includes(id) ? prev : [...prev, id])
}, [])
const handleRemove = useCallback((id: string) => {
setDraftPool(prev => prev.filter(x => x !== id))
}, [])
const handleReorder = useCallback((newOrder: string[]) => {
setDraftPool(newOrder)
}, [])
return (
<AnimatePresence>
<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"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="w-[680px] max-h-[78vh] bg-surface border border-border rounded-card shadow-xl flex flex-col"
>
{/* 标题 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground">
<span className="text-muted font-normal text-xs">{validDraft.length} / {allStrategies.length}</span>
{invalidPoolCount > 0 && <span className="ml-2 text-[10px] text-danger">{invalidPoolCount} </span>}
</span>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
) : (
<div className="flex-1 min-h-0 grid grid-cols-2 gap-0">
{/* 左侧: 待选 (Tab 分组) */}
<div className="flex flex-col min-h-0 border-r border-border">
<div className="flex items-center gap-0.5 px-3 py-2 border-b border-border/60 shrink-0">
{TABS.map(tab => {
const count = tab.id === 'all'
? available.length
: available.filter(s => s.source === tab.id).length
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-btn transition-colors cursor-pointer ${
activeTab === tab.id
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-secondary hover:bg-elevated'
}`}
>
{tab.label}
<span className="ml-1 text-[9px] opacity-60">{count}</span>
</button>
)
})}
</div>
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
{filteredAvailable.length === 0 ? (
<div className="flex items-center justify-center h-full text-[11px] text-muted">
{available.length === 0 ? '全部已加入策略池' : '此分组无待选策略'}
</div>
) : filteredAvailable.map(s => (
<button
key={s.id}
onClick={() => handleAdd(s.id)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-btn
hover:bg-accent/8 transition-colors cursor-pointer group text-left"
>
<span className="flex-1 min-w-0">
<span className="text-[12px] text-foreground group-hover:text-accent transition-colors block truncate">{s.name}</span>
<span className="text-[10px] text-muted truncate block">{s.description}</span>
</span>
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[s.source] ?? SOURCE_CLS.builtin}`}>
{SOURCE_LABEL[s.source] ?? '内置'}
</span>
<Plus className="h-3.5 w-3.5 text-muted/40 group-hover:text-accent shrink-0" />
</button>
))}
</div>
</div>
{/* 右侧: 已选 (Reorder.Group 纵向拖拽) */}
<div className="flex flex-col min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/60 shrink-0">
<GripVertical className="h-3 w-3 text-muted/50" />
<span className="text-[10px] text-muted"> · </span>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{draftPool.length === 0 ? (
<div className="flex items-center justify-center h-full text-[11px] text-muted">
</div>
) : (
<Reorder.Group
axis="y"
values={draftPool}
onReorder={handleReorder}
className="space-y-1"
>
{draftPool.map(id => {
const s = stratMap.get(id)
const src = s?.source ?? 'invalid'
return (
<Reorder.Item
key={id}
value={id}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-btn
bg-accent/8 border border-accent/20
cursor-grab active:cursor-grabbing
hover:bg-accent/15 transition-colors group"
whileDrag={{ scale: 1.02, zIndex: 50, boxShadow: '0 4px 12px rgba(0,0,0,0.2)' }}
>
<GripVertical className="h-3.5 w-3.5 text-accent/40 group-hover:text-accent/70 shrink-0" />
<span className="flex-1 min-w-0 text-[12px] text-foreground truncate">{s?.name ?? id}</span>
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[src] ?? SOURCE_CLS.builtin}`}>
{SOURCE_LABEL[src] ?? '内置'}
</span>
<button
onClick={(e) => { e.stopPropagation(); handleRemove(id) }}
className="text-muted/40 hover:text-danger transition-colors cursor-pointer leading-none shrink-0"
title="移除"
>
<X className="h-3.5 w-3.5" />
</button>
</Reorder.Item>
)
})}
</Reorder.Group>
)}
</div>
</div>
</div>
)}
{/* 底部 */}
<div className="flex items-center justify-between px-4 py-2 border-t border-border shrink-0">
<span className="text-[10px] text-muted"></span>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-3 py-1 text-xs rounded-btn border border-border text-muted hover:text-foreground hover:border-border/80 transition-colors cursor-pointer"
>
</button>
<button
onClick={() => { onConfirm(draftPool); onClose() }}
className="px-3 py-1 text-xs rounded-btn bg-accent text-white hover:bg-accent/90 transition-colors cursor-pointer"
>
</button>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
@@ -0,0 +1,609 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
// 内置列名 → 中文标签
const FIELD_LABEL: Record<string, string> = {}
for (const c of BUILTIN_COLUMNS) {
if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
}
// enriched 列名别名
Object.assign(FIELD_LABEL, {
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
momentum_60d: '60D动量', turnover_rate: '换手率',
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
boll_upper: '布林上轨', boll_lower: '布林下轨',
})
interface Props {
strategyId: string | null
onClose: () => void
onSaved?: (displayLimit: number | null) => void
onAiModify?: () => void
onDeleted?: () => void
}
// ===== 可折叠区域 =====
function Section({ icon: Icon, title, accent, defaultOpen = true, children, extra }: {
icon?: React.ComponentType<{ className?: string }>
title: string
accent?: string
defaultOpen?: boolean
children: React.ReactNode
extra?: React.ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-xl border border-border/15 bg-surface/20 overflow-hidden">
<div className="flex items-center gap-2 px-3.5 py-2 hover:bg-surface/30 transition-colors">
<button
onClick={() => setOpen(v => !v)}
className="flex min-w-0 flex-1 items-center gap-2 text-left cursor-pointer"
>
<ChevronDown className={`h-3 w-3 text-muted/40 transition-transform duration-200 ${open ? '' : '-rotate-90'}`} />
{Icon && <Icon className={`h-3.5 w-3.5 ${accent ?? 'text-muted'}`} />}
<span className="text-[11px] font-medium text-foreground/70">{title}</span>
</button>
{extra && <div className="ml-auto flex items-center gap-1">{extra}</div>}
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="px-3.5 pb-3 pt-0.5 space-y-2">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// ===== 区间字段(最小 ~ 最大) =====
function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, step }: {
label: string
minVal: any
maxVal: any
onMinChange: (v: any) => void
onMaxChange: (v: any) => void
unit?: string
step?: string
}) {
return (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{label}</span>
<input
type="number"
value={minVal ?? ''}
onChange={e => onMinChange(e.target.value === '' ? null : Number(e.target.value))}
placeholder="最小"
step={step}
className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
<span className="text-[10px] text-muted">~</span>
<input
type="number"
value={maxVal ?? ''}
onChange={e => onMaxChange(e.target.value === '' ? null : Number(e.target.value))}
placeholder="最大"
step={step}
className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
{unit && <span className="text-[10px] text-muted shrink-0">{unit}</span>}
</div>
)
}
// 板块标签
const ALL_BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所']
// 策略参数字段
function ParamField({ def, value, onChange }: {
def: StrategyParamDef
value: any
onChange: (v: any) => void
}) {
if (def.type === 'bool') {
const checked = value === true || value === 'true' || value === 'True'
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-200 cursor-pointer ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
aria-pressed={checked}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
)
}
if (def.type === 'select' && def.options) {
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
<select
value={value ?? def.default}
onChange={e => onChange(e.target.value)}
className="w-24 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground focus:outline-none focus:border-accent/50"
>
{def.options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
)
}
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
<input
type="number"
value={value ?? def.default}
onChange={e => onChange(e.target.value === '' ? def.default : Number(e.target.value))}
step={def.step ?? 0.1}
min={def.min}
max={def.max}
className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
{def.min != null && def.max != null && (
<span className="text-[10px] text-muted">{def.min}~{def.max}</span>
)}
</div>
)
}
// 评分权重字段
function ScoringField({ col, weight, pct, editing, onChange }: {
col: string; weight: number; pct: number; editing: boolean; onChange: (v: number) => void
}) {
return (
<div className="flex items-center gap-2">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{FIELD_LABEL[col] ?? col}</span>
{editing ? (
<input
type="range"
value={weight}
onChange={e => onChange(Number(e.target.value))}
min={0} max={100} step={1}
className="flex-1 h-1 accent-amber-400 cursor-pointer"
/>
) : (
<div className="flex-1 h-1.5 bg-elevated rounded-full overflow-hidden">
<div className="h-full bg-amber-400/70 rounded-full transition-all duration-300" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
)}
<span className="w-10 text-right text-[10px] font-mono text-muted">{editing ? weight : `${pct}%`}</span>
</div>
)
}
export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModify, onDeleted }: Props) {
const [detail, setDetail] = useState<StrategyDetail | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [resetting, setResetting] = useState(false)
// 编辑状态
const [strategyName, setStrategyName] = useState('')
const [strategyDesc, setStrategyDesc] = useState('')
const [basicFilter, setBasicFilter] = useState<Record<string, any>>({})
const [params, setParams] = useState<Record<string, any>>({})
const [scoring, setScoring] = useState<Record<string, number>>({})
const [stopLoss, setStopLoss] = useState<number | null>(null)
const [maxHoldDays, setMaxHoldDays] = useState<number | null>(null)
const [entrySignals, setEntrySignals] = useState<string[]>([])
const [exitSignals, setExitSignals] = useState<string[]>([])
const [displayLimit, setDisplayLimit] = useState<number | null>(null)
const [basicFilterEnabled, setBasicFilterEnabled] = useState(true)
const [editingScoring, setEditingScoring] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// 辅助:更新 basicFilter 某个 key
const setBF = useCallback((key: string, value: any) => {
setBasicFilter(prev => ({ ...prev, [key]: value }))
}, [])
// 加载策略详情
useEffect(() => {
if (!strategyId) return
setLoading(true)
api.strategyGet(strategyId)
.then(d => {
setDetail(d)
setStrategyName(d.name ?? '')
setStrategyDesc(d.description ?? '')
// 确保 boards 有默认值
const bf = { ...d.basic_filter }
if (!bf.boards) bf.boards = ALL_BOARDS
setBasicFilter(bf)
setParams(d.params_defaults)
setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)])))
setStopLoss(d.stop_loss)
setMaxHoldDays(d.max_hold_days)
setEntrySignals(d.entry_signals ?? [])
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
})
.catch(() => setDetail(null))
.finally(() => setLoading(false))
}, [strategyId])
// 保存
const handleSave = async () => {
if (!strategyId) return
setSaving(true)
try {
await api.strategySaveConfig(strategyId, {
name: strategyName,
description: strategyDesc,
basic_filter: { ...basicFilter, enabled: basicFilterEnabled },
params,
scoring: Object.fromEntries(Object.entries(scoring).map(([k, v]) => [k, +(v / 100).toFixed(4)])),
stop_loss: stopLoss,
max_hold_days: maxHoldDays,
entry_signals: entrySignals,
exit_signals: exitSignals,
display_limit: displayLimit,
})
onSaved?.(displayLimit)
onClose()
} finally {
setSaving(false)
}
}
// 重置
const handleReset = async () => {
if (!strategyId) return
setResetting(true)
try {
await api.strategyResetConfig(strategyId)
// 重新加载默认值
const d = await api.strategyGet(strategyId)
setDetail(d)
setStrategyName(d.name ?? '')
setStrategyDesc(d.description ?? '')
const bf = { ...d.basic_filter }
if (!bf.boards) bf.boards = ALL_BOARDS
setBasicFilter(bf)
setParams(d.params_defaults)
setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)])))
setStopLoss(d.stop_loss)
setMaxHoldDays(d.max_hold_days)
setEntrySignals(d.entry_signals ?? [])
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
} finally {
setResetting(false)
}
}
const handleDelete = async () => {
if (!strategyId) return
setDeleting(true)
try {
await api.strategyDelete(strategyId)
onDeleted?.()
onClose()
} catch { /* ignore */ }
finally { setDeleting(false); setShowDeleteConfirm(false) }
}
if (!strategyId) return null
return (
<>
<AnimatePresence>
<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"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="w-[980px] max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* 标题 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border/50">
<div className="flex items-center gap-2.5">
<Settings2 className="h-4 w-4 text-accent" />
<span className="text-sm font-semibold text-foreground">{detail?.name ?? strategyId}</span>
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI' }[detail.source] ?? detail.source}</span>}
<span className="text-[10px] text-muted/40 font-mono">{strategyId}</span>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-elevated transition-colors cursor-pointer"><X className="h-4 w-4 text-muted" /></button>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
{loading ? (
<div className="flex items-center justify-center py-16"><div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" /></div>
) : detail ? (
<>
{/* 名称 + 描述 + 显示上限 */}
<div className="flex items-end gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted/50 uppercase tracking-wider w-8 shrink-0"></span>
<input type="text" value={strategyName} onChange={e => setStrategyName(e.target.value)}
className="flex-1 h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm font-medium text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted/50 uppercase tracking-wider w-8 shrink-0"></span>
<input type="text" value={strategyDesc} onChange={e => setStrategyDesc(e.target.value)}
className="flex-1 h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
</div>
</div>
<div className="flex items-center gap-1.5 pb-0.5 shrink-0">
<span className="text-[10px] text-muted/50"></span>
<input type="number" value={displayLimit ?? ''} onChange={e => setDisplayLimit(e.target.value ? Number(e.target.value) : null)} step={1} min={10} max={200} placeholder="不限"
className="w-14 h-8 px-1.5 rounded-lg bg-base border border-border/40 text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<span className="text-[10px] text-muted/50"></span>
</div>
</div>
{/* 三列 */}
<div className="grid grid-cols-3 gap-5 items-start">
{/* 列1:选股条件 */}
<Section icon={Filter} title="基础参数" accent="text-sky-400">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted"></span>
<button onClick={() => setBasicFilterEnabled(v => !v)}
className={`relative w-8 h-[18px] rounded-full transition-colors ${basicFilterEnabled ? 'bg-sky-500' : 'bg-border'}`}>
<span className={`absolute top-0.5 w-3.5 h-3.5 rounded-full bg-white transition-transform ${basicFilterEnabled ? 'left-[16px]' : 'left-0.5'}`} />
</button>
</div>
<div className={`space-y-2 transition-opacity duration-200 ${basicFilterEnabled ? '' : 'opacity-25 pointer-events-none'}`}>
<RangeField label="价格" minVal={basicFilter.price_min} maxVal={basicFilter.price_max} onMinChange={v => setBF('price_min', v)} onMaxChange={v => setBF('price_max', v)} unit="元" step="1" />
<RangeField label="流通市值" minVal={basicFilter.float_cap_min != null ? basicFilter.float_cap_min / 1e8 : null} maxVal={basicFilter.float_cap_max != null ? basicFilter.float_cap_max / 1e8 : null} onMinChange={v => setBF('float_cap_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('float_cap_max', v != null ? v * 1e8 : null)} unit="亿" step="5" />
<RangeField label="成交额" minVal={basicFilter.amount_min != null ? basicFilter.amount_min / 1e8 : null} maxVal={basicFilter.amount_max != null ? basicFilter.amount_max / 1e8 : null} onMinChange={v => setBF('amount_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('amount_max', v != null ? v * 1e8 : null)} unit="亿" step="0.5" />
<RangeField label="换手率" minVal={basicFilter.turnover_min} maxVal={basicFilter.turnover_max} onMinChange={v => setBF('turnover_min', v)} onMaxChange={v => setBF('turnover_max', v)} unit="%" step="0.5" />
<div className="space-y-1.5">
<div className="flex items-start gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right pt-0.5"></span>
<div className="flex flex-wrap gap-0.5">
{ALL_BOARDS.map(b => {
const boards: string[] = basicFilter.boards ?? ALL_BOARDS
const active = boards.includes(b)
return (
<button key={b} onClick={() => { const cur: string[] = basicFilter.boards ?? ALL_BOARDS; const next = active ? cur.filter(x => x !== b) : [...cur, b]; setBF('boards', next.length === 0 ? ALL_BOARDS : next) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${active ? `${color.select.border} ${color.select.bgLight} ${color.select.text}` : `border-border bg-base text-muted ${color.select.borderHover}`}`}>{b}</button>
)
})}
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">ST</span>
<button onClick={() => setBF('exclude_st', !basicFilter.exclude_st)}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${basicFilter.exclude_st ? 'border-danger/40 bg-danger/10 text-danger' : 'border-border bg-base text-muted hover:border-danger/30'}`}>{basicFilter.exclude_st ? '排除' : '包含'}</button>
</div>
</div>
</div>
</Section>
{/* 列2:策略参数 */}
<div className="space-y-3">
{detail.params.length > 0 ? (
<Section icon={Settings2} title="策略参数" accent="text-muted">
<div className="space-y-1.5">
{detail.params.map(p => <ParamField key={p.id} def={p} value={params[p.id]} onChange={v => setParams({ ...params, [p.id]: v })} />)}
</div>
</Section>
) : (
<div className="rounded-xl border border-border/15 bg-surface/20 px-3.5 py-4 text-[11px] text-muted/50 text-center"></div>
)}
</div>
{/* 列3:评分 + 交易 */}
<div className="space-y-3">
<Section icon={Star} title="评分权重" accent="text-amber-400">
{Object.entries(scoring).length > 0 ? (() => {
const total = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
return (
<div className="space-y-2">
{Object.entries(scoring).map(([col, w]) => {
const pct = Math.round((w / total) * 100)
return (
<ScoringField key={col} col={col} weight={w} pct={pct}
editing={editingScoring}
onChange={v => setScoring({ ...scoring, [col]: Math.max(0, v) })} />
)
})}
<div className="flex items-center justify-between pt-1.5 border-t border-border/10">
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<span></span>
<span className={`font-mono font-medium text-xs ${editingScoring ? (total === 100 ? color.ok : color.scoreWarn) : color.ok}`}>{editingScoring ? total : '100'}</span>
<span className="text-muted/40"></span>
</div>
<button
onClick={() => {
if (editingScoring) {
// 确认:归一化到 100
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
const norm = Object.fromEntries(
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
)
// 修正舍入误差
const newSum = Object.values(norm).reduce((a: number, b: number) => a + b, 0)
if (newSum !== 100) {
const keys = Object.keys(norm)
norm[keys[0]] += (100 - newSum)
}
setScoring(norm)
} else {
// 进入编辑:展开为 0-100 范围
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
setScoring(Object.fromEntries(
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
))
}
setEditingScoring(v => !v)
}}
className="text-[10px] text-accent/80 hover:text-accent cursor-pointer"
>{editingScoring ? '确定' : '设置'}</button>
</div>
</div>
)
})() : <div className="text-[11px] text-muted"></div>}
</Section>
<Section icon={TrendingUp} title="交易参数" accent="text-emerald-400">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-12 shrink-0"></span>
<input type="number" value={stopLoss ?? ''} onChange={e => setStopLoss(e.target.value === '' ? null : Number(e.target.value))} step={0.01} min={-0.5} max={0}
className="w-16 h-6 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<span className="text-[10px] text-muted">{stopLoss != null ? `${(stopLoss * 100).toFixed(1)}%` : '—'}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-12 shrink-0"></span>
<input type="number" value={maxHoldDays ?? ''} onChange={e => setMaxHoldDays(e.target.value === '' ? null : Number(e.target.value))} step={1} min={1}
className="w-16 h-6 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<span className="text-[10px] text-muted"></span>
</div>
<div className="text-[11px] text-muted pt-1 border-t border-border/10">
<span className="text-secondary"> </span><span className="text-foreground/70">{entrySignals.length > 0 ? `${entrySignals.length} 个触发器` : '无'}</span>
<span className="text-secondary ml-3"> </span><span className="text-foreground/70">{exitSignals.length > 0 ? `${exitSignals.length} 个触发器` : '无'}</span>
</div>
</div>
</Section>
<Section
icon={TrendingUp}
title="买入触发器"
accent="text-accent"
defaultOpen={false}
extra={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={setEntrySignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
>
<SignalPicker signals={entrySignals} onChange={setEntrySignals} kind="entry" variant="dialog" />
<div className="text-[10px] leading-4 text-muted/70"></div>
</Section>
<Section
icon={TrendingUp}
title="卖出触发器"
accent="text-warning"
defaultOpen={false}
extra={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={setExitSignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
>
<SignalPicker signals={exitSignals} onChange={setExitSignals} kind="exit" variant="dialog" />
<div className="text-[10px] leading-4 text-muted/70"></div>
</Section>
<div className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] px-3 py-2 text-[10px] leading-4 text-muted">
<b className="text-secondary"></b>;,
</div>
{detail.alerts.length > 0 && (
<Section icon={Settings2} title="提醒" accent="text-muted">
<div className="space-y-1">
{detail.alerts.map((a, i) => (
<div key={i} className="text-[10px] text-secondary">{a.message} <span className="text-muted font-mono">{a.op ? `${FIELD_LABEL[a.field] ?? a.field} ${a.op} ${a.value}` : FIELD_LABEL[a.field] ?? a.field}</span></div>
))}
</div>
</Section>
)}
</div>
</div>
</>
) : (
<div className="flex items-center justify-center py-16 text-sm text-muted"></div>
)}
</div>
{/* 底部按钮 */}
<div className="flex items-center justify-between px-5 py-3 border-t border-border/50 bg-surface/50">
<div className="flex items-center gap-2">
<button onClick={handleReset} disabled={resetting}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-border bg-surface text-xs text-secondary hover:text-danger hover:border-danger/30 transition-colors cursor-pointer disabled:opacity-50">
<RotateCcw className="h-3.5 w-3.5" />{resetting ? '重置中…' : '重置默认'}
</button>
{(detail?.source === 'ai' || detail?.source === 'custom') && (
<button onClick={() => setShowDeleteConfirm(true)}
className="text-[10px] text-muted/40 hover:text-danger transition-colors"></button>
)}
</div>
<div className="flex items-center gap-2">
{(detail?.source === 'ai' || detail?.source === 'custom') && (
<button onClick={onAiModify}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-amber-400/30 bg-amber-400/8 text-amber-400 text-xs font-medium hover:bg-amber-400/15 transition-colors cursor-pointer">
<Sparkles className="h-3.5 w-3.5" />AI
</button>
)}
<button onClick={handleSave} disabled={saving}
className="inline-flex items-center gap-1.5 h-8 px-4 rounded-lg bg-accent text-white text-xs font-semibold hover:bg-accent/90 transition-colors cursor-pointer disabled:opacity-50">
<Save className="h-3.5 w-3.5" />{saving ? '保存中…' : '保存设置'}
</button>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
{/* 删除确认弹窗 */}
{showDeleteConfirm && (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={() => setShowDeleteConfirm(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
className="w-[380px] bg-surface border border-border/50 rounded-2xl shadow-2xl p-6"
onClick={e => e.stopPropagation()}
>
<div className="text-center space-y-3">
<div className="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center mx-auto">
<span className="text-danger text-lg">!</span>
</div>
<div>
<div className="text-sm font-semibold text-foreground"></div>
<div className="text-xs text-muted mt-1">{detail?.name ?? strategyId}</div>
</div>
<div className="text-[11px] text-danger/70 bg-danger/[0.04] rounded-lg px-3 py-2 border border-danger/10">
</div>
<div className="flex gap-2 pt-2">
<button onClick={() => setShowDeleteConfirm(false)}
className="flex-1 h-8 rounded-lg border border-border text-xs text-secondary hover:text-foreground"></button>
<button onClick={handleDelete} disabled={deleting}
className="flex-1 h-8 rounded-lg bg-danger text-white text-xs font-medium hover:bg-danger/90 disabled:opacity-50">
{deleting ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)}
</>
)
}
@@ -0,0 +1,87 @@
import { motion, AnimatePresence } from 'framer-motion'
import { X, Store, Hammer, Download, Zap } from 'lucide-react'
interface Props {
open: boolean
onClose: () => void
}
/**
* 获取策略(占位)
* 目标:不定时更新更多策略。功能正在建设中,当前仅占位。
*/
export function StrategyStoreDialog({ open, onClose }: Props) {
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/50"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
className="w-[560px] max-h-[78vh] bg-surface border border-border rounded-card shadow-xl flex flex-col"
>
{/* 标题 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Store className="h-4 w-4 text-accent" />
</span>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
</button>
</div>
{/* 建设中占位内容 */}
<div className="flex-1 flex flex-col items-center justify-center px-6 py-14 text-center">
<div className="relative mb-5">
<div className="absolute inset-0 blur-2xl bg-amber-400/20 rounded-full" />
<div className="relative h-16 w-16 rounded-2xl bg-amber-400/10 border border-amber-400/30 flex items-center justify-center">
<Hammer className="h-8 w-8 text-amber-400" />
</div>
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5">
</h3>
<p className="text-sm text-muted leading-relaxed max-w-[380px]">
</p>
<div className="mt-6 flex items-center gap-4 text-[11px] text-muted">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5" />
</span>
<span className="w-px h-3 bg-border" />
<span className="flex items-center gap-1">
<Zap className="h-3.5 w-3.5" />
使
</span>
</div>
</div>
{/* 底部关闭 */}
<div className="flex justify-end px-4 py-2.5 border-t border-border shrink-0">
<button
onClick={onClose}
className="inline-flex items-center gap-1.5 h-7 px-4 rounded-btn
border border-border bg-surface text-xs font-medium text-secondary
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer"
>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowRight, Plus, Save, X } from 'lucide-react'
import { api, type CustomSignal, type CustomSignalCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
interface Props {
open: boolean
signal?: CustomSignal | null
defaultKind?: CustomSignal['kind']
onClose: () => void
onSaved?: (signal: CustomSignal) => void
}
const emptySignal = (kind: CustomSignal['kind'] = 'exit'): CustomSignal => ({
id: '', name: '', kind, enabled: true,
conditions: [{ left: 'close', op: '>', right: 'field:ma20' }],
})
export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, enabled: open })
const [draft, setDraft] = useState<CustomSignal>(() => emptySignal(defaultKind))
const [error, setError] = useState('')
const fields = options.data?.fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const editing = !!signal
useEffect(() => {
if (!open) return
setDraft(signal ? { ...signal, conditions: signal.conditions.map(c => ({ ...c })) } : emptySignal(defaultKind))
setError('')
}, [open, signal, defaultKind])
const save = useMutation({
mutationFn: () => {
const d = draft
if (!d.id.trim()) throw new Error('请输入信号标识')
if (!/^[a-z0-9_]{1,40}$/.test(d.id)) throw new Error('标识仅允许小写字母、数字、下划线(1-40字符)')
if (!d.name.trim()) throw new Error('请输入信号名称')
if (d.conditions.length === 0) throw new Error('至少需要一个条件')
for (const c of d.conditions) {
if (!c.left || !c.op || c.right === '') throw new Error('条件填写不完整')
}
return api.customSignalSave(d)
},
onSuccess: res => {
qc.invalidateQueries({ queryKey: QK.customSignals })
onSaved?.(res.signal)
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
const updateCond = (idx: number, patch: Partial<CustomSignalCondition>) => {
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
}
const addCond = () => setDraft(d => ({ ...d, conditions: [...d.conditions, { left: 'close', op: '>', right: '0' }] }))
const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-5 py-4">
<div>
<h3 className="text-sm font-semibold text-foreground">{editing ? '编辑自定义信号' : '新建自定义信号'}</h3>
<p className="mt-1 text-[11px] text-muted"> csg_* </p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-muted transition-colors hover:bg-elevated hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
<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={draft.id}
disabled={editing}
onChange={e => setDraft(d => ({ ...d, id: e.target.value.replace(/[^a-z0-9_]/g, '') }))}
placeholder="如 low_touches_ma5"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground disabled:opacity-60"
/>
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如 跌至MA5" 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={draft.kind} onChange={e => setDraft(d => ({ ...d, kind: e.target.value as CustomSignal['kind'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="entry"></option>
<option value="exit"></option>
<option value="both"></option>
</select>
</label>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
<button onClick={addCond} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
<div className="space-y-2 rounded-card border border-border/70 bg-base/50 p-3">
{draft.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 ? '当' : '且'}</span>
<select value={c.left} onChange={e => updateCond(i, { left: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(i, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<RightValueInput cond={c} fields={fields} onChange={v => updateCond(i, { right: v })} />
{draft.conditions.length > 1 && (
<button onClick={() => removeCond(i)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</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>
<div className="flex justify-end gap-2 border-t border-border/50 px-5 py-4">
<button onClick={onClose} 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-amber-500/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function RightValueInput({ cond, fields, onChange }: { cond: CustomSignalCondition; fields: { key: string; label: string }[]; onChange: (v: string) => void }) {
const isField = cond.right.startsWith('field:')
const fieldValue = isField ? cond.right.slice(6) : ''
const numValue = isField ? '' : cond.right
return (
<div className="flex items-center gap-1">
{isField ? (
<>
<select value={fieldValue} onChange={e => onChange(`field:${e.target.value}`)} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<button onClick={() => onChange('0')} title="切换为数字" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 rotate-90" />
</button>
</>
) : (
<>
<input type="number" value={numValue} onChange={e => onChange(e.target.value)} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<button onClick={() => onChange('field:close')} title="切换为字段" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 -rotate-90" />
</button>
</>
)}
</div>
)
}
@@ -0,0 +1,56 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Settings2 } from 'lucide-react'
import type { CustomSignal } from '@/lib/api'
import { CustomSignalDialog } from './CustomSignalDialog'
interface Props {
kind: 'entry' | 'exit'
signals: string[]
onChange: (next: string[]) => void
buttonClassName?: string
iconClassName?: string
}
export function SignalTriggerActions({ kind, signals, onChange, buttonClassName, iconClassName }: Props) {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const accent = kind === 'entry' ? 'hover:text-accent hover:border-accent/40' : 'hover:text-warning hover:border-warning/40'
const btnCls = buttonClassName ?? 'rounded-btn border border-border bg-base p-1 text-muted transition-colors cursor-pointer'
const iconCls = iconClassName ?? 'h-3.5 w-3.5'
const handleSaved = (signal: CustomSignal) => {
if (signal.kind !== kind && signal.kind !== 'both') return
const signalId = `csg_${signal.id}`
onChange(signals.includes(signalId) ? signals : [...signals, signalId])
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title="新增自定义信号"
className={`${btnCls} ${accent}`}
>
<Plus className={iconCls} />
</button>
<button
type="button"
onClick={() => navigate('/settings?tab=signals')}
title="去信号库"
className={`${btnCls} hover:border-amber-400/40 hover:text-amber-400`}
>
<Settings2 className={iconCls} />
</button>
<CustomSignalDialog
open={open}
defaultKind={kind}
onClose={() => setOpen(false)}
onSaved={handleSaved}
/>
</>
)
}
@@ -0,0 +1,547 @@
import { useEffect, useRef, useMemo, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { KlineRow, LevelSeries } from '@/lib/api'
/**
* 个股分析专用日 K 图表。
*
* 与 StockDailyKChart/EChartsCandlestick 刻意不复用:
* - 那套图表面向「行情浏览」,强调全套指标副图(MA/MACD/KDJ/BOLL)、涨停标记等;
* - 本图表面向「分析决策」,核心是【关键价位】(压力/支撑/密集区/枢轴/前高前低),
* 通过开关按钮控制各价位组的显隐,布局更简洁(主图 + 成交量即可)。
*
* 预留接口(类型已定义,渲染逻辑留 hook,后续实现):
* - markers: 日期标记点(新闻/暴雷/利好 → markPoint)
* - ranges: 区间高亮(事件区间 → markArea)
* - onDateClick: 点击日期回调(后续接消息面时间轴)
* - 指标副图: 后续如需 MACD/KDJ,按 SUB_CHARTS 模式扩展
*/
// ===== 配色(与主图一致的红涨绿跌,深色背景) =====
const THEME = {
bull: '#C74040',
bear: '#2D9B65',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
volUp: 'rgba(240,68,56,0.5)',
volDown: 'rgba(18,183,106,0.5)',
}
// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
export interface PriceLevel {
value: number
label: string
type: LevelType
side: 'resistance' | 'support' | 'neutral'
strength?: 'strong' | 'medium' | 'weak'
/** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3 */
rank?: number
}
/** 价位组开关配置:label = 按钮文案,color = markLine 颜色 */
export const LEVEL_GROUPS: { key: LevelType; label: string; color: string }[] = [
{ key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙(成交密集区,价量驱动)
{ key: 'pivot', label: '枢轴点', color: '#8B5CF6' }, // 紫
{ key: 'extreme', label: '前高前低', color: '#EAB308' }, // 黄
{ key: 'boll', label: '布林带', color: '#F97316' }, // 橙(MA20±2σ 曲线)
{ key: 'keltner_s',label: 'Keltner短期', color: '#06B6D4' }, // 青(MA20±2ATR 曲线)
{ key: 'keltner_m',label: 'Keltner中期', color: '#22D3EE' }, // 浅青(MA60±2.5ATR 曲线)
{ key: 'keltner_l',label: 'Keltner长期', color: '#67E8F9' }, // 更浅青(MA120±3ATR 曲线)
{ key: 'atr_stop', label: 'ATR止损', color: '#EF4444' }, // 红(警示)
{ key: 'gap', label: '缺口位', color: '#EC4899' }, // 粉
{ key: 'fib', label: '斐波那契', color: '#F59E0B' }, // 金
{ key: 'round', label: '整数关口', color: '#71717A' }, // 灰(心理位,弱视觉)
]
// 通道曲线元数据(单一数据源):供 buildOption 画线 + 右侧面板取最新值共用。
// alignedKey: alignedSeries 中的 key(由 series.boll/keltner/atr 对齐而来)
// group: 属于哪个价位开关组(开关该组即开关这条曲线)
// endLabel: 右侧端点标签(显示最新值的文字)
const CURVE_DEFS: { alignedKey: string; group: LevelType; endLabel: string; color: string; dashed?: boolean }[] = [
{ alignedKey: 'boll_upper', group: 'boll', endLabel: '布林上轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_lower', group: 'boll', endLabel: '布林下轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_mid', group: 'boll', endLabel: '布林中轨', color: '#FB923C', dashed: false },
{ alignedKey: 'keltner_s_upper',group: 'keltner_s', endLabel: 'Keltner短上', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_s_lower',group: 'keltner_s', endLabel: 'Keltner短下', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_m_upper',group: 'keltner_m', endLabel: 'Keltner中上', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_m_lower',group: 'keltner_m', endLabel: 'Keltner中下', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_l_upper',group: 'keltner_l', endLabel: 'Keltner长上', color: '#67E8F9', dashed: true },
{ alignedKey: 'keltner_l_lower',group: 'keltner_l', endLabel: 'Keltner长下', color: '#67E8F9', dashed: true },
{ alignedKey: 'atr_stop', group: 'atr_stop', endLabel: 'ATR止损', color: '#EF4444', dashed: true },
{ alignedKey: 'atr_tp', group: 'atr_stop', endLabel: 'ATR止盈', color: '#F87171', dashed: true },
]
// ===== 预留:标记 / 区间(后续新闻面、事件区间用) =====
export interface ChartMarker {
date: string
label?: string
color?: string
above?: boolean
}
export interface ChartRange {
start: string
end: string
label?: string
color?: string
}
interface Props {
rows: KlineRow[]
levels?: Record<LevelType, PriceLevel[]>
/** 带状曲线指标(布林带/Keltner/ATR)的每日序列 —— 画成跟随时间漂移的曲线 */
series?: LevelSeries
/** series 数据对应的日期数组(与 series 各数组对齐) */
seriesDates?: string[]
/** 默认开启的价位组 */
defaultLevelTypes?: LevelType[]
/** 预留:新闻/暴雷/利好日期标记 */
markers?: ChartMarker[]
/** 预留:事件区间高亮 */
ranges?: ChartRange[]
/** 预留:点击某根 K 线 */
onDateClick?: (date: string) => void
height?: number
className?: string
}
const VOL_PANE_H = 90
export function AnalysisKChart({
rows,
levels,
series,
seriesDates,
defaultLevelTypes = ['sr', 'pivot', 'keltner_s'],
markers,
ranges,
onDateClick,
height = 460,
className,
}: Props) {
const chartRef = useRef<HTMLDivElement>(null)
const chartInstRef = useRef<ECharts | null>(null)
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(new Set(defaultLevelTypes))
/** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
// 数据预处理 + 带状曲线序列对齐(后端 series 的日期范围可能与 rows 不同,需映射)
const { dates, candle, vols, dateIndex, zoomStart, alignedSeries } = useMemo(() => {
const dates = rows.map(r => (typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)))
const candle = rows.map(r => [r.open, r.close, r.low, r.high])
const vols = rows.map(r => ({
value: r.volume ?? 0,
itemStyle: { color: r.close >= r.open ? THEME.volUp : THEME.volDown },
}))
const dateIndex = new Map(dates.map((d, i) => [d, i]))
// 默认显示最近 6 个月 ≈ 120 个交易日;数据不足则全部显示
const showBars = 120
const zoomStart = dates.length > showBars ? Math.round((1 - showBars / dates.length) * 100) : 0
// 把后端 series(按 seriesDates 对齐)映射到前端 rows 的 dates 顺序
const alignedSeries: Record<string, (number | null)[]> = {}
if (series && seriesDates && seriesDates.length > 0) {
// 构建 seriesDates 索引
const sIdx = new Map(seriesDates.map((d, i) => [d, i]))
// 通用对齐:给定 series 里某条数组,返回与 rows dates 对齐的版本
const align = (arr: (number | null)[] | undefined): (number | null)[] => {
if (!arr) return dates.map(() => null)
return dates.map(d => {
const i = sIdx.get(d)
return i != null ? arr[i] : null
})
}
if (series.boll) {
alignedSeries['boll_upper'] = align(series.boll.upper)
alignedSeries['boll_lower'] = align(series.boll.lower)
if (series.boll.mid) alignedSeries['boll_mid'] = align(series.boll.mid)
}
if (series.keltner_s) {
alignedSeries['keltner_s_upper'] = align(series.keltner_s.upper)
alignedSeries['keltner_s_lower'] = align(series.keltner_s.lower)
}
if (series.keltner_m) {
alignedSeries['keltner_m_upper'] = align(series.keltner_m.upper)
alignedSeries['keltner_m_lower'] = align(series.keltner_m.lower)
}
if (series.keltner_l) {
alignedSeries['keltner_l_upper'] = align(series.keltner_l.upper)
alignedSeries['keltner_l_lower'] = align(series.keltner_l.lower)
}
if (series.atr) {
alignedSeries['atr_stop'] = align(series.atr.stop_loss)
alignedSeries['atr_tp'] = align(series.atr.take_profit)
}
}
return { dates, candle, vols, dateIndex, zoomStart, alignedSeries }
}, [rows, series, seriesDates])
// 构建 option
const buildOption = (): EChartsOption => {
const priceLines = collectPriceLines(levels, activeTypes, pivotRank)
// 三段布局:主图 / 成交量 / 缩放条,从上到下累加,各段之间留间距,互不遮挡
// [16 顶部] [mainH 主图] [8 间距] [volH 成交量] [12 间距] [SLIDER_H 缩放条] [8 底部]
const SLIDER_H = 22
const PAD_TOP = 16
const GAP_MAIN_VOL = 8 // 主图 ↔ 成交量
const GAP_VOL_SLIDER = 12 // 成交量 ↔ 缩放条(留足,避免遮挡)
const PAD_BOTTOM = 8
const volH = VOL_PANE_H
const mainH = height - PAD_TOP - GAP_MAIN_VOL - volH - GAP_VOL_SLIDER - SLIDER_H - PAD_BOTTOM
const volTop = PAD_TOP + mainH + GAP_MAIN_VOL
const sliderBottom = PAD_BOTTOM
// 预留:markPoint(新闻标记)
const markPointData: any[] = (markers ?? [])
.filter(m => dateIndex.has(m.date))
.map(m => ({
coord: [m.date, rows[dateIndex.get(m.date)!].high],
symbol: 'pin', symbolSize: 32,
itemStyle: { color: m.color ?? '#EAB308' },
label: { show: !!m.label, formatter: m.label ?? '', fontSize: 9, color: '#fff' },
}))
// 预留:markArea(事件区间)
const markAreaData: any[] = (ranges ?? [])
.filter(r => dateIndex.has(r.start) && dateIndex.has(r.end))
.map(r => [{
xAxis: r.start, name: r.label ?? '',
itemStyle: { color: r.color ?? 'rgba(234,179,8,0.08)' },
label: r.label ? { show: true, position: 'insideTop', distance: 6, color: '#EAB308', fontSize: 10 } : undefined,
}, { xAxis: r.end }])
const series: any[] = [
{
name: 'K', type: 'candlestick', data: candle, animation: false,
itemStyle: {
color: THEME.bull, color0: THEME.bear,
borderColor: THEME.bull, borderColor0: THEME.bear,
},
markPoint: markPointData.length ? { data: markPointData, animation: false } : undefined,
markArea: markAreaData.length ? { silent: true, data: markAreaData } : undefined,
},
{
name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1,
data: vols, animation: false,
},
]
// 价位水平线 —— 用 line series(恒定值)画水平线,endLabel 显示标签文字;
// 与通道曲线一致,标签落在右侧 grid.right 预留带(外侧),不压蜡烛。
for (const p of priceLines) {
series.push({
name: p.label, type: 'line', silent: true, animation: false,
symbol: 'none',
data: dates.map(() => p.value),
lineStyle: { width: 1, color: p.color, type: 'dashed', opacity: 0.7 },
itemStyle: { color: p.color },
endLabel: {
show: true,
formatter: () => `${p.label} ${p.value.toFixed(2)}`,
color: p.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
},
})
}
// 带状曲线指标(布林带 / Keltner通道 / ATR止损) —— 跟随行情漂移的曲线
// 单一数据源 CURVE_DEFS 驱动:每条曲线带 endLabel(右侧端点标签),显示最新数值
for (const def of CURVE_DEFS) {
if (!activeTypes.has(def.group)) continue
const data = alignedSeries[def.alignedKey]
if (!data || !data.some(v => v != null)) continue
// 取最后一个有效值作为右侧端点显示文字
let lastVal: number | null = null
for (let i = data.length - 1; i >= 0; i--) {
if (data[i] != null) { lastVal = data[i]; break }
}
series.push({
name: def.endLabel, type: 'line', data: data.map(v => v ?? '-'),
smooth: true, symbol: 'none', silent: true, animation: false,
lineStyle: { width: 1, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity: 0.8 },
itemStyle: { color: def.color },
// 右侧端点标签:显示该通道的最新数值,距绘图区右缘留 6px 间距
endLabel: lastVal != null ? {
show: true,
formatter: () => `${lastVal!.toFixed(2)}`,
color: def.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
} : undefined,
})
}
return {
animation: false,
backgroundColor: 'transparent',
// grid.right 留出足够宽度给价位标签文字区:蜡烛只占左侧主区域,
// 价位线右端的标签文字显示在这条预留带里,不压在蜡烛上。
// 预留 ~144px:最长标签(如「成交密集区(POC) 12.34」)约 13 字符,fontSize 9 等宽。
grid: [
{ left: 56, right: 144, top: 16, height: mainH },
{ left: 56, right: 144, top: volTop, height: volH },
],
xAxis: [
{
type: 'category', data: dates, boundaryGap: true,
axisLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10 },
splitLine: { show: false },
axisPointer: { show: true, label: { show: false } },
},
{
type: 'category', gridIndex: 1, data: dates, boundaryGap: true,
axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false },
},
],
yAxis: [
{ scale: true, splitLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
{ scale: true, gridIndex: 1, splitNumber: 2,
// 成交量区不画背景横线
splitLine: { show: false },
axisLabel: { color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => fmtVol(v) } },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: zoomStart, end: 100 },
{ type: 'slider', xAxisIndex: [0, 1], bottom: sliderBottom, height: SLIDER_H, start: zoomStart, end: 100,
borderColor: 'transparent', fillerColor: 'rgba(255,255,255,0.06)',
handleStyle: { color: '#52525B' }, textStyle: { color: THEME.text, fontSize: 10 } },
],
// 不弹 hover tooltip(用户要求);但保留十字线 axisPointer 作为缩放/定位参照
tooltip: { show: false },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
series,
}
}
// 初始化 + 数据更新
useEffect(() => {
if (!chartRef.current) return
if (!chartInstRef.current) {
chartInstRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
chartInstRef.current.on('click', (params: any) => {
// 预留:点击 K 线(非 markPoint/markLine)回调
if (params.componentType === 'series' && params.seriesType === 'candlestick' && onDateClick) {
onDateClick(dates[params.dataIndex])
}
})
}
chartInstRef.current.setOption(buildOption(), true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height])
// resize
useEffect(() => {
const inst = chartInstRef.current
if (!inst) return
const onResize = () => inst.resize()
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize); inst.dispose(); chartInstRef.current = null }
}, [])
const toggleType = (t: LevelType) => {
setActiveTypes(prev => {
const next = new Set(prev)
if (next.has(t)) next.delete(t)
else next.add(t)
return next
})
}
return (
<div className={className}>
{/* 价位开关按钮组 */}
{levels && (
<div className="flex flex-wrap items-center gap-1.5 mb-2">
<span className="text-[10px] text-muted mr-1"></span>
{LEVEL_GROUPS.map(g => {
const active = activeTypes.has(g.key)
// 枢轴点数量按当前档位过滤显示;其他组显示原始数量
const raw = levels[g.key] ?? []
const count = g.key === 'pivot'
? raw.filter(p => p.rank === undefined || p.rank <= pivotRank).length
: raw.length
return (
<button
key={g.key}
onClick={() => toggleType(g.key)}
disabled={raw.length === 0}
title={`${g.label} (${count} 个)`}
className={`inline-flex items-center gap-1 h-6 px-2 rounded-md text-[10px] font-medium border transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
active
? 'text-foreground'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
style={active ? { borderColor: g.color + '66', backgroundColor: g.color + '1a' } : undefined}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: active ? g.color : '#52525B' }} />
{g.label}
<span className="opacity-50">{count}</span>
</button>
)
})}
{/* 枢轴点档位选择器 —— 仅当枢轴点开启时显示 */}
{activeTypes.has('pivot') && (levels.pivot?.length ?? 0) > 0 && (
<div className="inline-flex items-center gap-0.5 ml-1 pl-2 border-l border-border/40">
<span className="text-[10px] text-muted mr-1"></span>
{([1, 2, 3] as const).map(r => (
<button
key={r}
onClick={() => setPivotRank(r)}
title={r === 1 ? 'P + R1/S1(3 个)' : r === 2 ? '到 R2/S2(5 个)' : '全档 R3/S3(7 个)'}
className={`h-6 px-2 rounded-md text-[10px] font-mono border transition-all ${
pivotRank === r
? 'bg-[#8B5CF6]/15 border-[#8B5CF6]/40 text-[#c4b5fd]'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
>
{r}
</button>
))}
</div>
)}
</div>
)}
{/* 图表:右侧预留带(grid.right 预留)显示价位标签文字,不压蜡烛 */}
<div ref={chartRef} style={{ width: '100%', height }} />
{/* 价位统计面板:把当前开启的点位按"压力 / 支撑"结构化列出 */}
{levels && (
<LevelOverview
levels={levels}
activeTypes={activeTypes}
pivotRank={pivotRank}
close={rows.length ? rows[rows.length - 1].close : undefined}
/>
)}
</div>
)
}
// ===== 价位统计面板(图表下方,结构化文本展示) =====
function LevelOverview({
levels, activeTypes, pivotRank, close,
}: {
levels: Record<LevelType, PriceLevel[]>
activeTypes: Set<LevelType>
pivotRank: 1 | 2 | 3
close?: number
}) {
// 收集当前显示的点位(同 collectPriceLines 的过滤逻辑)
const visible: PriceLevel[] = []
for (const g of LEVEL_GROUPS) {
if (!activeTypes.has(g.key)) continue
for (const p of levels[g.key] ?? []) {
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
visible.push(p)
}
}
if (visible.length === 0) return null
// 按方向分两组:压力位(在当前价之上) / 支撑位(之下),各自按距当前价远近排序
const cur = close ?? visible[0].value
const resistances = visible
.filter(p => p.side === 'resistance')
.sort((a, b) => a.value - b.value) // 由近及远(低→高)
const supports = visible
.filter(p => p.side === 'support')
.sort((a, b) => b.value - a.value) // 由近及远(高→低)
const neutrals = visible.filter(p => p.side === 'neutral')
const fmtPct = (v: number) => {
if (!cur) return ''
const pct = ((v - cur) / cur) * 100
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(1)}%`
}
const Row = ({ p }: { p: PriceLevel }) => {
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? THEME.text
return (
<div className="flex items-center gap-2 py-0.5">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
</div>
)
}
return (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 rounded-lg border border-border/40 bg-base/20 px-3 py-2">
{/* 当前价 */}
<div className="sm:col-span-2 flex items-center gap-2 pb-1 border-b border-border/30 mb-0.5">
<span className="text-[10px] text-muted"></span>
<span className="text-xs font-mono font-medium text-foreground">{cur.toFixed(2)}</span>
</div>
{/* 压力位(从近到远,即从低到高)倒序展示:最高的在最上 */}
{resistances.length > 0 && (
<div>
<div className="text-[10px] font-medium text-bear mb-0.5"> </div>
{[...resistances].reverse().map((p, i) => <Row key={`r-${i}`} p={p} />)}
</div>
)}
{/* 支撑位 + 中性(枢轴位 P) */}
<div>
{supports.length > 0 && (
<>
<div className="text-[10px] font-medium text-bull mb-0.5"> </div>
{supports.map((p, i) => <Row key={`s-${i}`} p={p} />)}
</>
)}
{neutrals.length > 0 && (
<div className={supports.length > 0 ? 'mt-2' : ''}>
{supports.length === 0 && <div className="text-[10px] font-medium text-muted mb-0.5"></div>}
{neutrals.map((p, i) => <Row key={`n-${i}`} p={p} />)}
</div>
)}
</div>
</div>
)
}
// ===== 工具:收集要画的水平价位线(按开启的组 + 档位 + 强度配色) =====
// 注意:带状指标(布林带/Keltner/ATR)改用曲线渲染,不在此画水平线,避免重复。
function collectPriceLines(
levels: Record<LevelType, PriceLevel[]> | undefined,
active: Set<LevelType>,
pivotRank: 1 | 2 | 3,
): { value: number; label: string; color: string }[] {
if (!levels) return []
const out: { value: number; label: string; color: string }[] = []
for (const g of LEVEL_GROUPS) {
if (!active.has(g.key)) continue
for (const p of levels[g.key] ?? []) {
// 枢轴点:按档位过滤(rank>P 的,只显示到选定的档位)
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
// 波动通道类(boll / keltner三档 / atr_stop)整组走曲线渲染,不画水平线;
// sr 组现为成交密集区水平点,直接画线即可,无需特判。
if (p.type === 'boll' || p.type === 'keltner_s' || p.type === 'keltner_m'
|| p.type === 'keltner_l' || p.type === 'atr_stop') continue
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
}
}
return out
}
function strengthColor(strength: string | undefined, base: string): string {
// strong 用实色,medium 用 0.85,weak 用 0.55 透明
if (strength === 'weak') return base + '8C'
if (strength === 'medium') return base + 'D9'
return base
}
function fmtVol(v: number): string {
if (!v) return '0'
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
return v.toFixed(0)
}
@@ -0,0 +1,207 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Loader2, Check, AlertCircle } from 'lucide-react'
import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore'
import type { ActiveTask } from '@/lib/stockAnalysisStore'
/**
* AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。
* 挂在右侧,拖拽丝滑(逻辑同 AiReportBubble,独立状态池)。
*
* 与财务胶囊的差异:蓝色系 + "个股分析中"文案,避免与财务分析混淆。
*/
const BUBBLE_W = 148
const EDGE_MARGIN = 12
export function StockAnalysisBubble() {
const activeTasks = useBubbleTasks()
const containerRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
const draggingRef = useRef(false)
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 })
const movedRef = useRef(false)
const clickTargetRef = useRef<(() => void) | null>(null)
const applyTransform = useCallback((x: number, y: number) => {
const el = containerRef.current
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
}, [])
const clamp = useCallback((x: number, y: number) => {
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight - 80
return {
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
}
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
draggingRef.current = true
movedRef.current = false
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
const el = containerRef.current
if (el) el.classList.add('dragging')
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}, [pos.x, pos.y])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!draggingRef.current) return
const dx = e.clientX - dragData.current.mx
const dy = e.clientY - dragData.current.my
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy)
applyTransform(c.x, c.y)
}, [clamp, applyTransform])
const onPointerUp = useCallback(() => {
if (!draggingRef.current) return
draggingRef.current = false
const el = containerRef.current
if (el) el.classList.remove('dragging')
if (movedRef.current) {
setPos(prev => {
const transform = el?.style.transform ?? ''
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
savePos(finalPos)
return finalPos
})
} else {
const fn = clickTargetRef.current
clickTargetRef.current = null
fn?.()
}
}, [])
useEffect(() => {
const onResize = () => {
setPos(prev => {
const c = clamp(prev.x, prev.y)
if (c.x !== prev.x || c.y !== prev.y) {
applyTransform(c.x, c.y)
return c
}
return prev
})
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [clamp, applyTransform])
useEffect(() => { applyTransform(pos.x, pos.y) }, [pos.x, pos.y, applyTransform])
if (activeTasks.length === 0) return null
// 默认位置偏下,避免与财务胶囊(右下)重叠
return (
<div
ref={containerRef}
className="sa-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
style={{
width: `${BUBBLE_W}px`,
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
touchAction: 'none',
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<AnimatePresence mode="popLayout">
{activeTasks.map((task, i) => (
<BubbleItem
key={task.id}
task={task}
isLast={i === activeTasks.length - 1}
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
/>
))}
</AnimatePresence>
<style>{`.sa-bubble-root.dragging { transition: none !important; }`}</style>
</div>
)
}
function BubbleItem({ task, isLast, onPointerDown }: {
task: ActiveTask
isLast: boolean
onPointerDown: () => void
}) {
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
const isError = task.phase === 'error'
// 蓝色系(区别于财务分析的紫色)
const accent = isWorking
? 'from-sky-500/25 to-blue-500/20 text-sky-300 border-sky-300/40 shadow-[0_6px_24px_-10px_rgba(14,165,233,0.5)]'
: isError
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
return (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -8 }}
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
className={isLast ? '' : 'mb-1.5'}
>
<div
onPointerDown={onPointerDown}
role="button"
tabIndex={0}
title={isWorking ? '个股分析中,点击恢复' : isError ? '分析失败,点击重试' : '点击查看个股分析报告'}
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
>
{isWorking && (
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-sky-200 to-transparent animate-sa-bubble-progress" />
</div>
)}
<span className="flex h-4 w-4 items-center justify-center shrink-0">
{isWorking ? <Loader2 className="h-3 w-3 animate-spin" />
: isError ? <AlertCircle className="h-3 w-3" />
: <Check className="h-3 w-3" />}
</span>
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
{task.name || task.symbol}
</span>
<span className="shrink-0 text-[9px] leading-none">
{isWorking ? <span className="text-sky-300/80"></span>
: isError ? <span className="text-red-300/80"></span>
: <span className="text-emerald-300/80"></span>}
</span>
</div>
<style>{`
@keyframes sa-bubble-progress { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
.animate-sa-bubble-progress { animation: sa-bubble-progress 1.6s ease-in-out infinite; }
`}</style>
</motion.div>
)
}
const POS_KEY = 'sa_bubble_pos'
function loadPos(): { x: number; y: number } {
// 默认右下,偏上一点(避开财务胶囊的右下位置)
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320)
try {
const v = localStorage.getItem(POS_KEY)
if (v) {
const p = JSON.parse(v)
if (typeof p.x === 'number' && typeof p.y === 'number') {
return {
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
}
}
}
} catch { /* ignore */ }
return { x: defaultX, y: defaultY }
}
function savePos(p: { x: number; y: number }) {
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
}
@@ -0,0 +1,256 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
Settings2, Send, Wand2, Minimize2, History, LineChart,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
import {
type ActiveTask, type HistoryReport,
minimizeDialog, closeDialog, startAnalysis,
} from '@/lib/stockAnalysisStore'
/**
* AI 个股分析对话框 —— 蓝色主题,与财务分析对话框区分。
* 复用 MarkdownRenderer(通用 markdown 渲染);标题/配色/文案独立。
*/
interface Props {
task: ActiveTask | HistoryReport | null
mode: 'active' | 'history' | null
minimized: boolean
}
type Phase = 'loading' | 'streaming' | 'done' | 'error'
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
if (!task) return 'loading'
if ('phase' in task) return task.phase
return 'done'
}
function getContent(task: ActiveTask | HistoryReport | null): string {
return task?.content ?? ''
}
function getMeta(task: ActiveTask | HistoryReport | null) {
if (!task) return null
if ('meta' in task) return task.meta
return { summary: task.summary, close: task.close, levels: task.levels }
}
export function StockAnalysisDialog({ task, mode, minimized }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const [focus, setFocus] = useState('')
const [copied, setCopied] = useState(false)
const phase = getPhase(task)
const content = getContent(task)
const meta = getMeta(task)
const isHistory = mode === 'history'
const isWorking = phase === 'loading' || phase === 'streaming'
const open = !!task && !minimized
useEffect(() => {
if (open && phase === 'streaming' && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [content, phase, open])
useEffect(() => {
setFocus(task && 'focus' in task ? task.focus : '')
}, [task])
const handleStartNew = useCallback(async () => {
if (!task) return
const name = 'name' in task ? task.name : ''
await startAnalysis(task.symbol, name, focus.trim())
}, [task, focus])
const handleCopy = async () => {
if (!content) return
try {
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* ignore */ }
}
if (!open) return null
const error = task && 'error' in task ? task.error : ''
return (
<AnimatePresence>
<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 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* 头部 —— 蓝色主题 */}
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-sky-500/[0.06] via-blue-500/[0.04] to-transparent">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 shrink-0">
{isHistory
? <History className="h-4.5 w-4.5 text-sky-300" />
: <LineChart className="h-4.5 w-4.5 text-sky-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">
{isHistory ? '历史分析报告' : 'AI 个股分析'}
</span>
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
{meta?.summary ? (
<span className="flex items-center gap-1 truncate">
<Sparkles className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{meta.summary}</span>
</span>
) : isWorking ? <span></span> : null}
{phase === 'streaming' && (
<span className="flex items-center gap-1 text-sky-300 shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />
</span>
)}
{isHistory && task && 'created_at' in task && (
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{content && !isWorking && (
<button onClick={handleCopy} title="复制全文"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
)}
{!isHistory && isWorking && (
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<Minimize2 className="h-4 w-4" />
</button>
)}
{(!isWorking || isHistory) && (
<button onClick={closeDialog} title="关闭"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
{/* 内容区 */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
{phase === 'loading' && !content && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="relative">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 flex items-center justify-center">
<LineChart className="h-4.5 w-4.5 text-sky-300 animate-pulse" />
</div>
<Loader2 className="absolute -inset-1 h-12 w-12 text-sky-400/40 animate-spin" style={{ animationDuration: '3s' }} />
</div>
<div className="text-xs text-secondary">AI </div>
<div className="text-[10px] text-muted"> K / / / ,</div>
</div>
)}
{phase === 'error' && (
<div className="flex flex-col items-center justify-center py-14 gap-3">
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
)}
<button onClick={handleStartNew}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-sky-500/15 border border-sky-400/30 text-xs text-sky-300 hover:bg-sky-500/20 transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
{(content || phase === 'streaming') && (
<div className="relative">
<MarkdownRenderer content={content} />
{phase === 'streaming' && (
<span className="inline-block w-1.5 h-3.5 bg-sky-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
)}
</div>
{/* 底部:关注点输入 */}
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
<Wand2 className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</div>
<input
type="text"
value={focus}
onChange={e => setFocus(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
disabled={isWorking}
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看能否突破压力位…回车重新分析' : '可留空,留空则全面分析')}
className={cn(
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
'focus:outline-none focus:ring-2 focus:ring-sky-400/30 transition-shadow disabled:opacity-50',
)}
/>
{isHistory ? (
<button
onClick={handleStartNew}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 transition-all shrink-0"
title="以此关注点重新生成新报告"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleStartNew}
disabled={isWorking}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
>
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
{phase === 'done' ? '重新分析' : '分析'}
</button>
)}
</div>
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
{isHistory
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
: '报告由项目已配置的 AI 模型基于本地行情与财务数据生成;消息面维度暂依据价量异动推断。报告仅供参考,不构成投资建议。'}
</p>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
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 '' }
}
@@ -0,0 +1,12 @@
import { useDialogTask, useDialogState } from '@/lib/stockAnalysisStore'
import { StockAnalysisDialog } from './StockAnalysisDialog'
/**
* AI 个股分析对话框宿主 —— 单点挂载在 Layout。
* 与财务分析的 AiAnalysisHost 并列,独立 store,蓝色主题。
*/
export function StockAnalysisHost() {
const { task, mode } = useDialogTask()
const { minimized } = useDialogState()
return <StockAnalysisDialog task={task} mode={mode} minimized={minimized} />
}
@@ -0,0 +1,81 @@
/** 迷你蜡烛图(自选/策略列表共享)。 */
import type { KlineRow } from '@/lib/api'
export function MiniCandlestick({ rows, width = 100, height = 80 }: { rows: KlineRow[]; width?: number; height?: number }) {
// 空数据:返回等尺寸占位(不画内容),保证 kline 加载前后单元格尺寸一致、不闪烁
if (!rows || rows.length === 0) {
return <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="block" aria-label="加载中" />
}
const BULL = '#C74040'
const BEAR = '#2D9B65'
const NEUTRAL = '#A1A1AA'
const W = width
const H = height
const padY = 2
const n = rows.length
const barW = W / n
const bodyW = Math.max(barW * 0.55, 2)
let hi = -Infinity, lo = Infinity
for (const r of rows) {
if (r.high > hi) hi = r.high
if (r.low < lo) lo = r.low
}
const range = hi - lo || 1
const yScale = (v: number) => padY + (1 - (v - lo) / range) * (H - padY * 2)
const rects: React.ReactNode[] = []
const wicks: React.ReactNode[] = []
for (let i = 0; i < n; i++) {
const r = rows[i]
const x = i * barW + barW / 2
// 涨跌判断: open !== close 用实体方向, 一字板用前日收盘计算涨跌
let color: string
if (r.close > r.open) {
color = BULL
} else if (r.close < r.open) {
color = BEAR
} else {
// 一字板: open === close, 用前一日收盘价判断涨跌方向
const prevClose = i > 0 ? rows[i - 1].close : null
if (prevClose && prevClose > 0 && r.close !== prevClose) {
color = r.close > prevClose ? BULL : BEAR
} else {
color = NEUTRAL
}
}
wicks.push(
<line
key={`w${i}`}
x1={x} y1={yScale(r.high)} x2={x} y2={yScale(r.low)}
stroke={color} strokeWidth={1}
/>
)
const top = yScale(Math.max(r.open, r.close))
const bot = yScale(Math.min(r.open, r.close))
const bodyH = Math.max(bot - top, 1)
rects.push(
<rect
key={`c${i}`}
x={x - bodyW / 2} y={top}
width={bodyW} height={bodyH}
fill={color}
/>
)
}
return (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block">
{wicks}
{rects}
</svg>
)
}
@@ -0,0 +1,120 @@
/**
* 股票列表表格骨架(自选/策略页共享)。
*
* 职责:表头渲染(读列配置 label/align + 可排序三态指示器)、表体遍历、sticky 表头。
* 不内置任何业务逻辑:单元格内容(含 symbol 列交互、操作列、ext 列)由调用方通过
* renderCell / renderExtraCol 注入。这样两个页面的特有交互得以保留,同时表头能力一致。
*/
import type { ReactNode } from 'react'
import type { ColumnConfig } from '@/lib/list-columns'
import { UNSORTABLE_KEYS } from '@/lib/stock-table'
import type { SortState } from './useTableSort'
export type { SortState }
export interface StockDataTableProps {
columns: ColumnConfig[]
rows: any[]
/** 单元格渲染回调。返回 null 时回退到内置纯数据列渲染。 */
renderCell: (r: any, col: ColumnConfig) => ReactNode
/** 行 key(默认取 r.symbol */
rowKey?: (r: any) => string | number
/** 行 className(默认含 hover */
rowClassName?: (r: any) => string
/** 表头是否 sticky(自选页需要,策略页不需要) */
headerSticky?: boolean
/** 最小表格宽度,默认按列数计算 */
minWidth?: number
/** 排序:外部受控时传入(含当前 sort 与 toggle);不传则表头不可排序 */
sort?: SortState | null
onSortToggle?: (colId: string) => void
/** 追加在每行末尾的额外单元格(如自选页的操作列) */
renderExtraCol?: (r: any) => ReactNode
/** 追加的表头单元格(对应 renderExtraCol */
extraHeader?: ReactNode
/** 自定义表头单元格内容覆盖(如日k眼睛按钮)。返回 undefined 则用 col.label */
renderHeaderContent?: (col: ColumnConfig) => ReactNode | undefined
/** 外层容器 className */
className?: string
}
function alignThClass(align: ColumnConfig['align']): string {
if (align === 'right') return 'px-3 py-2.5 font-medium text-right'
if (align === 'center') return 'px-3 py-2.5 font-medium text-center'
return 'px-3 py-2.5 font-medium'
}
export function StockDataTable({
columns, rows, renderCell,
rowKey = (r: any) => r.symbol,
rowClassName = () => 'border-t border-border hover:bg-elevated/50',
headerSticky = false,
minWidth,
sort,
onSortToggle,
renderExtraCol,
extraHeader,
renderHeaderContent,
className = 'rounded-card border border-border overflow-x-auto',
}: StockDataTableProps) {
const visibleColumns = columns.filter(c => c.visible)
const computedMinWidth = minWidth ?? Math.max(900, visibleColumns.length * 110)
const isColSortable = (col: ColumnConfig): boolean => {
// 排序能力由调用方是否提供 onSortToggle 决定;sort 是否为 null 只影响当前指示器
if (!onSortToggle) return false
if (col.source.type === 'builtin' && UNSORTABLE_KEYS.has(col.source.key)) return false
return true
}
const theadClass = headerSticky
? 'sticky top-0 z-10 bg-surface after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border'
: 'bg-elevated'
return (
<div className={className}>
<table className="w-full text-sm" style={{ minWidth: computedMinWidth }}>
<thead className={theadClass}>
<tr className="text-left text-secondary">
{visibleColumns.map(col => {
const sortable = isColSortable(col)
const isSorted = sort?.key === col.id
const dir = isSorted ? sort!.dir : null
const contentOverride = renderHeaderContent?.(col)
return (
<th
key={col.id}
className={`${alignThClass(col.align)} ${sortable ? 'cursor-pointer select-none group' : ''}`}
onClick={sortable ? () => onSortToggle!(col.id) : undefined}
>
{contentOverride !== undefined ? contentOverride : col.label}
{sortable && (
<span className="inline-block ml-1 text-[10px] opacity-30 group-hover:opacity-60 transition-opacity">
{isSorted ? (dir === 'asc' ? '↑' : '↓') : '↕'}
</span>
)}
</th>
)
})}
{extraHeader && (
<th className="px-3 py-2.5 font-medium text-right">{extraHeader}</th>
)}
</tr>
</thead>
<tbody>
{rows.map((r: any) => {
return (
<tr
key={rowKey(r)}
className={`transition-colors duration-150 ease-smooth group ${rowClassName(r)}`}
>
{visibleColumns.map(col => renderCell(r, col))}
{renderExtraCol && renderExtraCol(r)}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,163 @@
/**
* 股票列表单元格渲染原语(共享)。
*
* 只负责「无业务上下文」的纯数据列:价格/成交/均线/区间/技术指标/动量/连板/财务等。
* symbol、strategies、score、signals、candle 等需要页面上下文(加自选按钮、失效行、
* kline 数据、信号提取)的列由各页面的 renderCell 自行处理。
*
* 口径与原 ScreenerTable 对齐(已和自选页校准过):amplitude 用 *100、annual_vol/
* 财务率类用 fmtPct、kdj 用 toFixed(1)、vol_ma 用 fmtBigNum 等。
*/
import type { ReactNode } from 'react'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
import type { ColumnConfig } from '@/lib/list-columns'
import { NUM_CELL_CLASS } from '@/lib/stock-table'
// ===== 板块标识(自选/策略页统一口径) =====
export function boardTag(symbol: string): { label: string; color: string } | null {
if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' }
if (/^688/.test(symbol)) return { label: '科', color: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' }
if (/\.BJ$/.test(symbol)) return { label: '北', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' }
return null
}
// ===== RSI 指标带颜色 =====
export function RSIBadge({ value }: { value: number | null | undefined }) {
if (value == null || Number.isNaN(value)) return <span className="text-muted"></span>
let color = 'text-secondary'
if (value >= 80) color = 'text-danger font-medium'
else if (value >= 70) color = 'text-bull'
else if (value <= 20) color = 'text-bear font-medium'
else if (value <= 30) color = 'text-bear'
return <span className={`tabular-nums ${color}`}>{value.toFixed(1)}</span>
}
// ===== 纯数据列渲染 =====
function fmtMaybePrice(value: any) {
return value != null && !Number.isNaN(value) ? fmtPrice(value) : '—'
}
/**
* 渲染一个纯数据内置列的 <td>。
* 返回 null 表示该列不属于纯数据列(symbol/strategies/score/signals/candle 等),由调用方处理。
*/
export function renderBuiltinDataCell(r: any, col: ColumnConfig): ReactNode | null {
if (col.source.type !== 'builtin') return null
const key = col.source.key
// 这些列需要业务上下文,不在此处理
if (key === 'symbol' || key === 'strategies' || key === 'score' || key === 'signals' || key === 'candle') {
return null
}
const numCls = `${alignTdClass(col.align)} ${NUM_CELL_CLASS}`
switch (key) {
// 价格
case 'price':
return <td key={col.id} className={`${numCls} text-secondary`}>{fmtMaybePrice(r.close)}</td>
case 'pct':
return <td key={col.id} className={`${numCls} font-medium ${priceColorClass(r.change_pct)}`}>{fmtPct(r.change_pct)}</td>
case 'change_amount':
return <td key={col.id} className={`${numCls} ${priceColorClass(r.change_amount)}`}>{r.change_amount != null ? fmtPrice(r.change_amount) : '—'}</td>
case 'amplitude':
return <td key={col.id} className={numCls}>{r.amplitude != null ? `${(r.amplitude * 100).toFixed(2)}%` : '—'}</td>
// 成交
case 'turnover':
return <td key={col.id} className={numCls}>{r.turnover_rate != null ? `${Number(r.turnover_rate).toFixed(2)}%` : '—'}</td>
case 'amount':
return <td key={col.id} className={`${numCls} text-secondary`}>{fmtBigNum(r.amount)}</td>
case 'float_val':
return <td key={col.id} className={`${numCls} text-secondary`}>{r.float_shares && r.close ? fmtBigNum(r.float_shares * r.close) : '—'}</td>
case 'vol_ratio':
return (
<td key={col.id} className={numCls}>
<span className={r.vol_ratio_5d >= 2 ? 'text-accent font-medium' : ''}>
{fmtMaybePrice(r.vol_ratio_5d)}
</span>
</td>
)
case 'annual_vol':
return <td key={col.id} className={numCls}>{r.annual_vol_20d != null ? fmtPct(r.annual_vol_20d) : '—'}</td>
// 均线
case 'ma5': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma5)}</td>
case 'ma10': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma10)}</td>
case 'ma20': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma20)}</td>
case 'ma60': return <td key={col.id} className={numCls}>{fmtMaybePrice(r.ma60)}</td>
// 区间
case 'high_60d': return <td key={col.id} className={numCls}>{r.high_60d != null ? fmtPrice(r.high_60d) : '—'}</td>
case 'low_60d': return <td key={col.id} className={numCls}>{r.low_60d != null ? fmtPrice(r.low_60d) : '—'}</td>
// 技术指标
case 'rsi6': return <td key={col.id} className={numCls}><RSIBadge value={r.rsi_6} /></td>
case 'rsi14': return <td key={col.id} className={numCls}><RSIBadge value={r.rsi_14} /></td>
case 'rsi24': return <td key={col.id} className={numCls}><RSIBadge value={r.rsi_24} /></td>
case 'macd_dif':
return <td key={col.id} className={`${numCls} ${priceColorClass(r.macd_hist)}`}>{r.macd_dif != null ? fmtPrice(r.macd_dif) : '—'}</td>
case 'macd_dea':
return <td key={col.id} className={numCls}>{r.macd_dea != null ? fmtPrice(r.macd_dea) : '—'}</td>
case 'macd_hist':
return <td key={col.id} className={`${numCls} ${priceColorClass(r.macd_hist)}`}>{r.macd_hist != null ? fmtPrice(r.macd_hist) : '—'}</td>
case 'kdj_k': return <td key={col.id} className={numCls}>{r.kdj_k != null ? r.kdj_k.toFixed(1) : '—'}</td>
case 'kdj_d': return <td key={col.id} className={numCls}>{r.kdj_d != null ? r.kdj_d.toFixed(1) : '—'}</td>
case 'kdj_j': return <td key={col.id} className={numCls}>{r.kdj_j != null ? r.kdj_j.toFixed(1) : '—'}</td>
case 'boll_upper': return <td key={col.id} className={numCls}>{r.boll_upper != null ? fmtPrice(r.boll_upper) : '—'}</td>
case 'boll_lower': return <td key={col.id} className={numCls}>{r.boll_lower != null ? fmtPrice(r.boll_lower) : '—'}</td>
case 'atr14': return <td key={col.id} className={numCls}>{r.atr_14 != null ? fmtPrice(r.atr_14) : '—'}</td>
case 'vol_ma5': return <td key={col.id} className={numCls}>{r.vol_ma5 != null ? fmtBigNum(r.vol_ma5) : '—'}</td>
case 'vol_ma10': return <td key={col.id} className={numCls}>{r.vol_ma10 != null ? fmtBigNum(r.vol_ma10) : '—'}</td>
// 动量
case 'momentum_5d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_5d)}`}>{fmtPct(r.momentum_5d)}</td>
case 'momentum_10d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_10d)}`}>{fmtPct(r.momentum_10d)}</td>
case 'momentum_20d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_20d)}`}>{fmtPct(r.momentum_20d)}</td>
case 'momentum_30d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_30d)}`}>{fmtPct(r.momentum_30d)}</td>
case 'momentum_60d': return <td key={col.id} className={`${numCls} ${priceColorClass(r.momentum_60d)}`}>{fmtPct(r.momentum_60d)}</td>
// 连板
case 'limit_ups':
return (
<td key={col.id} className="px-3 py-2 text-center">
{r.consecutive_limit_ups > 0 ? (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded bg-danger/15 text-danger text-xs font-bold tabular-nums">
{r.consecutive_limit_ups}
</span>
) : (
<span className="text-muted"></span>
)}
</td>
)
case 'limit_downs':
return (
<td key={col.id} className="px-3 py-2 text-center">
{r.consecutive_limit_downs > 0 ? (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded bg-bear/15 text-bear text-xs font-bold tabular-nums">
{r.consecutive_limit_downs}
</span>
) : (
<span className="text-muted"></span>
)}
</td>
)
// 财务指标(后端 enriched 未返回时显示 —)
case 'eps': return <td key={col.id} className={numCls}>{r.eps != null ? fmtPrice(r.eps) : '—'}</td>
case 'bps': return <td key={col.id} className={numCls}>{r.bps != null ? fmtPrice(r.bps) : '—'}</td>
case 'roe': return <td key={col.id} className={numCls}>{r.roe != null ? fmtPct(r.roe) : '—'}</td>
case 'pe_ttm': return <td key={col.id} className={numCls}>{r.pe_ttm != null ? fmtPrice(r.pe_ttm) : '—'}</td>
case 'pb': return <td key={col.id} className={numCls}>{r.pb != null ? fmtPrice(r.pb) : '—'}</td>
case 'gross_margin': return <td key={col.id} className={numCls}>{r.gross_margin != null ? fmtPct(r.gross_margin) : '—'}</td>
case 'net_margin': return <td key={col.id} className={numCls}>{r.net_margin != null ? fmtPct(r.net_margin) : '—'}</td>
case 'revenue_yoy': return <td key={col.id} className={numCls}>{r.revenue_yoy != null ? fmtPct(r.revenue_yoy) : '—'}</td>
case 'net_income_yoy':return <td key={col.id} className={numCls}>{r.net_income_yoy != null ? fmtPct(r.net_income_yoy) : '—'}</td>
case 'debt_ratio': return <td key={col.id} className={numCls}>{r.debt_ratio != null ? fmtPct(r.debt_ratio) : '—'}</td>
default:
return <td key={col.id} className={`${alignTdClass(col.align)} text-muted`}></td>
}
}
/** 根据列对齐返回 td 基础 class(不含 num 样式) */
function alignTdClass(align: ColumnConfig['align']): string {
if (align === 'right') return 'px-3 py-2 text-right'
if (align === 'center') return 'px-3 py-2 text-center'
return 'px-3 py-2'
}
@@ -0,0 +1,50 @@
/**
* 表格排序 hook(三态:无 → 升序 → 降序 → 无)。
*
* 从自选页提炼,自选/策略列表共享。调用方提供 getSortValue 决定每列的排序标量。
*/
import { useCallback, useState } from 'react'
import type { ColumnConfig } from '@/lib/list-columns'
import { UNSORTABLE_KEYS, getSortValue as defaultGetSortValue } from '@/lib/stock-table'
export interface SortState {
key: string // 列 id
dir: 'asc' | 'desc'
}
export function useTableSort<T>(getSortValue: (r: T, col: ColumnConfig) => any = defaultGetSortValue) {
const [sort, setSort] = useState<SortState | null>(null)
/** 点击表头:同列轮换 asc→desc→清除;不同列重置为 asc */
const toggle = useCallback((colId: string) => {
setSort(prev => {
if (!prev || prev.key !== colId) return { key: colId, dir: 'asc' }
if (prev.dir === 'asc') return { key: colId, dir: 'desc' }
return null
})
}, [])
/** 对行集合按当前 sort 排序(返回新数组)。无 sort 或列为不可排序时原样返回。 */
const sortRows = useCallback((rows: T[], columns: ColumnConfig[]): T[] => {
if (!sort) return rows
const col = columns.find(c => c.id === sort.key)
if (!col) return rows
if (col.source.type === 'builtin' && UNSORTABLE_KEYS.has(col.source.key)) return rows
const { dir } = sort
return [...rows].sort((a, b) => {
const va = getSortValue(a, col)
const vb = getSortValue(b, col)
if (va == null) return 1
if (vb == null) return -1
const na = typeof va === 'number' ? va : Number(va)
const nb = typeof vb === 'number' ? vb : Number(vb)
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
return dir === 'asc' ? na - nb : nb - na
}
const sa = String(va), sb = String(vb)
return dir === 'asc' ? sa.localeCompare(sb) : sb.localeCompare(sa)
})
}, [sort, getSortValue])
return { sort, toggle, sortRows }
}
+79
View File
@@ -0,0 +1,79 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* === §6.0 设计语言 CSS variables ===
* 暗色为默认(html.dark) / 亮色用 :root 反转
* HSL 形式以兼容 Tailwind alpha-value 语法
*/
:root {
/* 亮色(可切换) */
--base: 0 0% 98%; /* #FAFAFA */
--surface: 0 0% 100%; /* #FFFFFF */
--elevated: 240 5% 96%; /* #F4F4F5 */
--border: 240 6% 90%; /* #E4E4E7 */
--fg-primary: 240 6% 10%; /* #18181B */
--fg-secondary: 240 4% 35%;/* #52525B */
--fg-muted: 240 5% 65%; /* #A1A1AA */
--accent: 217 91% 60%; /* #3B82F6 电光蓝 */
--bull: 4 87% 60%; /* #F04438 红涨 */
--bear: 152 67% 45%; /* #12B76A 绿跌 */
--warning: 32 95% 50%; /* #F79009 */
--danger: 4 87% 60%; /* #F04438 */
}
html.dark {
--base: 240 5% 5%; /* #0A0A0B */
--surface: 240 7% 10%; /* #18181B 略亮一点,与 base 对比清晰 */
--elevated: 240 9% 14%; /* #212126 */
--border: 240 5% 22%; /* #353539 边框加亮,易识别 */
--fg-primary: 0 0% 98%; /* #FAFAFA */
--fg-secondary: 240 5% 78%;/* #C4C4CB 大幅提亮 — 子标题/导航非激活态 */
--fg-muted: 240 5% 58%; /* #8E8E96 muted 提亮 — 辅助提示 */
--accent: 217 91% 60%;
--bull: 4 87% 60%;
--bear: 152 67% 45%;
--warning: 32 95% 50%;
--danger: 4 87% 60%;
}
/* 价格 / 数字一律等宽 */
.scrollbar-gutter-stable { scrollbar-gutter: stable; }
/* 全局滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: hsl(var(--border) / 0.72);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent) / 0.75);
background-clip: content-box;
}
.tabular { font-variant-numeric: tabular-nums; }
.num { font-family: theme('fontFamily.mono'); font-variant-numeric: tabular-nums; }
/* 默认 1px 细分隔线(暗色用边框区分层次,不用 shadow) */
* { border-color: hsl(var(--border)); }
body {
background: hsl(var(--base));
color: hsl(var(--fg-primary));
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
/* 暗色模式不用 antialiased — 让字形保持饱满,不发虚 */
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: optimizeLegibility;
}
+336
View File
@@ -0,0 +1,336 @@
import { useSyncExternalStore } from 'react'
import { api } from './api'
/**
* AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。
*
* 设计要点:
* 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。
* 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。
* 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。
* (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离)
* 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。
* 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。
*/
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
export interface ActiveTask {
id: string // 任务 id(前端生成,与最终 report id 解耦)
symbol: string
name: string
focus: string
phase: Phase
content: string // 累积的 Markdown
error: string
meta: { summary?: string; periods?: number } | null
createdAt: number // ms 时间戳
savedReportId?: string // 完成后存到后端的报告 id
doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理)
dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示
}
export interface HistoryReport {
id: string
symbol: string
name: string
focus: string
content: string
periods?: number
summary?: string
created_at: string
}
const MAX_ACTIVE = 3
// ===== 全局状态 =====
let activeTasks: ActiveTask[] = []
let history: HistoryReport[] = []
let historyLoaded = false
const listeners = new Set<() => void>()
// 当前"前台"展示的任务:
// - 活跃任务 id(正在生成/刚完成,对话框打开)
// - 或 'history:<id>'(查看历史报告)
// - 或 null(对话框关闭/最小化)
let activeDialogTaskId: string | null = null
let dialogMinimized = false // 对话框是否最小化为气泡
function emit() { listeners.forEach(fn => fn()) }
function subscribe(fn: () => void) {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
let _activeSnap: ActiveTask[] = []
let _historySnap: HistoryReport[] = []
interface DialogSnap { taskId: string | null; minimized: boolean }
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
function rebuildSnap() {
_activeSnap = activeTasks
_historySnap = history
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
}
function getActiveSnapshot() { return _activeSnap }
function getHistorySnapshot() { return _historySnap }
function getDialogSnapshot() { return _dialogSnap }
function patchTask(id: string, patch: Partial<ActiveTask>) {
activeTasks = activeTasks.map(t => {
if (t.id !== id) return t
const next = { ...t, ...patch }
// 首次进入 done/error 态时记录 doneAt
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
next.doneAt = Date.now()
}
return next
})
rebuildSnap()
emit()
}
// ===== 公开:查询 hooks =====
export function useBubbleTasks(): ActiveTask[] {
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
// 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。
// (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现)
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
const ds = _dialogSnap
return all.filter(t => {
// 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化)
if (t.phase === 'loading' || t.phase === 'streaming') {
// 除非对话框正打开看着它(非最小化)
return !(ds.taskId === t.id && !ds.minimized)
}
// 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。
// 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。
if (t.dismissed) return false // 用户已点击查看过 → 移除
if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复
return true
})
}
/** 兼容旧调用名(Layout 等处可能引用) */
export const useActiveTasks = useBubbleTasks
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
return { reports, loaded: historyLoaded }
}
export function useDialogState() {
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
}
/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
const ds = useDialogState()
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
if (!ds.taskId) return { task: null, mode: null }
if (ds.taskId.startsWith('history:')) {
const rid = ds.taskId.slice('history:'.length)
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
}
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
}
// ===== 公开:动作 =====
/** 拉取历史报告(惰性,首次需要时调用)。 */
export async function loadHistory(): Promise<void> {
try {
const res = await api.financialReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch {
// 静默失败,列表会显示空
}
}
/**
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
* 若历史未加载,先触发拉取。
* @returns 最近一条报告,或 null
*/
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
// history 已按 created_at 降序,取第一条匹配
return history.find(r => r.symbol === symbol) ?? null
}
/**
* 启动一个新的 AI 分析任务。
* @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。
*/
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
// 同 symbol 已有活跃任务 → 直接聚焦它
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
if (existing) {
activeDialogTaskId = existing.id
dialogMinimized = false
rebuildSnap()
emit()
return { id: existing.id }
}
// 上限检查
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
if (ongoing.length >= MAX_ACTIVE) {
return { error: `同时进行的分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
}
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const task: ActiveTask = {
id, symbol, name, focus,
phase: 'loading', content: '', error: '',
meta: null, createdAt: Date.now(),
}
activeTasks = [...activeTasks, task]
activeDialogTaskId = id
dialogMinimized = false
rebuildSnap()
emit()
// 启动流式接收(后台运行,不阻塞)
runStream(id, symbol, focus)
return { id }
}
async function runStream(id: string, symbol: string, focus: string) {
try {
let firstDelta = true
for await (const chunk of api.financialAnalyzeStream(symbol, focus)) {
// 任务可能已被取消(不在列表里了)→ 终止
const cur = activeTasks.find(t => t.id === id)
if (!cur) return
switch (chunk.type) {
case 'meta':
patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } })
break
case 'delta':
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
patchTask(id, { content: cur.content + (chunk.content ?? '') })
break
case 'error':
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
return
case 'done':
// 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准)
patchTask(id, { phase: 'done' })
break
}
}
// 流正常结束 → 持久化报告
const final = activeTasks.find(t => t.id === id)
if (final && final.phase !== 'error' && final.content) {
try {
const res = await api.financialReportSave({
symbol: final.symbol,
name: final.name,
focus: final.focus,
content: final.content,
periods: final.meta?.periods,
summary: final.meta?.summary ?? '',
})
if (res.report) {
patchTask(id, { savedReportId: res.report.id })
// 加到历史列表头部
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
historyLoaded = true
rebuildSnap()
emit()
// 任务完成:不自动弹出对话框,只在胶囊显示"已完成"态,用户想看再点。
// (若对话框正打开看此任务,内容已实时更新;最小化/在别处则胶囊亮起完成态)
}
} catch {
// 持久化失败不影响前端已展示的内容
}
}
} catch (e: any) {
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: normalizeAiError(msg),
})
}
}
/** 打开对话框(活跃任务或历史报告)。 */
export function openDialog(taskId: string) {
activeDialogTaskId = taskId
dialogMinimized = false
rebuildSnap()
emit()
}
/** 最小化对话框 → 变成气泡。 */
export function minimizeDialog() {
dialogMinimized = true
rebuildSnap()
emit()
}
/** 关闭对话框(活跃任务继续在后台跑,仅移除对话框视图)。
* 对历史报告:仅关闭视图。
*/
export function closeDialog() {
activeDialogTaskId = null
dialogMinimized = false
rebuildSnap()
emit()
}
/** 从气泡恢复对话框。
* 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹);
* 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。
*/
export function restoreDialog(taskId: string) {
const t = activeTasks.find(x => x.id === taskId)
if (t && (t.phase === 'done' || t.phase === 'error')) {
patchTask(taskId, { dismissed: true })
}
activeDialogTaskId = taskId
dialogMinimized = false
rebuildSnap()
emit()
}
/** 重试一个失败/已完成的任务(以新任务方式重新分析)。 */
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
return startAnalysis(task.symbol, task.name, task.focus)
}
/** 删除历史报告。 */
export async function deleteReport(reportId: string): Promise<void> {
try {
await api.financialReportDelete(reportId)
history = history.filter(r => r.id !== reportId)
rebuildSnap()
emit()
} catch {
// 静默
}
}
/** 打开历史报告到对话框。 */
export function openHistoryReport(reportId: string) {
activeDialogTaskId = `history:${reportId}`
dialogMinimized = false
rebuildSnap()
emit()
}
+341
View File
@@ -0,0 +1,341 @@
/**
* 概念/行业分析 — 数据适配层
*
* 处理两种扩展数据结构:
* - 结构 A(个股维度):每行一只股票,维度字段(如 concept)存该股票所属的概念/行业
* - 结构 B(板块维度):每行一个概念/行业,带成分股列表(如 constituents: [...]
*
* 两种结构统一输出为 DimensionGroup[],供页面组件消费。
*/
import type { ExtDataConfig, ExtDataField, ExtDataRowsResult } from '@/lib/api'
// ===== 公共类型 =====
export interface StockRow {
symbol: string
code?: string
name?: string
[key: string]: unknown
}
export interface DimensionGroup {
/** 维度名称(概念名/行业名) */
key: string
/** 成分股数量 */
count: number
/** 成分股原始行 */
stocks: StockRow[]
/** 聚合指标(如涨跌幅均值等) */
metrics: Record<string, number | null>
}
export interface ResolvedDimension {
/** 是否成功解析 */
ok: boolean
/** 数据结构类型 */
structure: 'per_stock' | 'per_dimension' | 'unknown'
/** 维度字段名 */
dimensionField: string
/** 所有解析出的分组 */
groups: DimensionGroup[]
/** 原始全部行(结构 A 下为原始行,结构 B 下展平后的全部成分股) */
allStocks: StockRow[]
/** 解析提示 */
hint?: string
}
// ===== 结构探测 =====
const SEPARATORS = /[、,;|/\s]+/
const CONSTITUENT_KEYS = [
'constituents', '成分股', 'stocks', 'members', 'codes', 'list',
'symbol_list', 'stock_list', 'member_list',
]
const DIMENSION_NAME_KEYS = [
'name', '概念名称', '概念', '行业名称', '行业', '板块名称', '板块',
'concept', 'industry', 'sector', 'theme', 'title', 'label',
]
/** 检测行是否是"板块维度"结构(含成分股列表字段) */
function detectConstituentField(fields: ExtDataField[]): string | null {
return fields.find(f =>
CONSTITUENT_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
)?.name ?? null
}
/** 检测维度名称字段 */
function detectDimensionNameField(fields: ExtDataField[]): string | null {
return fields.find(f =>
DIMENSION_NAME_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
)?.name ?? null
}
/** 从候选名中选取最佳维度字段(结构 A) */
export function pickDimensionField(
fields: ExtDataField[],
candidates: string[],
): string {
const nonMeta = fields.filter(f =>
!['symbol', 'code', 'name', '股票简称', '股票代码', 'date'].includes(f.name)
)
for (const c of candidates) {
const m = nonMeta.find(f =>
f.name.toLowerCase().includes(c.toLowerCase()) ||
f.label?.toLowerCase().includes(c.toLowerCase())
)
if (m) return m.name
}
// 回退:第一个非数值字段
return nonMeta.find(f => f.dtype !== 'int' && f.dtype !== 'float')?.name ?? nonMeta[0]?.name ?? ''
}
/** 判断字段是否为数值类型 */
function isNumericField(f: ExtDataField): boolean {
return f.dtype === 'int' || f.dtype === 'float'
}
// ===== 结构 A 解析:个股维度 =====
function parsePerStock(
rows: Record<string, any>[],
dimensionField: string,
numericFields: string[],
): DimensionGroup[] {
const map = new Map<string, StockRow[]>()
for (const row of rows) {
const raw = row[dimensionField]
if (raw == null) continue
const text = String(raw).trim()
if (!text) continue
// 支持多值分隔(如 "人工智能,芯片,5G"
const values = text.split(SEPARATORS).map(s => s.trim()).filter(Boolean)
const stock: StockRow = { ...row, symbol: row.symbol ?? row.code ?? '' }
for (const v of values) {
const list = map.get(v) ?? []
list.push(stock)
map.set(v, list)
}
}
return [...map.entries()]
.map(([key, stocks]) => ({
key,
count: stocks.length,
stocks,
metrics: computeMetrics(stocks, numericFields),
}))
.sort((a, b) => b.count - a.count)
}
// ===== 结构 B 解析:板块维度 =====
function parsePerDimension(
rows: Record<string, any>[],
constituentField: string,
nameField: string,
numericFields: string[],
): DimensionGroup[] {
const allStocks: StockRow[] = []
const groups = rows.map(row => {
const key = String(row[nameField] ?? row[constituentField] ?? '').trim()
if (!key) return null
// 成分股可能是字符串数组、对象数组、逗号分隔字符串
const rawList = row[constituentField]
const stocks = parseConstituents(rawList)
stocks.forEach(s => { if (s.symbol) allStocks.push(s) })
// 维度自身的数值指标也保留
const metrics = computeMetrics(stocks, numericFields)
// 补上行级别的数值
for (const f of numericFields) {
if (typeof row[f] === 'number') {
metrics[`__dim_${f}`] = row[f]
}
}
return { key, count: stocks.length, stocks, metrics } as DimensionGroup
}).filter((g): g is DimensionGroup => g !== null && g.key !== '')
return groups.sort((a, b) => b.count - a.count)
}
/** 解析成分股字段(支持多种格式) */
function parseConstituents(raw: unknown): StockRow[] {
if (raw == null) return []
if (typeof raw === 'string') {
// 逗号/分隔符分隔的股票代码字符串
return raw.split(SEPARATORS).map(s => s.trim()).filter(Boolean).map(s => ({
symbol: normalizeSymbol(s),
code: s,
}))
}
if (Array.isArray(raw)) {
return raw.map(item => {
if (typeof item === 'string') {
return { symbol: normalizeSymbol(item), code: item }
}
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, any>
return {
symbol: obj.symbol ?? obj.code ?? obj. ?? '',
code: obj.code ?? obj.symbol ?? '',
name: obj.name ?? obj. ?? obj. ?? '',
...obj,
}
}
return { symbol: String(item) }
})
}
return []
}
function normalizeSymbol(s: string): string {
// 尝试补全为 6 位代码
if (/^\d{6}$/.test(s)) return s
return s
}
// ===== 聚合指标计算 =====
function computeMetrics(
stocks: StockRow[],
numericFields: string[],
): Record<string, number | null> {
const result: Record<string, number | null> = {}
for (const f of numericFields) {
const vals = stocks
.map(s => s[f])
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
if (vals.length === 0) {
result[f] = null
} else {
result[f] = vals.reduce((a, b) => a + b, 0) / vals.length
}
}
return result
}
// ===== 主入口:自动探测 + 解析 =====
export function resolveDimension(
data: ExtDataRowsResult | null | undefined,
config: ExtDataConfig | null | undefined,
candidateFields: string[],
): ResolvedDimension {
if (!data || !config || !data.rows.length) {
return { ok: false, structure: 'unknown', dimensionField: '', groups: [], allStocks: [] }
}
const fields = data.fields ?? config.fields
const rows = data.rows
const numericFields = fields.filter(f => isNumericField(f)).map(f => f.name)
// 先检测是否为结构 B(板块维度)
const constituentField = detectConstituentField(fields)
if (constituentField) {
const nameField = detectDimensionNameField(fields) ?? 'name'
const groups = parsePerDimension(rows, constituentField, nameField, numericFields)
const allStocks = groups.flatMap(g => g.stocks)
return {
ok: true,
structure: 'per_dimension',
dimensionField: nameField,
groups,
allStocks,
hint: `检测到板块维度结构(成分股字段: ${constituentField}`,
}
}
// 结构 A(个股维度)
const dimensionField = pickDimensionField(fields, candidateFields)
if (!dimensionField) {
return {
ok: false,
structure: 'unknown',
dimensionField: '',
groups: [],
allStocks: rows as StockRow[],
hint: '未找到合适的维度字段',
}
}
const groups = parsePerStock(rows, dimensionField, numericFields)
const allStocks = rows as StockRow[]
return {
ok: true,
structure: 'per_stock',
dimensionField,
groups,
allStocks,
hint: `${dimensionField} 分组,共 ${groups.length} 个维度`,
}
}
// ===== 行情数据关联 =====
export interface QuoteMap {
symbol: string
price?: number
pct?: number
change_pct?: number
name?: string
[key: string]: unknown
}
/** 构建 symbol → quote 的快速查找 */
export function buildQuoteMap(quotes: QuoteMap[]): Map<string, QuoteMap> {
const map = new Map<string, QuoteMap>()
for (const q of quotes) {
if (q.symbol) map.set(q.symbol, q)
// 也用纯数字代码做索引
const code = q.symbol?.replace(/\.\w+$/, '')
if (code) map.set(code, q)
}
return map
}
/** 为分组计算行情聚合指标 */
export function computeQuoteMetrics(
stocks: StockRow[],
quoteMap: Map<string, QuoteMap>,
): {
avgPct: number | null
upCount: number
downCount: number
flatCount: number
totalVolume: number
} {
let up = 0, down = 0, flat = 0, totalVol = 0
let sumPct = 0, countPct = 0
for (const s of stocks) {
const sym = String(s.symbol ?? '')
const q = quoteMap.get(sym) ?? quoteMap.get(sym.replace(/\.\w+$/, ''))
if (!q) continue
const pct = q.pct ?? q.change_pct
if (pct != null && typeof pct === 'number' && Number.isFinite(pct)) {
sumPct += pct
countPct++
if (pct > 0) up++
else if (pct < 0) down++
else flat++
}
totalVol += (typeof q.price === 'number' ? 1 : 0) // 简化计数
}
return {
avgPct: countPct > 0 ? sumPct / countPct : null,
upCount: up,
downCount: down,
flatCount: flat,
totalVolume: totalVol,
}
}
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
import { useSyncExternalStore } from 'react'
import type { StrategyBacktestResult } from './api'
/**
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
*
* 特性:
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
*/
export interface BacktestProgress {
day: number
total: number
date: string
equity: number
}
export interface BacktestTask {
id: number
isPending: boolean
result: StrategyBacktestResult | null
progress: BacktestProgress | null
error: string | null
}
let current: BacktestTask | null = null
const listeners = new Set<() => void>()
let taskSeq = 0
let eventSource: EventSource | null = null
const RECONNECT_KEY = 'backtest_reconnect'
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function getSnapshot() {
return current
}
function getServerSnapshot() {
return null
}
/** 查询字符串构建 */
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') sp.set(k, String(v))
}
return sp.toString()
}
/** 连接 SSE (新建或重连都用这个) */
function connectSSE(url: string): void {
const id = current?.id ?? ++taskSeq
// 关闭旧连接
if (eventSource) {
eventSource.close()
eventSource = null
}
const es = new EventSource(url)
eventSource = es
es.addEventListener('progress', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const prog = JSON.parse(e.data) as BacktestProgress
current = { ...current, progress: prog }
emit()
} catch { /* ignore */ }
})
es.addEventListener('done', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const result = JSON.parse(e.data) as StrategyBacktestResult
current = { ...current, isPending: false, result, error: null }
emit()
} catch {
current = { ...current, isPending: false, error: '结果解析失败' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
})
es.addEventListener('error', (e: MessageEvent) => {
if (current?.id !== id) return
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
if (e.data) {
try {
const msg = JSON.parse(e.data)?.message ?? '回测出错'
current = { ...current, isPending: false, error: msg }
emit()
} catch {
current = { ...current, isPending: false, error: '回测出错' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
}
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
})
}
/** 启动一次 SSE 回测任务 */
export function startBacktest(params: {
strategy_id: string
symbols?: string[] | null
start?: string | null
end?: string | null
matching?: string
entry_fill?: string
exit_fill?: string
fees_pct?: number
slippage_bps?: number
max_positions?: number
max_exposure_pct?: number
initial_capital?: number
position_sizing?: string
params?: Record<string, any> | null
overrides?: Record<string, any> | null
mode?: 'position' | 'full'
holding_days?: number
}): void {
// 取消之前的任务状态
if (eventSource) {
eventSource.close()
eventSource = null
}
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
const qs = buildQuery({
strategy_id: params.strategy_id,
symbols: params.symbols?.join(','),
start: params.start ?? undefined,
end: params.end ?? undefined,
matching: params.matching,
entry_fill: params.entry_fill,
exit_fill: params.exit_fill,
fees_pct: params.fees_pct,
slippage_bps: params.slippage_bps,
max_positions: params.max_positions,
max_exposure_pct: params.max_exposure_pct,
initial_capital: params.initial_capital,
position_sizing: params.position_sizing,
params: params.params ? JSON.stringify(params.params) : undefined,
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
mode: params.mode,
holding_days: params.holding_days,
})
// 存 reconnect 信息 (刷新后用)
localStorage.setItem(RECONNECT_KEY, qs)
connectSSE(`/api/backtest/strategy/stream?${qs}`)
}
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
export async function stopBacktest(): Promise<void> {
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
const qs = localStorage.getItem(RECONNECT_KEY)
if (qs) {
// 解析出参数, 用 fetch 调 cancel
try {
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
// 后端会找到同一个 job 并返回它的 job_key? 不行。
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
await fetch('/api/backtest/strategy/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qs }),
}).catch(() => {})
} catch { /* ignore */ }
}
if (eventSource) {
eventSource.close()
eventSource = null
}
if (current?.isPending) {
current = { ...current, isPending: false, error: '已取消' }
emit()
}
localStorage.removeItem(RECONNECT_KEY)
}
/** 清除任务状态 (隐藏提示) */
export function clearBacktest(): void {
current = null
emit()
}
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
export function tryReconnect(): boolean {
const qs = localStorage.getItem(RECONNECT_KEY)
if (!qs) return false
// 有未完成的任务, 重连
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
connectSSE(`/api/backtest/strategy/stream?${qs}`)
return true
}
/** React hook: 读取当前全局回测任务状态 */
export function useBacktestTask(): BacktestTask | null {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
+25
View File
@@ -0,0 +1,25 @@
// 板块判断工具函数
export const BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] as const
export type BoardType = (typeof BOARDS)[number]
/** 根据股票代码判断板块 */
export function getBoardType(symbol: string): BoardType | null {
if (/^(300|301)/.test(symbol)) return '创业板'
if (/^688/.test(symbol)) return '科创板'
if (/\.BJ$/.test(symbol)) return '北交所'
if (/^60[0135]/.test(symbol)) return '沪主板'
if (/^00[012]/.test(symbol)) return '深主板'
return null
}
/** 板块简称标签: 主板返回空字符串(不显示), 创/科/北 等返回简称 */
export function boardTag(symbol: string): string {
const b = getBoardType(symbol)
if (!b) return ''
if (b === '沪主板' || b === '深主板') return ''
if (b === '创业板') return '创'
if (b === '科创板') return '科'
if (b === '北交所') return '北'
return ''
}
@@ -0,0 +1,106 @@
// capability 内部名 → 用户能理解的中文标签
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'quote.by_symbol': { name: '自选股实时监控', hint: 'Free 可按标的查询实时行情,用于少量自选股监控' },
'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' },
'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' },
'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' },
'kline.daily.batch': { name: '日 K(批量)', hint: '一次拿多只股票的日 K — 选股 / 信号扫描 必需' },
'kline.minute.by_symbol': { name: '分钟 K(按标的)', hint: '单股 1m/5m/15m/30m/60m K 线' },
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
}
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
// none = None 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
export const TIER_RANK: Record<string, number> = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 }
export const EXPERT_RANK = TIER_RANK.expert
export function tierRank(label: string): number {
const base = (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
return TIER_RANK[base] ?? -1
}
export function isExpertOrAbove(label: string): boolean {
return tierRank(label) >= EXPERT_RANK
}
/** 档位完整样式(tag 背景 + 圆点 + 文字渐变), 与左侧菜单 TierBadge 一致 */
export interface TierStyle {
tagBg: { background: string }
dotStyle: { background: string }
labelTextStyle: { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string }
desc: string
}
const TIER_STYLE: Record<string, TierStyle> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '历史日K · 自选实时',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '除权因子 · 全市场实时',
tagBg: { background: 'rgba(59,130,246,0.2)' },
dotStyle: { background: '#3b82f6' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
desc: '分钟K · 盘口',
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
}
/** 从档位 label 提取基础档位名(小写): "Expert +" → "expert" */
export function tierBaseName(label: string): string {
return (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
}
/** 返回档位完整样式 */
export function tierStyle(label: string): TierStyle {
return TIER_STYLE[tierBaseName(label)] ?? TIER_STYLE.free
}
/** 所有档位(有序, 供档位列表渲染) */
export const ALL_TIERS = ['none', 'free', 'starter', 'pro', 'expert'] as const
/** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */
export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } {
return tierStyle(label).labelTextStyle
}
/** 渲染档位 tag(与左侧菜单一致的胶囊样式) */
export function TierTag({ label, className = '' }: { label: string; className?: string }) {
const t = tierStyle(label)
const base = tierBaseName(label)
// none 档显示英文「None」,其余档显示英文档名
const display = base === 'none' ? 'None' : base
return (
<span
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
style={t.tagBg}
>
<span className="truncate capitalize" style={t.labelTextStyle}>{display}</span>
</span>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 全局颜色别名 — 集中管理项目中重复使用的色彩组合。
*
* 不修改 Tailwind 配置和 CSS 变量,仅作为语义化引用。
* 使用方式:
* import { color } from '@/lib/colors'
* className={`${color.select.bg} ${color.select.text}`}
* // → bg-sky-500 text-sky-400
*/
export const color = {
/** 选中/激活态 — sky 色系 */
select: {
bg: 'bg-sky-500',
text: 'text-sky-400',
border: 'border-sky-400/40',
bgLight: 'bg-sky-400/10',
borderHover: 'hover:border-sky-400/30',
},
/** 选股条件区 section 标题色 */
filterSection: 'text-sky-400',
/** 评分权重正常指示 — emerald 色系 */
ok: 'text-emerald-400',
/** 评分权重异常警告 — amber 色系 */
scoreWarn: 'text-amber-400',
/** 交易参数区 section 标题色 */
tradeSection: 'text-emerald-400',
} as const
+81
View File
@@ -0,0 +1,81 @@
// 数字 / 价格 / 涨跌幅 格式化(§6.0.2 等宽数字)
export function fmtPrice(v: number | null | undefined, digits = 2): string {
if (v == null || Number.isNaN(v)) return '—'
return v.toFixed(digits)
}
export function fmtPct(v: number | null | undefined, digits = 2): string {
if (v == null || Number.isNaN(v)) return '—'
const sign = v > 0 ? '+' : ''
return `${sign}${(v * 100).toFixed(digits)}%`
}
export function fmtVolume(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
if (v >= 1e8) return `${(v / 1e8).toFixed(2)}亿`
if (v >= 1e4) return `${(v / 1e4).toFixed(2)}`
return v.toFixed(0)
}
// A 股语义色:红涨绿跌 → 仅用于价格相关元素
export function priceColorClass(v: number | null | undefined): string {
if (v == null || Number.isNaN(v) || v === 0) return 'text-muted'
return v > 0 ? 'text-bull' : 'text-bear'
}
export function fmtBigNum(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
if (v >= 1_000_000_000_000) return `${(v / 1_000_000_000_000).toFixed(2)}万亿`
if (v >= 100_000_000) return `${(v / 100_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
return v.toFixed(0)
}
export function fmtDate(s: string | Date | null | undefined): string {
if (s == null) return '—'
const d = typeof s === 'string' ? new Date(s) : s
if (isNaN(d.getTime())) return String(s)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// ===== Data 页面工具函数 =====
export function formatNumber(n: number): string {
if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1)}亿`
if (n >= 10_000) return `${(n / 10_000).toFixed(1)}`
return n.toLocaleString()
}
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`
const m = Math.floor(seconds / 60)
const s = seconds % 60
if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`
const h = Math.floor(m / 60)
const rm = m % 60
return rm > 0 ? `${h}h ${rm}m` : `${h}h`
}
export function formatScheduleDatePart(iso: string): string {
const d = new Date(iso)
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function formatScheduleTimePart(iso: string): string {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export function isToday(iso: string): boolean {
const d = new Date(iso)
const now = new Date()
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
}
export function formatLogTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
+199
View File
@@ -0,0 +1,199 @@
/**
* 通用列表列配置底座。
*
* 业务页面只负责定义内置列、分组和持久化 adapter;拖拽、显隐、扩展列参数等
* 公共能力集中在这里,避免每个股票列表重复实现。
*/
export type ColumnSource =
| { type: 'builtin'; key: string }
| { type: 'ext'; configId: string; fieldName: string; fieldLabel?: string }
| { type: 'computed'; key: string }
/** 扩展列字符串值渲染配置 */
export interface ExtColumnDisplayConfig {
/** 显示模式: tag=分隔为标签, text=纯文本 */
displayMode: 'tag' | 'text'
/** 自定义分隔符,留空使用默认 [、,,;;-] */
separator?: string
/** 列最大宽度 CSS 值,如 "200px" */
maxWidth?: string
/** 标签显示上限,0 或 undefined=全部显示 */
maxTags?: number
/** 隐藏的标签索引(0-based),在 maxTags 范围内按位置隐藏 */
hiddenIndices?: number[]
/** 标签排列方向: horizontal=横向(默认), vertical=竖向 */
tagLayout?: 'horizontal' | 'vertical'
}
/** 日k列渲染配置(builtin: candle 列专用) */
export interface CandleColumnConfig {
/** 开启(显示蜡烛图)时单元格宽度 px */
enabledWidth?: number
/** 开启时单元格高度 px */
enabledHeight?: number
/** 关闭(收起)时单元格宽度 px */
disabledWidth?: number
/** 关闭时单元格高度 px */
disabledHeight?: number
/** 显示最近多少个交易日的日k */
days?: number
}
/** 日k列配置默认值(与改造前 MiniCandlestick 硬编码值一致) */
export const DEFAULT_CANDLE_CONFIG: Required<CandleColumnConfig> = {
enabledWidth: 100,
enabledHeight: 80,
disabledWidth: 40,
disabledHeight: 40,
days: 12,
}
/** 数值边界(设置过大取上限,过小取最小值) */
const CANDLE_BOUNDS = {
enabledWidth: { min: 40, max: 300 },
enabledHeight: { min: 32, max: 200 },
disabledWidth: { min: 20, max: 200 },
disabledHeight:{ min: 20, max: 200 },
days: { min: 1, max: 60 },
} as const
function clampNum(v: unknown, bounds: { min: number; max: number }, fallback: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? v : fallback
return Math.min(bounds.max, Math.max(bounds.min, n))
}
/**
* 合并用户配置与默认值,并对越界数值做钳制(过大取上限,过小取最小值)。
* 返回字段齐全的配置,调用方可直接解构使用。
*/
export function resolveCandleConfig(cfg: CandleColumnConfig | undefined): Required<CandleColumnConfig> {
const c = cfg ?? {}
return {
enabledWidth: clampNum(c.enabledWidth, CANDLE_BOUNDS.enabledWidth, DEFAULT_CANDLE_CONFIG.enabledWidth),
enabledHeight: clampNum(c.enabledHeight, CANDLE_BOUNDS.enabledHeight, DEFAULT_CANDLE_CONFIG.enabledHeight),
disabledWidth: clampNum(c.disabledWidth, CANDLE_BOUNDS.disabledWidth, DEFAULT_CANDLE_CONFIG.disabledWidth),
disabledHeight: clampNum(c.disabledHeight, CANDLE_BOUNDS.disabledHeight, DEFAULT_CANDLE_CONFIG.disabledHeight),
days: clampNum(c.days, CANDLE_BOUNDS.days, DEFAULT_CANDLE_CONFIG.days),
}
}
export interface ColumnConfig {
id: string // 唯一标识,如 "builtin:price" 或 "ext:my_table:score"
source: ColumnSource
label: string // 用户看到的表头名
visible: boolean // 是否显示
pinned?: boolean // 固定列不可隐藏(代码/名称、操作)
align?: 'left' | 'center' | 'right'
/** 扩展列显示配置(仅 ext 类型生效) */
extDisplay?: ExtColumnDisplayConfig
/** 日k列渲染配置(仅 builtin: candle 列生效) */
candleConfig?: CandleColumnConfig
/** 信息条场景:是否单独占一行显示(仅 StockInfoBar 生效,表格场景忽略) */
standalone?: boolean
}
export interface ColumnGroup {
id: string
label: string
icon?: string
/** builtin/computed source key 列表 */
keys: string[]
}
export const DEFAULT_ACTION_COLUMN_ID = 'builtin:action'
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action */
export function serializeColumns(
columns: ColumnConfig[],
actionColumnId = DEFAULT_ACTION_COLUMN_ID,
): ColumnConfig[] {
return columns.filter(c => !c.pinned && c.id !== actionColumnId)
}
export interface MergeColumnsOptions {
actionColumnId?: string
pinnedFirstIds?: string[]
}
/** 合并用户保存的列与默认列,保留用户顺序并补齐新增默认列。 */
export function mergeColumns(
saved: ColumnConfig[] | null | undefined,
defaults: ColumnConfig[],
options: MergeColumnsOptions = {},
): ColumnConfig[] {
const actionColumnId = options.actionColumnId ?? DEFAULT_ACTION_COLUMN_ID
const pinnedFirstIds = options.pinnedFirstIds ?? ['builtin:symbol']
const normalizedSaved = Array.isArray(saved) ? saved : []
const result: ColumnConfig[] = []
const savedMap = new Map(normalizedSaved.map(c => [c.id, c]))
const defaultMap = new Map(defaults.map(c => [c.id, c]))
// 1. 按用户保存顺序排列
for (const col of normalizedSaved) {
if (!col || col.id === actionColumnId) continue
const def = defaultMap.get(col.id)
if (def) {
// 内置列: label/source/align/pinned 以默认定义为准;visible 使用用户配置;
// 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失
result.push({
...def,
visible: col.visible,
...(col.candleConfig ? { candleConfig: col.candleConfig } : {}),
...(col.extDisplay ? { extDisplay: col.extDisplay } : {}),
...(col.standalone ? { standalone: col.standalone } : {}),
})
} else if (col.source?.type === 'ext') {
// ext 列: 保留用户配置,清理旧 label 中的括号后缀
let extCol = col
if (col.label.includes('(') || col.label.includes('')) {
extCol = {
...col,
label: col.source.fieldLabel || col.label.replace(/[(].*/, '').trim() || col.source.fieldName,
}
}
result.push(extCol)
}
}
// 2. 补充新增的默认列
for (const def of defaults) {
if (!savedMap.has(def.id)) result.push(def)
}
// 3. 固定优先列放到最前,例如代码/名称
for (let i = pinnedFirstIds.length - 1; i >= 0; i -= 1) {
const id = pinnedFirstIds[i]
const idx = result.findIndex(c => c.id === id)
if (idx > 0) {
const [col] = result.splice(idx, 1)
result.unshift(col)
}
}
return result
}
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口。 */
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
return columns
.filter(c => c.visible && c.source.type === 'ext')
.map(c => `${(c.source as { type: 'ext'; configId: string; fieldName: string }).configId}.${(c.source as { type: 'ext'; configId: string; fieldName: string }).fieldName}`)
.join(',')
}
/** 根据 ext schema 数据创建 ext 列配置。 */
export function createExtColumn(
configId: string,
_configLabel: string,
fieldName: string,
fieldLabel?: string,
): ColumnConfig {
return {
id: `ext:${configId}:${fieldName}`,
source: { type: 'ext', configId, fieldName, fieldLabel },
label: fieldLabel || fieldName,
visible: false,
align: 'center',
}
}
+118
View File
@@ -0,0 +1,118 @@
import { useSyncExternalStore } from 'react'
/**
* 监控中心未读触发记录徽标 — 全局 store + localStorage 持久化。
*
* 核心逻辑:
* - 在监控中心页面时, 每次收到新推送都同步更新 lastSeen (看到=已读)
* - 离开监控中心后, 新推送才计入未读
* - 刷新页面从 localStorage 恢复 lastSeen, 未读 = 期间新增
*/
const STORAGE_KEY = 'monitor_last_seen_total'
let currentTotal = 0
let lastSeenTotal = readSeen()
let onMonitorPage = false // 当前是否在监控中心页面
let pendingSeen = false // Monitor mount 请求 markSeen, 等 currentTotal 就绪
const listeners = new Set<() => void>()
function readSeen(): number {
try {
const v = localStorage.getItem(STORAGE_KEY)
if (v === null) return -1 // 从未设置过 → 未初始化
return parseInt(v, 10) || 0
} catch {
return -1
}
}
function writeSeen(v: number) {
try { localStorage.setItem(STORAGE_KEY, String(v)) } catch { /* ignore */ }
}
function syncSeen() {
if (lastSeenTotal !== currentTotal) {
lastSeenTotal = currentTotal
writeSeen(currentTotal)
}
}
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
function getSnapshot() {
return Math.max(0, currentTotal - lastSeenTotal)
}
/** 轮询更新最新总数 (Layout 层调用)。 */
export function setCurrentTotal(total: number): void {
// total=0 视为未初始化, 不更新 (避免渲染期 data=undefined 传 0 重置 lastSeen)
if (total <= 0) return
// 首次初始化: lastSeen <= 0 (从未设置 -1, 或历史为 0) → 把已读基线设为当前总数
// 否则 lastSeen=0 + total=1 会被误算成"1条未读" (首次进入就显示徽标的 bug)
if (lastSeenTotal <= 0) {
lastSeenTotal = total
writeSeen(total)
}
// 总数减少 (清空) → 同步重置
if (total < lastSeenTotal) {
lastSeenTotal = total
writeSeen(total)
}
const changed = total !== currentTotal
currentTotal = total
// 消费 pending markSeen
if (pendingSeen) {
pendingSeen = false
syncSeen()
}
// ★ 在监控中心页面期间: 收到新推送立即同步 (看到=已读, 不计入未读)
else if (onMonitorPage && changed) {
syncSeen()
}
emit()
}
/** 进入监控页时调用。 */
export function markSeen(): void {
onMonitorPage = true
if (currentTotal > 0) {
syncSeen()
emit()
} else {
pendingSeen = true
}
}
/** 离开监控页时调用 (停止同步, 之后新增才计入未读)。 */
export function leaveMonitorPage(): void {
onMonitorPage = false
pendingSeen = false
// 不在此处 syncSeen — lastSeen 保持页面期间最后一次同步的值即可
// (避免 currentTotal 此刻还没刷新到最新, 写入偏小的值)
}
/** 记录被清空时调用。 */
export function resetBadge(): void {
currentTotal = 0
lastSeenTotal = 0
pendingSeen = false
writeSeen(0)
emit()
}
/** 读取当前未读数。 */
export function useUnreadAlerts(): number {
return useSyncExternalStore(subscribe, getSnapshot, () => 0)
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 通知声效 — 用 Web Audio API 合成, 无需音频文件。
*
* 声效列表 (纯代码合成, 不同频率/波形/节奏):
* - ding: 清脆"叮" (默认, 适合一般提醒)
* - chime: 两音阶风铃 (适合策略信号)
* - alert: 急促警报 (适合重要/异动)
* - soft: 柔和低音 (适合价格提醒)
* - none: 无声
*/
let _audioCtx: AudioContext | null = null
function getCtx(): AudioContext | null {
try {
if (!_audioCtx) {
_audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
}
if (_audioCtx.state === 'suspended') _audioCtx.resume()
return _audioCtx
} catch {
return null
}
}
/** 播放单个音符 */
function playTone(ctx: AudioContext, freq: number, start: number, duration: number, type: OscillatorType = 'sine', gain: number = 0.15) {
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = type
osc.frequency.value = freq
osc.connect(g)
g.connect(ctx.destination)
const t = ctx.currentTime + start
g.gain.setValueAtTime(0, t)
g.gain.linearRampToValueAtTime(gain, t + 0.01)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
osc.start(t)
osc.stop(t + duration + 0.05)
}
const SOUND_PRESETS: Record<string, (ctx: AudioContext) => void> = {
// 清脆"叮" — 一个正弦波短音
ding: (ctx) => {
playTone(ctx, 880, 0, 0.3, 'sine', 0.2)
},
// 两音阶风铃 — 高低两个音
chime: (ctx) => {
playTone(ctx, 660, 0, 0.25, 'sine', 0.15)
playTone(ctx, 990, 0.12, 0.35, 'sine', 0.15)
},
// 急促警报 — 三个快速方波
alert: (ctx) => {
playTone(ctx, 800, 0, 0.1, 'square', 0.12)
playTone(ctx, 800, 0.15, 0.1, 'square', 0.12)
playTone(ctx, 1000, 0.3, 0.15, 'square', 0.12)
},
// 柔和低音 — 低频三角波
soft: (ctx) => {
playTone(ctx, 440, 0, 0.5, 'triangle', 0.15)
playTone(ctx, 330, 0.2, 0.5, 'triangle', 0.12)
},
// 上升音阶 — C-E-G-C 递进 (积极感)
rise: (ctx) => {
playTone(ctx, 523, 0, 0.12, 'sine', 0.18) // C5
playTone(ctx, 659, 0.1, 0.12, 'sine', 0.18) // E5
playTone(ctx, 784, 0.2, 0.12, 'sine', 0.18) // G5
playTone(ctx, 1047, 0.3, 0.3, 'sine', 0.2) // C6
},
// 下降音阶 — C-A-F-D (消极/警示感)
fall: (ctx) => {
playTone(ctx, 523, 0, 0.15, 'sine', 0.18) // C5
playTone(ctx, 440, 0.12, 0.15, 'sine', 0.18) // A4
playTone(ctx, 349, 0.24, 0.15, 'sine', 0.18) // F4
playTone(ctx, 294, 0.36, 0.3, 'sine', 0.18) // D4
},
// 电子提示音 — 锯齿波短促
electronic: (ctx) => {
playTone(ctx, 1200, 0, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1600, 0.06, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1200, 0.12, 0.15, 'sawtooth', 0.1)
},
// 水滴 — 极高频短音, 清脆
drop: (ctx) => {
playTone(ctx, 1800, 0, 0.06, 'sine', 0.15)
playTone(ctx, 2400, 0.04, 0.1, 'sine', 0.12)
},
// 钟声 — 低频持续共鸣
bell: (ctx) => {
playTone(ctx, 523, 0, 0.8, 'sine', 0.15)
playTone(ctx, 784, 0.02, 0.8, 'sine', 0.1) // 泛音
playTone(ctx, 1047, 0.04, 0.6, 'sine', 0.06) // 高泛音
},
// 乒乓 — 两个交替音
pingpong: (ctx) => {
playTone(ctx, 1000, 0, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.1, 0.08, 'sine', 0.15)
playTone(ctx, 1000, 0.2, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.3, 0.15, 'sine', 0.15)
},
// 魔法 — 快速上升扫频感
magic: (ctx) => {
playTone(ctx, 400, 0, 0.05, 'sine', 0.12)
playTone(ctx, 600, 0.04, 0.05, 'sine', 0.12)
playTone(ctx, 800, 0.08, 0.05, 'sine', 0.12)
playTone(ctx, 1100, 0.12, 0.05, 'sine', 0.12)
playTone(ctx, 1400, 0.16, 0.2, 'sine', 0.15)
},
}
/** 播放通知声效 (从 localStorage 读配置) */
export function playNotificationSound() {
try {
const enabled = localStorage.getItem('alert_sound_enabled')
if (enabled === '0') return // 关闭声效
const sound = localStorage.getItem('alert_sound') || 'ding'
if (sound === 'none') return
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch {
// 音频不可用时静默
}
}
/** 声效选项 (供设置页下拉) */
export const SOUND_OPTIONS = [
{ key: 'ding', label: '清脆叮' },
{ key: 'chime', label: '风铃' },
{ key: 'rise', label: '上升音阶' },
{ key: 'fall', label: '下降音阶' },
{ key: 'alert', label: '急促警报' },
{ key: 'electronic', label: '电子音' },
{ key: 'drop', label: '水滴' },
{ key: 'bell', label: '钟声' },
{ key: 'pingpong', label: '乒乓' },
{ key: 'magic', label: '魔法' },
{ key: 'soft', label: '柔和' },
{ key: 'none', label: '无声' },
]
/** 预览声效 (设置页点"试听"用) */
export function previewSound(sound: string) {
try {
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch { /* ignore */ }
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 集中管理所有 React Query key。
*
* - 新增查询只需在此加一行,所有消费方自动引用。
* - SSE invalidation 基于 SSE_INVALIDATE_PREFIXES 列表,新增 key 无需改 useQuoteStream。
*/
// ===== Query Key 工厂 =====
export const QK = {
// 全局 / 共享 (Layout 预取)
capabilities: ['capabilities'] as const,
settings: ['settings'] as const,
endpoints: ['endpoints'] as const,
version: ['version'] as const,
preferences: ['preferences'] as const,
quoteStatus: ['quote-status'] as const,
quoteInterval: ['quote-interval'] as const,
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
indexQuotes: ['index-quotes'] as const,
indexList: ['index-list'] as const,
// Watchlist
watchlist: ['watchlist'] as const,
watchlistQuotes: ['watchlist-quotes'] as const,
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
instrumentSearch: (q: string) => ['instrument-search', q] as const,
// Screener
screener: ['screener'] as const,
screenerStrategies: ['screener-strategies'] as const,
screenerCached: (ext?: string) => ['screener-cached', ext] as const,
screenerKlineBatch: (symbols: string) => ['screener-kline-batch', symbols] as const,
marketSnapshot: ['market-snapshot'] as const,
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
// Backtest
backtestStatus: ['backtest-status'] as const,
// Data / Pipeline
dataStatus: ['data-status'] as const,
pipelineJobs: ['pipeline-jobs'] as const,
pipelineJob: (id: string) => ['pipeline-job', id] as const,
extData: ['ext-data'] as const,
extDataRows: (id: string, date?: string, limit?: number, columns?: string) => ['ext-data-rows', id, date, limit, columns] as const,
analysisMenus: ['analysis-menus'] as const,
analysisMenu: (id: string) => ['analysis-menu', id] as const,
// Kline
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
['kline', symbol, start, end, extColumns ?? ''] as const,
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
klineMinute: (symbol: string, date: string) =>
['kline-minute', symbol, date] as const,
indexDaily: (symbol: string, start: string, end: string) =>
['index-daily', symbol, start, end] as const,
indexMinute: (symbol: string, date: string) =>
['index-minute', symbol, date] as const,
// Schema
extDataSchemaAll: ['ext-data-schema-all'] as const,
tableSchema: (table: string) => ['table-schema', table] as const,
// Custom Signals
customSignals: ['custom-signals'] as const,
customSignalsOptions: ['custom-signals-options'] as const,
// Monitor (监控规则 + 触发记录)
monitorRules: ['monitor-rules'] as const,
monitorRuleOptions: ['monitor-rule-options'] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,
// AI 大盘复盘
reviewReports: ['review-reports'] as const,
// 概念涨幅轮动矩阵
rpsRotation: (days: number) => ['rps-rotation', days] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
// 新增需要 SSE 推送的查询,只需在此加一行
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
'quote-status',
'index-quotes',
'overview-market',
'limit-ladder',
'screener',
] as const
+220
View File
@@ -0,0 +1,220 @@
/**
* 复盘生成状态的全局单例 store —— 脱离 Review 组件生命周期。
*
* 解决的问题:生成中切换到其他页面,Review 组件卸载会丢失 phase/content。
* 本 store 把流式生成的状态提到模块级,组件卸载后流仍在后台继续跑,
* 回到页面订阅即可恢复显示。
*
* 设计:
* - 模块级 state(phase/content/meta/focus),唯一的生成实例
* - AbortController 存模块级 ref,组件卸载不中断流
* - 订阅者列表(notify 机制),Review mount 时订阅、unmount 时退订
*/
import { api } from '@/lib/api'
export type ReviewPhase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
export interface ReviewMeta {
as_of?: string
emotion_score?: number
emotion_label?: string
summary?: string
}
export interface ReviewState {
phase: ReviewPhase
content: string
error: string
meta: ReviewMeta | null
focus: string
}
const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null, focus: '' }
// ===== 模块级单例状态(组件卸载不销毁)=====
let state: ReviewState = { ...INITIAL }
let abortCtrl: AbortController | null = null
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
// 用于区分两条流, 避免互相丢弃事件或重复归档。
let generatingSource: 'manual' | 'sse' | null = null
// ===== 订阅机制 =====
type Listener = () => void
const listeners = new Set<Listener>()
function notify() {
for (const l of listeners) l()
}
export function getReviewState(): ReviewState {
return state
}
export function subscribeReview(listener: Listener): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
// 暴露给组件直接读取最新 meta(用于自动归档,避免闭包取旧值)
export function getReviewMeta(): ReviewMeta | null {
return state.meta
}
/** 是否正在生成(loading 或 streaming) */
export function isReviewGenerating(): boolean {
return state.phase === 'loading' || state.phase === 'streaming'
}
/**
* 启动复盘生成。返回后流在后台独立运行,组件卸载不影响。
* @param asOf 复盘日期
* @param focus 用户追加的复盘关注点
* @param onDone 完成回调(供调用方做自动归档)
*/
export async function startReviewGeneration(
asOf: string | undefined,
focus: string,
onDone?: (fullContent: string, meta: ReviewMeta | null) => void,
): Promise<void> {
// 已在生成中,不重复启动
if (isReviewGenerating()) return
generatingSource = 'manual'
state = { phase: 'loading', content: '', error: '', meta: null, focus }
notify()
abortCtrl = new AbortController()
let buf = ''
let failed = false
let doneMeta: ReviewMeta | null = null
try {
for await (const evt of api.reviewStream(asOf, focus)) {
if (abortCtrl.signal.aborted) break
if (evt.type === 'meta') {
doneMeta = evt
state = { ...state, meta: evt }
notify()
} else if (evt.type === 'delta' && evt.content) {
buf += evt.content
state = { ...state, content: buf, phase: 'streaming' }
notify()
} else if (evt.type === 'error') {
failed = true
state = { ...state, error: evt.message ?? '复盘失败', phase: 'error' }
notify()
return
} else if (evt.type === 'done') {
state = { ...state, phase: 'done' }
notify()
}
}
// 流正常结束但无 done 事件,按 done 处理
if (buf && !failed) {
state = { ...state, phase: 'done' }
notify()
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
if (buf && !failed) {
onDone?.(buf, doneMeta)
}
}
} catch (e: any) {
if (!abortCtrl.signal.aborted) {
state = { ...state, error: e?.message ?? '复盘失败', phase: 'error' }
notify()
}
} finally {
abortCtrl = null
generatingSource = null
}
}
/** 中断当前生成(供"查看历史"等场景主动中断流)。 */
export function abortReviewGeneration(): void {
abortCtrl?.abort()
abortCtrl = null
}
/** 设置当前查看的历史报告(把 store 状态切到 done + 该报告内容)。 */
export function setViewingReport(report: {
content: string
as_of?: string
emotion_score?: number | null
emotion_label?: string
summary?: string
}): void {
abortCtrl?.abort()
abortCtrl = null
state = {
phase: 'done',
content: report.content,
error: '',
meta: {
as_of: report.as_of,
emotion_score: report.emotion_score ?? undefined,
emotion_label: report.emotion_label,
summary: report.summary,
},
focus: state.focus,
}
notify()
}
/** 重置到 idle(清空当前显示)。 */
export function resetReview(): void {
state = { ...INITIAL }
notify()
}
/**
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
*
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
*
* 事件格式与 recap_market_stream 产出一致:
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
*
* 与手动生成的并发:
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
*/
export function feedReviewEvent(evt: any): void {
if (!evt || typeof evt !== 'object') return
const t = evt.type
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
if (generatingSource === 'manual') return
if (t === 'meta') {
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
generatingSource = 'sse'
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
notify()
} else if (t === 'delta' && evt.content) {
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
if (generatingSource !== 'sse') return
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
notify()
} else if (t === 'retry') {
if (generatingSource !== 'sse') return
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
state = { ...state, content: '', phase: 'streaming' }
notify()
} else if (t === 'error') {
if (generatingSource !== 'sse') return
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
notify()
generatingSource = null
} else if (t === 'done') {
if (generatingSource !== 'sse') return
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
state = { ...state, phase: 'done' }
notify()
generatingSource = null
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 策略结果列表自定义列配置。
*
* 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的
* 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam,
mergeColumns,
serializeColumns,
type ColumnConfig,
type ColumnGroup,
type ColumnSource,
type ExtColumnDisplayConfig,
type CandleColumnConfig,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
export { buildExtColumnsParam, mergeColumns, serializeColumns }
export const SCREENER_BUILTIN_COLUMNS: ColumnConfig[] = [
// 固定列
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '标的', visible: true, pinned: true, align: 'left' },
// 策略特有列
{ id: 'builtin:strategies', source: { type: 'builtin', key: 'strategies' }, label: '策略', visible: true, align: 'left' },
{ id: 'builtin:score', source: { type: 'builtin', key: 'score' }, label: '评分', visible: true, align: 'right' },
// 价格
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'right' },
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'right' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'right' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'right' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: false, align: 'right' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: true, align: 'right' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'right' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'right' },
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'right' },
// 均线
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'right' },
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'right' },
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'right' },
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'right' },
// 区间
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'right' },
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'right' },
// 技术指标
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'right' },
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'right' },
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'right' },
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'right' },
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'right' },
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'right' },
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'right' },
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'right' },
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'right' },
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'right' },
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'right' },
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'right' },
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'right' },
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'right' },
// 动量
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum_60d' }, label: '60D 动量', visible: true, align: 'right' },
// 连板
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'left' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
// 财务指标 (与自选页对齐; 当前后端 enriched 未返回这些字段,默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'right' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'right' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'right' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'right' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'right' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'right' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'right' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'right' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'right' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'right' },
]
export const SCREENER_COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'core', label: '核心', icon: '🎯', keys: ['strategies', 'score', 'signals'] },
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
export async function saveScreenerColumnConfig(columns: ColumnConfig[]): Promise<void> {
const saveable = serializeColumns(columns)
storage.screenerResultColumns.set(saveable)
try {
const { api } = await import('@/lib/api')
await api.updateScreenerResultColumns(saveable)
} catch {
// 后端不可用时 localStorage 仍有效
}
}
export async function loadScreenerColumnConfig(): Promise<ColumnConfig[]> {
try {
const { api } = await import('@/lib/api')
const res = await api.screenerResultColumns()
if (res.columns && res.columns.length > 0) {
const merged = mergeColumns(res.columns, SCREENER_BUILTIN_COLUMNS)
storage.screenerResultColumns.set(serializeColumns(merged))
return merged
}
} catch {
// 后端不可用,继续尝试 localStorage
}
const saved = storage.screenerResultColumns.get([]) as ColumnConfig[]
if (saved.length > 0) return mergeColumns(saved, SCREENER_BUILTIN_COLUMNS)
return [...SCREENER_BUILTIN_COLUMNS]
}
+170
View File
@@ -0,0 +1,170 @@
/**
* 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。
*
* 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
*/
export type SignalKind = 'entry' | 'exit' | 'both'
export interface BuiltinSignalDefinition {
id: string
name: string
kind: SignalKind
category: string
description: string
}
/** 内置原子信号清单 (权威展示来源, 两页统一) */
export const BUILTIN_SIGNAL_DEFINITIONS: BuiltinSignalDefinition[] = [
{
id: 'signal_ma_golden_5_20',
name: 'MA5上穿MA20',
kind: 'entry',
category: '均线',
description: '短期均线 MA5 上穿中期均线 MA20,常用于趋势转强确认。',
},
{
id: 'signal_ma_dead_5_20',
name: 'MA5下穿MA20',
kind: 'exit',
category: '均线',
description: '短期均线 MA5 下穿中期均线 MA20,常用于趋势转弱或止盈止损。',
},
{
id: 'signal_ma_golden_20_60',
name: 'MA20上穿MA60',
kind: 'entry',
category: '均线',
description: '中期均线 MA20 上穿长期均线 MA60,偏中线趋势信号。',
},
{
id: 'signal_macd_golden',
name: 'MACD金叉',
kind: 'entry',
category: 'MACD',
description: 'MACD DIF 上穿 DEA,表示动能可能由弱转强。',
},
{
id: 'signal_macd_dead',
name: 'MACD死叉',
kind: 'exit',
category: 'MACD',
description: 'MACD DIF 下穿 DEA,表示动能可能由强转弱。',
},
{
id: 'signal_ma20_breakout',
name: '突破MA20',
kind: 'entry',
category: '趋势',
description: '收盘价向上突破 MA20,常用于趋势突破买点。',
},
{
id: 'signal_ma20_breakdown',
name: '跌破MA20',
kind: 'exit',
category: '趋势',
description: '收盘价向下跌破 MA20,常用于趋势破位卖点。',
},
{
id: 'signal_n_day_high',
name: '60日新高',
kind: 'entry',
category: '趋势',
description: '收盘价创近 60 日新高,表示阶段强势或突破。',
},
{
id: 'signal_n_day_low',
name: '60日新低',
kind: 'exit',
category: '趋势',
description: '收盘价创近 60 日新低,表示阶段弱势或风险释放。',
},
{
id: 'signal_boll_breakout_upper',
name: '突破布林上轨',
kind: 'entry',
category: 'BOLL',
description: '价格突破布林上轨,偏强势突破或加速信号。',
},
{
id: 'signal_boll_breakdown_lower',
name: '跌破布林下轨',
kind: 'exit',
category: 'BOLL',
description: '价格跌破布林下轨,偏弱势破位或超跌风险信号。',
},
{
id: 'signal_volume_surge',
name: '放量',
kind: 'both',
category: '量价',
description: '成交量显著放大,可作为买入确认、卖出确认或告警条件。',
},
{
id: 'signal_limit_up',
name: '涨停',
kind: 'entry',
category: '涨跌停',
description: '收盘封住涨停,用于强势股、连板与市场情绪监控。',
},
{
id: 'signal_limit_down',
name: '跌停',
kind: 'exit',
category: '涨跌停',
description: '收盘触及跌停,用于风险控制与弱势监控。',
},
{
id: 'signal_limit_down_recovery',
name: '跌停翘板',
kind: 'entry',
category: '涨跌停',
description: '盘中触及跌停后回升,常用于短线情绪修复观察。',
},
{
id: 'signal_broken_limit_up',
name: '炸板',
kind: 'exit',
category: '涨跌停',
description: '盘中触及涨停但收盘未封住,用于强转弱或分歧监控。',
},
]
/** 内置原子信号 → 中文标签 */
export const SIGNAL_LABELS: Record<string, string> = BUILTIN_SIGNAL_DEFINITIONS.reduce<Record<string, string>>((acc, sig) => {
acc[sig.id] = sig.name
return acc
}, {})
/** 内置信号 ID 列表 */
export const SIGNAL_OPTIONS = BUILTIN_SIGNAL_DEFINITIONS.map(sig => sig.id)
/** 常用技术指标/字段 → 中文 (阈值条件展示用, 与后端 ENRICHED_COLUMNS 对齐) */
const FIELD_LABELS: Record<string, string> = {
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
change_pct: '涨跌幅', change_amount: '涨跌额', amplitude: '振幅',
turnover_rate: '换手率', volume: '成交量', amount: '成交额',
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
ema5: 'EMA5', ema10: 'EMA10', ema20: 'EMA20',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
boll_upper: '布林上轨', boll_lower: '布林下轨',
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
rsi_6: 'RSI6', rsi_14: 'RSI14', rsi_24: 'RSI24',
vol_ratio_5d: '5日量比', vol_ratio_20d: '20日量比',
vol_ma5: '5日均量', vol_ma10: '10日均量',
high_60d: '60日最高', low_60d: '60日最低',
momentum_5d: '5日动量', momentum_20d: '20日动量', momentum_60d: '60日动量',
atr_14: 'ATR14', annual_vol_20d: '20日年化波动',
consecutive_limit_ups: '连板数', consecutive_limit_downs: '跌停连板',
}
/**
* 信号/字段 ID → 中文显示名。
* 内置信号查 SIGNAL_LABELS; csg_ 前缀查传入的自定义信号名称映射;
* 技术指标查 FIELD_LABELS; 都找不到则原样返回。
*/
export function cnSignal(name: string, customNames?: Record<string, string>): string {
if (customNames && name in customNames) return customNames[name]
return SIGNAL_LABELS[name] ?? FIELD_LABELS[name] ?? name
}
@@ -0,0 +1,82 @@
/**
* 个股日K信息条(StockInfoBar Row 2)的指标自定义配置。
*
* 与自选列表列配置同源:复用 list-columns 的通用列模型与合并/序列化底座,
* 仅做纯 localStorage 同步持久化(无后端双写)。个股预览弹窗与回测成交K线
* Modal 共用同一份配置。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam as buildExtColumnsParamBase,
mergeColumns as mergeColumnsBase,
serializeColumns as serializeColumnsBase,
type ColumnConfig,
type ColumnGroup,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup }
// ===== 内置指标注册表 =====
export const BUILTIN_INFO_FIELDS: ColumnConfig[] = [
// 规模
{ id: 'builtin:market_cap', source: { type: 'builtin', key: 'market_cap' }, label: '市值', visible: true, align: 'left' },
{ id: 'builtin:float_market_cap', source: { type: 'builtin', key: 'float_market_cap' }, label: '流通值', visible: true, align: 'left' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手', visible: true, align: 'left' },
{ id: 'builtin:volume', source: { type: 'builtin', key: 'volume' }, label: '成交量', visible: false, align: 'left' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'left' },
// 行情
{ id: 'builtin:open', source: { type: 'builtin', key: 'open' }, label: '开盘', visible: false, align: 'left' },
{ id: 'builtin:high', source: { type: 'builtin', key: 'high' }, label: '最高', visible: false, align: 'left' },
{ id: 'builtin:low', source: { type: 'builtin', key: 'low' }, label: '最低', visible: false, align: 'left' },
// 财务(数据来自 financials metrics 接口,默认隐藏;pe_ttm/pb 用 close 现算)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'left' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'left' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'left' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE', visible: false, align: 'left' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'left' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'left' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'left' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'left' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'left' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'left' },
]
export const INFO_GROUPS: ColumnGroup[] = [
{ id: 'scale', label: '规模', icon: '🏦', keys: ['market_cap', 'float_market_cap'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amplitude'] },
{ id: 'quote', label: '行情', icon: '📈', keys: ['open', 'high', 'low'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'] },
]
// ===== localStorage 持久化 =====
/** 加载信息条指标配置:localStorage → 默认值,自动补齐新增默认项。 */
export function loadInfoFields(): ColumnConfig[] {
const saved = storage.stockInfoBarFields.get([]) as ColumnConfig[]
if (saved.length === 0) return [...BUILTIN_INFO_FIELDS]
return mergeFields(saved, BUILTIN_INFO_FIELDS)
}
/** 保存信息条指标配置到 localStorage。 */
export function saveInfoFields(columns: ColumnConfig[]): void {
storage.stockInfoBarFields.set(serializeFields(columns))
}
/** 序列化(此处无 pinned/action 列,直接用底座默认实现)。 */
function serializeFields(columns: ColumnConfig[]): ColumnConfig[] {
return serializeColumnsBase(columns)
}
/** 从信息条字段配置中提取 ext 列参数(逗号分隔 config_id.field_name),用于 klineDaily 接口。 */
export function buildInfoExtColumnsParam(columns: ColumnConfig[]): string {
return buildExtColumnsParamBase(columns)
}
/** 合并用户保存的配置与默认配置。 */
function mergeFields(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
// 无固定列,传入空的 pinnedFirstIds 跳过「代码置顶」逻辑
return mergeColumnsBase(saved, defaults, { pinnedFirstIds: [] })
}
+108
View File
@@ -0,0 +1,108 @@
/**
* 股票列表共享逻辑(无 JSX):信号提取、排序取值、不可排序列集合。
*
* 自选页与策略页共用,避免两边各维护一份导致口径漂移(历史上策略页 14 个信号、
* 自选页 13 个,缺一个均线金叉/死叉变体)。
*/
import type { ColumnConfig } from '@/lib/list-columns'
// ===== 信号 =====
export type SignalType = 'bull' | 'bear' | 'neutral'
export interface Signal {
label: string
type: SignalType
}
/** 信号字段定义:[行字段名, 展示标签, 信号类型] */
export const SIGNAL_FIELDS: [string, string, SignalType][] = [
['signal_limit_up', '涨停', 'bull'],
['signal_broken_board_recovery', '反包', 'bull'],
['signal_volume_surge', '放量', 'neutral'],
['signal_ma_golden_5_20', 'MA金叉', 'bull'],
['signal_ma_dead_5_20', 'MA死叉', 'bear'],
['signal_ma_golden_20_60', '均线金叉', 'bull'],
['signal_macd_golden', 'MACD金叉', 'bull'],
['signal_macd_dead', 'MACD死叉', 'bear'],
['signal_ma20_breakout', '站上MA20', 'bull'],
['signal_ma20_breakdown', '跌破MA20', 'bear'],
['signal_n_day_high', '60日新高', 'bull'],
['signal_n_day_low', '60日新低', 'bear'],
['signal_boll_breakout_upper', '布林突破', 'bull'],
['signal_boll_breakdown_lower', '布林下破', 'bear'],
]
/** 从一行数据中提取已命中的信号列表 */
export function getSignals(r: Record<string, any>): Signal[] {
return SIGNAL_FIELDS.filter(([key]) => r[key]).map(([, label, type]) => ({ label, type }))
}
/** 信号类型 → tailwind 颜色类 */
export function signalCls(type: SignalType): string {
if (type === 'bull') return 'text-bull bg-bull/10'
if (type === 'bear') return 'text-bear bg-bear/10'
return 'text-accent bg-accent/10'
}
// ===== 排序 =====
/** 不可参与数值/文本排序的内置列 key(渲染为标签/图表,无单一标量值) */
export const UNSORTABLE_KEYS = new Set(['signals', 'candle', 'strategies'])
/**
* 取一列在某行上的排序标量值。builtin 列按 key 映射到行字段;ext 列走
* `${configId}__${fieldName}`;不可排序列返回 null。
*/
export function getSortValue(r: any, col: ColumnConfig): any {
if (col.source.type === 'ext') {
return r[`${col.source.configId}__${col.source.fieldName}`]
}
const key = col.source.key
switch (key) {
case 'symbol': return r.symbol
case 'price': return r.rt_price ?? r.close
case 'pct': return r.rt_pct ?? r.change_pct
case 'change_amount': return r.change_amount
case 'amplitude': return r.amplitude
case 'turnover': return r.turnover_rate
case 'amount': return r.rt_amount ?? r.amount
case 'float_val': return r.float_shares && (r.rt_price ?? r.close) ? r.float_shares * (r.rt_price ?? r.close) : null
case 'vol_ratio': return r.vol_ratio_5d
case 'annual_vol': return r.annual_vol_20d
case 'ma5': return r.ma5
case 'ma10': return r.ma10
case 'ma20': return r.ma20
case 'ma60': return r.ma60
case 'high_60d': return r.high_60d
case 'low_60d': return r.low_60d
case 'rsi6': return r.rsi_6
case 'rsi14': return r.rsi_14
case 'rsi24': return r.rsi_24
case 'macd_dif': return r.macd_dif
case 'macd_dea': return r.macd_dea
case 'macd_hist': return r.macd_hist
case 'kdj_k': return r.kdj_k
case 'kdj_d': return r.kdj_d
case 'kdj_j': return r.kdj_j
case 'boll_upper': return r.boll_upper
case 'boll_lower': return r.boll_lower
case 'atr14': return r.atr_14
case 'vol_ma5': return r.vol_ma5
case 'vol_ma10': return r.vol_ma10
case 'momentum_5d': return r.momentum_5d
case 'momentum_10d': return r.momentum_10d
case 'momentum_20d': return r.momentum_20d
case 'momentum_30d': return r.momentum_30d
case 'momentum_60d': return r.momentum_60d
case 'limit_ups': return r.consecutive_limit_ups ?? 0
case 'limit_downs': return r.consecutive_limit_downs ?? 0
case 'score': return r.score
default: return null
}
}
// ===== 共享样式 =====
/** 数值单元格统一样式(含 tabular-nums 等宽数字) */
export const NUM_CELL_CLASS = 'num tabular-nums'
@@ -0,0 +1,272 @@
import { useSyncExternalStore } from 'react'
import { api, type PriceLevel, type LevelType } from './api'
/**
* AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
*
* 与财务分析 store 的区别:
* - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
* - meta 额外带 levels(关键价位),供图表回放
* - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
*
* 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
*/
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
export interface ActiveTask {
id: string
symbol: string
name: string
focus: string
phase: Phase
content: string
error: string
meta: {
summary?: string
levels?: Record<LevelType, PriceLevel[]>
close?: number | null
} | null
createdAt: number
savedReportId?: string
doneAt?: number
dismissed?: boolean
}
export interface HistoryReport {
id: string
symbol: string
name: string
focus: string
content: string
summary?: string
close?: number | null
levels?: Record<LevelType, PriceLevel[]>
created_at: string
}
const MAX_ACTIVE = 3
let activeTasks: ActiveTask[] = []
let history: HistoryReport[] = []
let historyLoaded = false
const listeners = new Set<() => void>()
let activeDialogTaskId: string | null = null
let dialogMinimized = false
function emit() { listeners.forEach(fn => fn()) }
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
let _activeSnap: ActiveTask[] = []
let _historySnap: HistoryReport[] = []
interface DialogSnap { taskId: string | null; minimized: boolean }
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
function rebuildSnap() {
_activeSnap = activeTasks
_historySnap = history
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
}
function getActiveSnapshot() { return _activeSnap }
function getHistorySnapshot() { return _historySnap }
function getDialogSnapshot() { return _dialogSnap }
function patchTask(id: string, patch: Partial<ActiveTask>) {
activeTasks = activeTasks.map(t => {
if (t.id !== id) return t
const next = { ...t, ...patch }
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
next.doneAt = Date.now()
}
return next
})
rebuildSnap()
emit()
}
// ===== 查询 hooks =====
export function useBubbleTasks(): ActiveTask[] {
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
const ds = _dialogSnap
return all.filter(t => {
if (t.phase === 'loading' || t.phase === 'streaming') {
return !(ds.taskId === t.id && !ds.minimized)
}
if (t.dismissed) return false
if (!ds.minimized && ds.taskId === t.id) return false
return true
})
}
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
return { reports, loaded: historyLoaded }
}
export function useDialogState() {
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
}
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
const ds = useDialogState()
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
if (!ds.taskId) return { task: null, mode: null }
if (ds.taskId.startsWith('history:')) {
const rid = ds.taskId.slice('history:'.length)
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
}
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
}
// ===== 动作 =====
export async function loadHistory(): Promise<void> {
try {
const res = await api.stockAnalysisReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch { /* 静默 */ }
}
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
return history.find(r => r.symbol === symbol) ?? null
}
/**
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
* 判断依据:created_at 的日期部分 == 本地今天。
* @returns 当天最近一条报告,或 null
*/
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
}
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
if (existing) {
activeDialogTaskId = existing.id
dialogMinimized = false
rebuildSnap()
emit()
return { id: existing.id }
}
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
if (ongoing.length >= MAX_ACTIVE) {
return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
}
const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const task: ActiveTask = {
id, symbol, name, focus,
phase: 'loading', content: '', error: '',
meta: null, createdAt: Date.now(),
}
activeTasks = [...activeTasks, task]
activeDialogTaskId = id
dialogMinimized = false
rebuildSnap()
emit()
runStream(id, symbol, name, focus)
return { id }
}
async function runStream(id: string, symbol: string, _name: string, focus: string) {
try {
let firstDelta = true
for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
const cur = activeTasks.find(t => t.id === id)
if (!cur) return
switch (chunk.type) {
case 'meta':
patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
break
case 'delta':
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
patchTask(id, { content: cur.content + (chunk.content ?? '') })
break
case 'error':
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
return
case 'done':
patchTask(id, { phase: 'done' })
break
}
}
const final = activeTasks.find(t => t.id === id)
if (final && final.phase !== 'error') {
// 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
if (!final.content) {
patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
return
}
try {
const res = await api.stockAnalysisReportSave({
symbol: final.symbol, name: final.name, focus: final.focus,
content: final.content, summary: final.meta?.summary ?? '',
close: final.meta?.close ?? null, levels: final.meta?.levels,
})
if (res.report) {
patchTask(id, { savedReportId: res.report.id })
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
historyLoaded = true
rebuildSnap()
emit()
}
} catch { /* 持久化失败不影响展示 */ }
}
} catch (e: any) {
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: normalizeAiError(msg),
})
}
}
export function openDialog(taskId: string) {
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
}
export function minimizeDialog() {
dialogMinimized = true; rebuildSnap(); emit()
}
export function closeDialog() {
activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
}
export function restoreDialog(taskId: string) {
const t = activeTasks.find(x => x.id === taskId)
if (t && (t.phase === 'done' || t.phase === 'error')) {
patchTask(taskId, { dismissed: true })
}
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
}
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
return startAnalysis(task.symbol, task.name, task.focus)
}
export async function deleteReport(reportId: string): Promise<void> {
try {
await api.stockAnalysisReportDelete(reportId)
history = history.filter(r => r.id !== reportId)
rebuildSnap()
emit()
} catch { /* 静默 */ }
}
export function openHistoryReport(reportId: string) {
activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 集中管理所有 localStorage 持久化。
*
* - key 在此注册,各页面只通过 storage.xxx.get/set 调用。
* - 类型安全,不再散落 try/catch。
*/
function kv<T>(key: string) {
return {
get(fallback: T): T {
try {
const raw = localStorage.getItem(key)
if (raw !== null) return JSON.parse(raw) as T
} catch { /* ignore */ }
return fallback
},
set(val: T) {
try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ }
},
}
}
export const storage = {
/** 查询轮询 / SSE 配置 */
queryConfig: kv<unknown>('tf-stocks-query-config'),
/** 策略池 (screener) */
strategyPool: kv<string[]>('strategy-pool'),
/** 自选列表列配置 */
watchlistColumns: kv<unknown[]>('watchlist_columns'),
/** 个股日K信息条指标配置 */
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
/** 自选列表视图模式 table | card */
watchlistView: kv<string>('watchlist_view'),
/** 自选列表日K蜡烛图显示状态 */
watchlistCandle: kv<boolean>('watchlist_showCandle'),
/** 策略结果列表日K蜡烛图显示状态 */
screenerCandle: kv<boolean>('screener_showCandle'),
/** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
/** Screener 卡片尺寸 */
screenerCardSize: kv<string>('screener-card-size'),
/** 连板梯队板块筛选 */
limitLadderBoard: kv<string[]>('limit-ladder-board-filter'),
/** 连板梯队 ext 字段配置 */
limitLadderExtFields: kv<Record<string, any>>('limit-ladder-ext-fields'),
/** 连板梯队 概念/行业 显示开关 */
limitLadderShowExt: kv<{ concept: boolean; industry: boolean }>('limit-ladder-show-ext'),
/** 连板梯队 涨停/跌停 切换方向 */
limitLadderDirection: kv<'up' | 'down'>('limit-ladder-direction'),
/** 连板梯队 封单显示模式: vol=按成交量(手), amount=按金额(元) */
limitLadderSealMode: kv<'vol' | 'amount'>('limit-ladder-seal-mode'),
/** 策略创建草稿(新建专用) */
strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-draft'),
/** 策略修改草稿(AI修改专用,不影响创建按钮) */
strategyModify: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-modify'),
/** 策略构建器草稿(旧版兼容,逐渐废弃) */
strategyBuilderDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-builder-draft'),
/** 已保存策略的原始规则(策略ID → 规则文本) */
strategyRules: kv<Record<string, string>>('strategy-rules'),
/** 策略回测快捷区间按钮配置 */
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
/** 策略回测最后一次成功结果和参数 */
strategyBacktestLast: kv<{
selectedStrategy: string | null
symbols: string
start: string
end: string
matching: 'close_t' | 'open_t+1'
entryFill: 'close_t' | 'open_t+1'
exitFill: 'close_t' | 'open_t+1'
fees: string
slippage: string
maxPositions: string
maxExposure: string
initialCapital: string
positionSizing: 'equal' | 'score_weight'
mode: 'position' | 'full'
holdingDays: string
params?: Record<string, any>
overrides?: Record<string, any>
result: any
} | null>('strategy-backtest-last'),
/** 概念分析页面字段配置 */
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
/** 行业分析页面字段配置 */
industryAnalysisConfig: kv<Record<string, any>>('industry-analysis-config'),
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
/** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */
dataCardOrder: kv<string[]>('data-card-order'),
} as const
+72
View File
@@ -0,0 +1,72 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
export const FINANCIAL_QK = {
status: ['financials', 'status'],
metrics: (symbol?: string) => ['financials', 'metrics', symbol],
income: (symbol?: string) => ['financials', 'income', symbol],
balanceSheet: (symbol?: string) => ['financials', 'balance-sheet', symbol],
cashFlow: (symbol?: string) => ['financials', 'cash-flow', symbol],
}
export function useFinancialStatus() {
return useQuery({
queryKey: FINANCIAL_QK.status,
queryFn: () => api.financialStatus(),
staleTime: 60_000,
// 同步进行中时每 3s 轮询,及时反映表数变化与同步完成;空闲时不轮询。
refetchInterval: (query) => (query.state.data?.syncing ? 3_000 : false),
})
}
export function useFinancialMetrics(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.metrics(symbol),
queryFn: () => api.financialMetrics(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialIncome(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.income(symbol),
queryFn: () => api.financialIncome(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialBalanceSheet(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.balanceSheet(symbol),
queryFn: () => api.financialBalanceSheet(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialCashFlow(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.cashFlow(symbol),
queryFn: () => api.financialCashFlow(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialSync() {
const qc = useQueryClient()
return useMutation({
mutationFn: (table: string) => api.financialSync(table),
// 点击瞬间立即刷新 status: 让后端 is_syncing=True 马上反映到 UI,
// 避免 mutation 阻塞(全量同步需数分钟)期间界面无变化。
onMutate: () => {
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
qc.invalidateQueries({ queryKey: ['financials'] })
},
})
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useState } from 'react'
/**
* 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
*
* 两个分析页(财务 / 个股)各自独立记忆,key 区分:
* - financials: 最后查看的财务分析个股
* - stock-analysis: 最后查看的个股分析个股
*
* 用法:
* const { last, remember } = useLastStock('stock-analysis')
* remember('000001.SZ', '平安银行') // 选中股票时调用
* <LastStockChip stock={last} ... /> // 渲染在 PageHeader 右侧
*/
export interface StockRef { symbol: string; name: string }
const PREFIX = 'last_stock:'
export function useLastStock(scope: string) {
const [last, setLast] = useState<StockRef | null>(() => load(scope))
const remember = useCallback((symbol: string, name: string) => {
const ref = { symbol, name }
setLast(ref)
save(scope, ref)
}, [scope])
const clear = useCallback(() => {
setLast(null)
save(scope, null)
}, [scope])
return { last, remember, clear }
}
function load(scope: string): StockRef | null {
try {
const v = localStorage.getItem(PREFIX + scope)
if (!v) return null
const p = JSON.parse(v)
if (p && typeof p.symbol === 'string' && typeof p.name === 'string') return p
} catch { /* ignore */ }
return null
}
function save(scope: string, ref: StockRef | null) {
try {
if (ref) localStorage.setItem(PREFIX + scope, JSON.stringify(ref))
else localStorage.removeItem(PREFIX + scope)
} catch { /* ignore */ }
}
+40
View File
@@ -0,0 +1,40 @@
/**
* SSE 配置 — 运行时参数。
*
* 目前只保留 SSE 重连延迟,其他数据刷新全部走 SSE invalidation。
* 存储在 localStorage。
*/
import { storage } from '@/lib/storage'
// ===== 配置结构 =====
export interface QueryConfig {
/** SSE 配置 */
sse: {
reconnectDelay: number
}
}
export const DEFAULT_QUERY_CONFIG: QueryConfig = {
sse: {
reconnectDelay: 5_000,
},
}
// ===== localStorage 持久化 =====
function loadConfig(): QueryConfig {
const raw = storage.queryConfig.get(null) as QueryConfig | null
if (!raw) return DEFAULT_QUERY_CONFIG
return {
sse: { ...DEFAULT_QUERY_CONFIG.sse, ...raw.sse },
}
}
/**
* 轻量版:只读取当前配置。
* 供 useQuoteStream 使用。
*/
export function getQueryConfig(): QueryConfig {
return loadConfig()
}
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import { feedReviewEvent } from './reviewStore'
import type { StrategyAlertEvent } from './api'
/**
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
*
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
*
* 应在顶层 Layout 中调用一次。
*/
export function useQuoteStream(
enabled: boolean,
sseRefreshPages: Record<string, boolean> | undefined,
onAlert?: (alerts: StrategyAlertEvent[]) => void,
) {
const qc = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const retryRef = useRef<ReturnType<typeof setTimeout>>()
const pagesRef = useRef(sseRefreshPages)
pagesRef.current = sseRefreshPages
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
// depth 系统接管通知: 单独处理, 不走 strategy 回调
const depthAlerts = alerts.filter(a => a.source === 'depth')
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
for (const a of depthAlerts.slice(0, 1)) {
toast(a.message, 'success')
}
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
if (strategyAlerts.length > 0) {
// 有 onAlert 回调时走回调, 否则弹 AlertToast
if (onAlert) {
onAlert(strategyAlerts)
}
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
pushAlertToasts(strategyAlerts as any)
}
}, [onAlert])
const enabledRef = useRef(enabled)
enabledRef.current = enabled
useEffect(() => {
// SSE 始终连接 — 监控告警不依赖实时行情开关
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
const connect = () => {
const es = new EventSource('/api/intraday/stream')
esRef.current = es
// sse-starlette ping 心跳走 SSE comment,不会到达这里
es.addEventListener('quotes_updated', () => {
// 实时行情未开启时不处理行情刷新
if (!enabledRef.current) return
// 根据用户配置过滤 invalidation
const pages = pagesRef.current
if (pages) {
// 只 invalidate 开启的页面对应的 prefix
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
// 'quote-status' 始终刷新 (全局状态)
if (p === 'quote-status') return true
return pages[p] !== false
})
qc.invalidateQueries({
predicate: (query) =>
activePrefixes.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
} else {
// 无配置时全部刷新 (向后兼容)
qc.invalidateQueries({
predicate: (query) =>
SSE_INVALIDATE_PREFIXES.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
}
})
es.addEventListener('depth_updated', () => {
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
qc.invalidateQueries({ queryKey: ['overview-market'] })
})
es.addEventListener('strategy_alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
const alerts: StrategyAlertEvent[] = data.alerts || []
if (alerts.length > 0) {
handleAlerts(alerts)
// 实时刷新触发记录列表 + 监控中心徽标
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
}
} catch {
// 忽略解析错误
}
})
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
es.addEventListener('review_progress', (e: MessageEvent) => {
try {
const evt = JSON.parse(e.data)
feedReviewEvent(evt)
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
if (evt.type === 'done') {
qc.invalidateQueries({ queryKey: QK.reviewReports })
}
} catch {
// 忽略解析错误
}
})
es.onerror = () => {
es.close()
esRef.current = null
const delay = getQueryConfig().sse.reconnectDelay
retryRef.current = setTimeout(connect, delay)
}
}
connect()
return () => {
clearTimeout(retryRef.current)
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
}
}, [qc, handleAlerts])
}
+14
View File
@@ -0,0 +1,14 @@
/**
* 订阅 reviewStore 的 React hook。
*
* mount 时订阅,store 变化触发 re-render;unmount 时退订(但 store 流继续跑)。
* 用 useSyncExternalStore 保证与 React 18 并发模式兼容。
*/
import { useSyncExternalStore } from 'react'
import {
getReviewState, subscribeReview, type ReviewState,
} from '@/lib/reviewStore'
export function useReviewState(): ReviewState {
return useSyncExternalStore(subscribeReview, getReviewState, getReviewState)
}
@@ -0,0 +1,42 @@
/**
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
/** 切换实时行情 — Layout / Data 共用 */
export function useToggleRealtimeQuotes() {
const qc = useQueryClient()
return useMutation({
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 更新行情轮询间隔 — Layout / Data 共用 */
export function useUpdateQuoteInterval() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: number) => api.updateQuoteInterval(v),
onSuccess: (data) => {
qc.setQueryData(QK.quoteInterval, data)
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 批量添加自选 — Screener / Intraday 共用 */
export function useWatchlistBatchAdd() {
const qc = useQueryClient()
return useMutation({
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
}
@@ -0,0 +1,81 @@
/**
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
*
* 实时数据走 SSE invalidation,无需前端轮询。
* 只有管线进度等非 SSE 数据才用 refetchInterval。
*/
import { useQuery } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
// ===== 全局共享 =====
/** 能力检测 — Layout / Data / Keys 共用 */
export function useCapabilities() {
return useQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
}
/** 设置状态 — Layout / Data / Keys 共用 */
export function useSettings() {
return useQuery({
queryKey: QK.settings,
queryFn: api.settings,
})
}
/** 用户偏好 — Layout / Data / Intraday 共用 */
export function usePreferences() {
return useQuery({
queryKey: QK.preferences,
queryFn: api.preferences,
})
}
/** 行情状态 — SSE quotes_updated 自动刷新。
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
*/
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
return useQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
enabled: opts?.enabled ?? true,
refetchInterval: opts?.poll
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
: false,
})
}
/** 行情间隔 — Layout / Data 共用 */
export function useQuoteInterval() {
return useQuery({
queryKey: QK.quoteInterval,
queryFn: api.quoteInterval,
})
}
/** 版本号 — Layout 专用 */
export function useVersion() {
return useQuery({
queryKey: QK.version,
queryFn: api.version,
staleTime: Infinity,
})
}
/** 数据状态 — Data / Screener 共用 */
export function useDataStatus(opts?: { staleTime?: number; refetchInterval?: number | false }) {
return useQuery({
queryKey: QK.dataStatus,
queryFn: api.dataStatus,
staleTime: opts?.staleTime,
refetchInterval: opts?.refetchInterval,
})
}
+36
View File
@@ -0,0 +1,36 @@
import { useState, useCallback, useEffect } from 'react'
import { storage } from '@/lib/storage'
export function useStrategyPool() {
const [pool, setPool] = useState<string[]>(() => storage.strategyPool.get([]))
// 同步写入 localStorage
useEffect(() => { storage.strategyPool.set(pool) }, [pool])
const addToPool = useCallback((id: string) => {
setPool(prev => prev.includes(id) ? prev : [...prev, id])
}, [])
const removeFromPool = useCallback((id: string) => {
setPool(prev => prev.filter(x => x !== id))
}, [])
const reorderPool = useCallback((newOrder: string[]) => {
setPool(newOrder)
}, [])
// 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。
// 仅当确实有失效项时才更新,避免无谓重渲染。
const prune = useCallback((validIds: Iterable<string>) => {
const validSet = validIds instanceof Set ? validIds : new Set(validIds)
setPool(prev => {
if (prev.length === 0) return prev
const next = prev.filter(id => validSet.has(id))
return next.length === prev.length ? prev : next
})
}, [])
const isInPool = useCallback((id: string) => pool.includes(id), [pool])
return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
}
+166
View File
@@ -0,0 +1,166 @@
/**
* 自选列表自定义列配置。
*
* 自选页只保留业务内置列、分组和偏好持久化;通用列模型/合并/扩展列参数
* 来自 list-columns,策略页等其它股票列表可复用同一底座。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam as buildExtColumnsParamBase,
createExtColumn as createExtColumnBase,
mergeColumns as mergeColumnsBase,
serializeColumns as serializeColumnsBase,
type ColumnConfig,
type ColumnGroup,
type ColumnSource,
type ExtColumnDisplayConfig,
type CandleColumnConfig,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
// ===== 内置列注册表(与当前硬编码一一对应) =====
export const BUILTIN_COLUMNS: ColumnConfig[] = [
// 固定列
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
// 价格
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
// 均线
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
// 区间
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
// 技术指标
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
// 动量
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
// 连板
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
]
export const COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
// 操作列(始终显示,不参与自定义)
export const ACTION_COLUMN_ID = 'builtin:action'
// ===== localStorage 持久化 =====
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action */
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
}
/** 序列化并保存到后端 + localStorage */
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
const saveable = serializeColumns(columns)
// 同时写 localStorage(即时)和后端(持久化)
storage.watchlistColumns.set(saveable)
try {
const { api } = await import('@/lib/api')
await api.updateWatchlistColumns(saveable)
} catch {
// 后端不可用时 localStorage 仍有效
}
}
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
// 1. 尝试从后端加载
try {
const { api } = await import('@/lib/api')
const res = await api.watchlistColumns()
if (res.columns && res.columns.length > 0) {
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
// 同步到 localStorage
storage.watchlistColumns.set(serializeColumns(merged))
return merged
}
} catch {
// 后端不可用,继续尝试 localStorage
}
// 2. 尝试从 localStorage 加载
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
if (saved.length > 0) {
return mergeColumns(saved, BUILTIN_COLUMNS)
}
// 3. 默认值
return [...BUILTIN_COLUMNS]
}
/** 合并用户保存的列与默认列 */
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
}
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
return buildExtColumnsParamBase(columns)
}
/** 根据 ext schema 数据创建 ext 列配置 */
export function createExtColumn(
configId: string,
configLabel: string,
fieldName: string,
fieldLabel?: string,
): ColumnConfig {
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { router } from './router'
import './index.css'
// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。
// api.ts 的 request() 已对 401 静默 (不弹 toast), 这里统一负责跳转。
// 排除 /login 自身的请求, 避免登录页请求失败又跳登录形成死循环。
const _redirectToLogin = (() => {
let redirecting = false
return (err: unknown) => {
if (redirecting) return
if (!(err instanceof Error)) return
const msg = err.message || ''
// 401 (未登录/会话过期) → 跳登录页
// 403 未初始化 (面板未设密码, 公网访问) → 也跳登录页(显示设密码提示)
const is401 = msg.includes('未登录') || msg.includes('会话已过期') || msg.includes('401')
const isNotInit = msg.includes('尚未初始化访问密码') || msg.includes('NOT_INITIALIZED')
if (!is401 && !isNotInit) return
// 已在登录页则不跳(避免死循环)
if (window.location.pathname === '/login') return
redirecting = true
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
window.location.href = `/login?redirect=${redirect}`
}
})()
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (err) => _redirectToLogin(err),
}),
defaultOptions: {
queries: {
staleTime: 5_000, // 5s 内复用,与 §4.2 Repository 不变量一致
refetchOnWindowFocus: false,
},
mutations: {
onError: (err) => _redirectToLogin(err),
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
)

Some files were not shown because too many files have changed in this diff Show More