mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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}只)`
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user