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)}