From 5b4b1d6e24e060f6b0cd1913976d82ac094b8df4 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Tue, 18 Aug 2026 15:51:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(watchlist):=20=E8=87=AA=E9=80=89=E5=88=86?= =?UTF-8?q?=E7=BB=84=E7=AD=89=E6=9D=83=E5=B9=B3=E5=9D=87=E6=B6=A8=E8=B7=8C?= =?UTF-8?q?=E5=B9=85=20=E2=80=94=20=E5=88=86=E7=BB=84=20tab=20=E4=B8=8E?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E5=AD=90=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 口径: 组内成员取「实时优先(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只)」正常。 --- frontend/src/components/Layout.tsx | 38 ++++++++++- frontend/src/components/WatchlistGroups.tsx | 21 ++++++ frontend/src/lib/watchlistGroupStats.ts | 72 +++++++++++++++++++++ frontend/src/pages/Watchlist.tsx | 10 +++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/watchlistGroupStats.ts diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d7a9268..f247ee8 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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() { > 全部 + {(() => { + const info = navGroupPcts['all'] + return info && info.pct != null ? ( + + {formatGroupPct(info.pct)} + + ) : null + })()} {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 ( {group.name} + {pctInfo && pctInfo.pct != null && ( + + {formatGroupPct(pctInfo.pct)} + + )} ) })} diff --git a/frontend/src/components/WatchlistGroups.tsx b/frontend/src/components/WatchlistGroups.tsx index 3c38086..93cc84f 100644 --- a/frontend/src/components/WatchlistGroups.tsx +++ b/frontend/src/components/WatchlistGroups.tsx @@ -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 selected: WatchlistGroupFilter total: number + /** 分组等权平均涨跌幅 (key: 'all' | 'ungrouped' | 分组id); 缺省不显示 */ + pcts?: GroupPctMap onSelect: (group: WatchlistGroupFilter) => void onCreate: (name: string, color: WatchlistGroupColor) => Promise onRename: (groupId: string, name: string, color: WatchlistGroupColor) => Promise @@ -31,6 +39,7 @@ export function WatchlistGroupBar({ counts, selected, total, + pcts, onSelect, onCreate, onRename, @@ -74,6 +83,18 @@ export function WatchlistGroupBar({ {tab.count} + {pcts && (() => { + const info = pcts[tab.id] + if (!info || info.pct == null || info.sampled === 0) return null + return ( + + {formatGroupPct(info.pct)} + + ) + })()} ) })} diff --git a/frontend/src/lib/watchlistGroupStats.ts b/frontend/src/lib/watchlistGroupStats.ts new file mode 100644 index 0000000..b7e1ee0 --- /dev/null +++ b/frontend/src/lib/watchlistGroupStats.ts @@ -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 + +export function computeGroupPcts( + entries: { symbol: string; group_id?: string | null }[], + rowsBySymbol: Map, +): GroupPctMap { + const buckets = new Map() + 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}只)` +} diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index 41643a9..6df9502 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -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 = { 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)}