feat(watchlist): 自选分组等权平均涨跌幅 — 分组 tab 与侧栏子菜单

口径: 组内成员取「实时优先(rt_pct)、收盘兜底(change_pct)」的涨跌幅
(与自选表格同源, 小数单位), 等权平均为分组涨跌幅 — 自选是个人组合
视角, 等权透明且无需市值数据。涨跌家数在悬停明细中给出。

- 新增 watchlistGroupStats: computeGroupPcts 纯函数 + 格式化/配色
  (text-bull 红涨 / text-bear 绿跌, 沿用 A 股惯例)
- 自选页分组 tab: 数量后追加彩色涨跌幅徽标(全部/未分组/自定义组)
- 侧边栏分组子菜单(开启 watchlist_groups_in_nav 时): 复用与自选页
  相同的 watchlist/enriched 查询缓存(盘中随 SSE 刷新), 组名右侧显示

验证: tsc 通过; 浏览器实测分组 tab 与侧栏数值逐组与 API 手算一致
(半导体 -1.55% / 商业航天 -0.79% / 反包备选 +4.34% / 全部 +0.59%),
悬停明细「等权平均 +0.59% · 上涨11 下跌11 平0 (共22只)」正常。
This commit is contained in:
shy3130
2026-08-18 15:51:11 +08:00
parent 0ee3aa8f1f
commit 5b4b1d6e24
4 changed files with 140 additions and 1 deletions
+37 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, Suspense } from 'react'
import { useEffect, useMemo, useRef, useState, Suspense } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
@@ -57,6 +57,7 @@ import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { cn } from '@/lib/cn'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { computeGroupPcts, formatGroupPct, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats'
import { toggleTheme, useTheme } from '@/lib/theme'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
@@ -301,6 +302,27 @@ export function Layout() {
staleTime: 60_000,
})
const watchlistGroups = watchlistGroupsData?.groups ?? []
// 分组等权平均涨跌幅 — 复用 watchlist/enriched 查询缓存(与自选页同 key,
// 盘中随 SSE 刷新); 侧栏开启分组时才拉取
const { data: navWatchlist } = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
enabled: groupsInNav,
staleTime: 60_000,
})
const { data: navEnriched } = useQuery({
queryKey: QK.watchlistEnriched(undefined),
queryFn: () => api.watchlistEnriched(),
enabled: groupsInNav,
staleTime: 60_000,
})
const navGroupPcts = useMemo(
() => computeGroupPcts(
navWatchlist?.symbols ?? [],
new Map((navEnriched?.rows ?? []).map((r: any) => [r.symbol as string, r])),
),
[navWatchlist, navEnriched],
)
// 自选二级菜单展开状态 — 默认当前在自选页时展开
const [watchlistNavExpanded, setWatchlistNavExpanded] = useState(location.pathname === '/watchlist')
@@ -650,11 +672,20 @@ export function Layout() {
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
<span></span>
{(() => {
const info = navGroupPcts['all']
return info && info.pct != null ? (
<span className={`ml-auto font-mono text-[10px] tabular-nums ${groupPctColor(info.pct)}`} title={groupPctTitle(info)}>
{formatGroupPct(info.pct)}
</span>
) : null
})()}
</NavLink>
{watchlistGroups.map(group => {
const color = resolveWatchlistGroupColor(group.color)
const groupPath = `/watchlist?group=${group.id}`
const isGroupActive = location.pathname === '/watchlist' && location.search === `?group=${group.id}`
const pctInfo = navGroupPcts[group.id]
return (
<NavLink
key={group.id}
@@ -668,6 +699,11 @@ export function Layout() {
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${color.dot}`} />
<span className="truncate">{group.name}</span>
{pctInfo && pctInfo.pct != null && (
<span className={`ml-auto font-mono text-[10px] tabular-nums ${groupPctColor(pctInfo.pct)}`} title={groupPctTitle(pctInfo)}>
{formatGroupPct(pctInfo.pct)}
</span>
)}
</NavLink>
)
})}
@@ -6,6 +6,12 @@ import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
import type { WatchlistGroup, WatchlistGroupColor } from '@/lib/api'
import {
formatGroupPct,
groupPctColor,
groupPctTitle,
type GroupPctMap,
} from '@/lib/watchlistGroupStats'
import {
DEFAULT_WATCHLIST_GROUP_COLOR,
WATCHLIST_GROUP_COLORS,
@@ -19,6 +25,8 @@ interface GroupBarProps {
counts: Record<string, number>
selected: WatchlistGroupFilter
total: number
/** 分组等权平均涨跌幅 (key: 'all' | 'ungrouped' | 分组id); 缺省不显示 */
pcts?: GroupPctMap
onSelect: (group: WatchlistGroupFilter) => void
onCreate: (name: string, color: WatchlistGroupColor) => Promise<void>
onRename: (groupId: string, name: string, color: WatchlistGroupColor) => Promise<void>
@@ -31,6 +39,7 @@ export function WatchlistGroupBar({
counts,
selected,
total,
pcts,
onSelect,
onCreate,
onRename,
@@ -74,6 +83,18 @@ export function WatchlistGroupBar({
<span className={`font-mono text-[10px] tabular-nums ${active && !color ? 'text-accent/80' : 'text-muted'}`}>
{tab.count}
</span>
{pcts && (() => {
const info = pcts[tab.id]
if (!info || info.pct == null || info.sampled === 0) return null
return (
<span
className={`font-mono text-[10px] tabular-nums ${groupPctColor(info.pct)}`}
title={groupPctTitle(info)}
>
{formatGroupPct(info.pct)}
</span>
)
})()}
</button>
)
})}
+72
View File
@@ -0,0 +1,72 @@
/**
* 自选分组涨跌幅 — 等权平均口径。
*
* 组内每只成员取「实时优先、收盘兜底」的涨跌幅(与自选表格展示同源:
* rt_pct ?? change_pct, 百分数单位), 算术平均即分组涨跌幅。等权最贴合
* 自选的"个人组合"视角 — 透明且无需市值数据。
*/
export interface GroupPctInfo {
/** 等权平均涨跌幅(小数, 0.0123 = +1.23%, 与 enriched change_pct 同单位); 无有效样本为 null */
pct: number | null
up: number
down: number
flat: number
/** 参与统计的样本数(涨跌幅非空的成员) */
sampled: number
}
/** key: 'all' | 'ungrouped' | 分组 id */
export type GroupPctMap = Record<string, GroupPctInfo>
export function computeGroupPcts(
entries: { symbol: string; group_id?: string | null }[],
rowsBySymbol: Map<string, { rt_pct?: number | null; change_pct?: number | null }>,
): GroupPctMap {
const buckets = new Map<string, { pcts: number[]; up: number; down: number; flat: number }>()
const add = (key: string, pct: number | null | undefined) => {
if (pct == null || !Number.isFinite(pct)) return
const b = buckets.get(key) ?? { pcts: [], up: 0, down: 0, flat: 0 }
b.pcts.push(pct)
if (pct > 0) b.up++
else if (pct < 0) b.down++
else b.flat++
buckets.set(key, b)
}
for (const entry of entries) {
const row = rowsBySymbol.get(entry.symbol)
const pct = row ? row.rt_pct ?? row.change_pct : null
add('all', pct)
add(entry.group_id ?? 'ungrouped', pct)
}
const out: GroupPctMap = {}
for (const [key, b] of buckets) {
out[key] = {
pct: b.pcts.length ? b.pcts.reduce((a, c) => a + c, 0) / b.pcts.length : null,
up: b.up,
down: b.down,
flat: b.flat,
sampled: b.pcts.length,
}
}
return out
}
/** 涨跌幅文本: +1.23% / -0.50% / 0.00% / — (无样本)。入参为小数(0.0123) */
export function formatGroupPct(pct: number | null): string {
if (pct == null) return '—'
const v = pct * 100
return `${v > 0 ? '+' : ''}${v.toFixed(2)}%`
}
/** 涨跌色 (A 股惯例红涨绿跌) */
export function groupPctColor(pct: number | null): string {
if (pct == null || pct === 0) return 'text-muted'
return pct > 0 ? 'text-bull' : 'text-bear'
}
/** 悬停明细: 等权平均 +1.23% · 上涨12 下跌5 平1 */
export function groupPctTitle(info: GroupPctInfo | undefined): string {
if (!info || info.pct == null) return '暂无涨跌幅数据'
return `等权平均 ${formatGroupPct(info.pct)} · 上涨${info.up} 下跌${info.down}${info.flat} (共${info.sampled}只)`
}
+10
View File
@@ -8,6 +8,7 @@ import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type Watc
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass, formatExtNumber } from '@/lib/format'
import { computeGroupPcts } from '@/lib/watchlistGroupStats'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
@@ -938,6 +939,14 @@ export function Watchlist() {
() => new Map(listEntries.map(entry => [entry.symbol, entry.group_id ?? null])),
[listEntries],
)
// 分组等权平均涨跌幅 (实时优先 rt_pct, 收盘兜底 change_pct; 与表格同源)
const groupPcts = useMemo(
() => computeGroupPcts(
listEntries,
new Map(rows.map((r: any) => [r.symbol as string, r])),
),
[listEntries, rows],
)
const groupCounts = useMemo(() => {
const counts: Record<string, number> = { ungrouped: 0 }
for (const entry of listEntries) {
@@ -1263,6 +1272,7 @@ export function Watchlist() {
counts={groupCounts}
selected={selectedGroup}
total={allSymbols.length}
pcts={groupPcts}
onSelect={setSelectedGroup}
onCreate={(name, color) => createGroup.mutateAsync({ name, color }).then(() => undefined)}
onRename={(groupId, name, color) => renameGroup.mutateAsync({ groupId, name, color }).then(() => undefined)}