Files
tick-stock-panel/frontend/src/components/WatchlistGroupStatsBar.tsx
T
shy3130 a8a573a834 feat(watchlist): 分组卡片/统计条完善 + 分组排序 + 监控自选导入 + 前端插槽
分组卡片视图 (WatchlistGroupCards):
- 新增整页卡片总览: 组内按涨跌幅降序, 默认前 N 条可展开
- 头部重排: 名称+指标数值居左, 总数+N涨/N跌居右 (红涨绿跌着色)
- 成员行: 创/科/北板块标识移至名称后; 序号列可开关
- 卡片设置 (GroupStatsSettings): 指标/排序/前N条/头部颜色/序号, 与统计条共享持久化

分组统计条 (WatchlistGroupStatsBar):
- 中轴分叉条形图概览, 指标与排序可配置
- 行尾 N涨/N跌 红绿着色

分组管理:
- 后端分组重排端点 + service + 测试 (9 passed)
- 管理弹窗上移/下移 + 拖拽排序, 顺序贯通标签栏/统计条/卡片/侧栏

自选交互:
- 修复 WatchlistAddMenu 内部滚动误关闭 (capture 目标包含判断)
- 监控规则编辑器: 指定标的支持从自选/自选分组批量导入 (去重合并)

前端扩展插槽:
- 新增 stock-preview.footer / watchlist.toolbar 插槽及 context 契约, 更新二开文档
2026-08-19 22:14:10 +08:00

162 lines
6.0 KiB
TypeScript

import type { WatchlistGroup } from '@/lib/api'
import { fmtPct } from '@/lib/format'
import {
groupPctColor,
groupMetricTitle,
groupMetricValue,
GROUP_METRICS,
sortGroupKeys,
type GroupPctMap,
type GroupStatsConfig,
type GroupStatsConfigPatch,
} from '@/lib/watchlistGroupStats'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { GroupStatsSettings } from '@/components/GroupStatsSettings'
import type { WatchlistGroupFilter } from '@/components/WatchlistGroups'
/**
* 自选「分组统计条」— 页面顶部的图形化分组涨跌概览。
*
* 每个分组一行: 中轴分叉条形图直观对比各组强弱 (红=强 向右, 绿=弱 向左,
* 长度按各组最大绝对值归一化)。指标与排序可配置 (与分组卡片视图共享,
* 由自选页统一持有并持久化):
* - 指标: 等权平均 / 中位数 / 上涨占比(50%强弱轴) / 组内最强 / 组内最弱
* - 排序: 分组定义顺序 / 按指标降序 / 升序
* 数据来自自选页既有的分组涨跌统计 (groupPcts), 零额外请求; 点击行钻取分组列表。
*/
interface Row {
key: string
name: string
dot: string
text: string
count: number
selected: boolean
}
export function WatchlistGroupStatsBar({
groups,
counts,
pcts,
selected,
onSelect,
config,
onConfigChange,
}: {
groups: WatchlistGroup[]
counts: Record<string, number>
pcts: GroupPctMap
selected?: WatchlistGroupFilter
onSelect: (group: WatchlistGroupFilter) => void
config: GroupStatsConfig
onConfigChange: (patch: GroupStatsConfigPatch) => void
}) {
const rows: Row[] = groups.map(group => {
const color = resolveWatchlistGroupColor(group.color)
return {
key: group.id,
name: group.name,
dot: color.dot,
text: color.text,
count: counts[group.id] ?? 0,
selected: selected === group.id,
}
})
if ((counts.ungrouped ?? 0) > 0) {
rows.push({
key: 'ungrouped',
name: '未分组',
dot: 'bg-muted/60',
text: 'text-foreground',
count: counts.ungrouped ?? 0,
selected: selected === 'ungrouped',
})
}
const metricLabel = GROUP_METRICS.find(m => m.id === config.metric)?.label ?? ''
const valueOf = (key: string) => groupMetricValue(pcts[key], config.metric)
if (rows.length === 0) return null
const ordered = sortGroupKeys(rows, r => r.key, pcts, config)
// 条长归一化基准: 上涨占比以 0.5 强弱轴的偏离量为幅值, 其余指标取绝对值
const magnitude = (v: number) => config.metric === 'up_ratio' ? Math.abs(v - 0.5) : Math.abs(v)
const maxMag = Math.max(...ordered.reduce<number[]>((acc, r) => {
const v = valueOf(r.key)
if (v != null) acc.push(magnitude(v))
return acc
}, [0.0001]))
return (
<div className="border-b border-border bg-surface/40 px-5 py-2">
<div className="mb-1 flex items-center justify-between">
<div className="text-[10px] uppercase tracking-wider text-muted">分组涨跌 · {metricLabel}</div>
<GroupStatsSettings config={config} onChange={onConfigChange} />
</div>
<div className="flex flex-col gap-px">
{ordered.map(r => {
const info = pcts[r.key]
const v = valueOf(r.key)
// 条形方向: 上涨占比以 0.5 为轴, 其余以 0 为轴; 幅值按组间最大值归一化
const signed = config.metric === 'up_ratio' ? (v == null ? null : v - 0.5) : v
const half = v == null ? 0 : Math.min(50, (magnitude(v) / maxMag) * 50)
const isUp = signed != null && signed > 0
const isDown = signed != null && signed < 0
const label = v == null
? '—'
: config.metric === 'up_ratio'
? `${(v * 100).toFixed(0)}%`
: fmtPct(v)
return (
<button
key={r.key}
type="button"
onClick={() => onSelect(r.key)}
title={groupMetricTitle(info, config.metric)}
className={`grid grid-cols-[minmax(72px,auto)_1fr_auto_auto] items-center gap-2.5 rounded px-1.5 py-1 text-left transition-colors ${
r.selected ? 'bg-accent/10' : 'hover:bg-elevated/50'
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${r.dot}`} />
<span className={`truncate text-xs ${r.selected ? 'text-foreground' : r.text}`}>{r.name}</span>
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted">{r.count}</span>
</span>
<span className="relative h-3.5 rounded bg-elevated/40">
<span className="absolute inset-y-0 left-1/2 w-px bg-border" />
{isUp && (
<span
className="absolute inset-y-[3px] left-1/2 rounded-r bg-bull/75 transition-[width] duration-300"
style={{ width: `${half}%` }}
/>
)}
{isDown && (
<span
className="absolute inset-y-[3px] right-1/2 rounded-l bg-bear/75 transition-[width] duration-300"
style={{ width: `${half}%` }}
/>
)}
</span>
<span className={`w-16 text-right font-mono text-xs font-semibold tabular-nums ${groupPctColor(signed)}`}>
{label}
</span>
<span className="w-[72px] shrink-0 text-right text-[10px] tabular-nums">
{info && info.sampled > 0 ? (
<>
<span className="text-bull">{info.up}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{info.down}</span>
</>
) : (
<span className="text-muted"></span>
)}
</span>
</button>
)
})}
</div>
</div>
)
}