feat(watchlist): 分组卡片/统计条完善 + 分组排序 + 监控自选导入 + 前端插槽

分组卡片视图 (WatchlistGroupCards):
- 新增整页卡片总览: 组内按涨跌幅降序, 默认前 N 条可展开
- 头部重排: 名称+指标数值居左, 总数+N涨/N跌居右 (红涨绿跌着色)
- 成员行: 创/科/北板块标识移至名称后; 序号列可开关
- 卡片设置 (GroupStatsSettings): 指标/排序/前N条/头部颜色/序号, 与统计条共享持久化

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

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

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

前端扩展插槽:
- 新增 stock-preview.footer / watchlist.toolbar 插槽及 context 契约, 更新二开文档
This commit is contained in:
shy3130
2026-08-19 22:14:10 +08:00
parent 610d52931b
commit a8a573a834
22 changed files with 1259 additions and 31 deletions
+14
View File
@@ -50,6 +50,10 @@ class GroupNameRequest(BaseModel):
color: str | None = None
class GroupReorderRequest(BaseModel):
ordered_ids: list[str]
class GroupAssignRequest(BaseModel):
group_id: str | None = None
@@ -105,6 +109,16 @@ def create_group(req: GroupNameRequest):
return {"groups": groups, "group": group}
@router.put("/groups/reorder")
def reorder_groups(req: GroupReorderRequest):
"""重排分组前后顺序 (json 数组顺序即定义顺序, 侧边栏/标签栏/分组视图共用)。"""
try:
groups = watchlist.reorder_groups(req.ordered_ids)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"groups": groups}
@router.put("/groups/{group_id}")
def rename_group(group_id: str, req: GroupNameRequest):
try:
+12
View File
@@ -246,6 +246,18 @@ def rename_group(group_id: str, name: str, color: str | None = None) -> list[dic
return groups
def reorder_groups(ordered_ids: list[str]) -> list[dict]:
"""按给定 id 顺序重排分组 (json 数组顺序即定义顺序)。"""
with _LOCK:
groups = _read_groups()
by_id = {group["id"]: group for group in groups}
if len(ordered_ids) != len(groups) or set(ordered_ids) != set(by_id):
raise ValueError("分组顺序与现有分组不一致")
reordered = [by_id[group_id] for group_id in ordered_ids]
_write_groups(reordered)
return reordered
def delete_group(group_id: str) -> tuple[list[dict], list[dict]]:
"""删除分组定义,原分组内的自选保留并转为未分组。"""
with _LOCK:
+38
View File
@@ -71,6 +71,44 @@ def test_group_validation_and_assignment_errors(monkeypatch, tmp_path):
assert rows[0]["group_id"] is None
def test_reorder_groups(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, first = watchlist.create_group("")
_, second = watchlist.create_group("")
_, third = watchlist.create_group("")
reordered = watchlist.reorder_groups([third["id"], first["id"], second["id"]])
assert [group["name"] for group in reordered] == ["", "", ""]
assert [group["name"] for group in watchlist.list_groups()] == ["", "", ""]
# ids 与现有分组不一致 (缺失 / 多余 / 重复) 均拒绝
with pytest.raises(ValueError, match="不一致"):
watchlist.reorder_groups([first["id"], second["id"]])
with pytest.raises(ValueError, match="不一致"):
watchlist.reorder_groups([first["id"], second["id"], third["id"], "missing"])
with pytest.raises(ValueError, match="不一致"):
watchlist.reorder_groups([first["id"], first["id"], second["id"], third["id"]])
# 失败请求不改变现有顺序
assert [group["name"] for group in watchlist.list_groups()] == ["", "", ""]
def test_reorder_groups_api(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, first = watchlist.create_group("")
_, second = watchlist.create_group("")
result = watchlist_api.reorder_groups(
watchlist_api.GroupReorderRequest(ordered_ids=[second["id"], first["id"]])
)
assert [group["name"] for group in result["groups"]] == ["", ""]
with pytest.raises(HTTPException) as exc_info:
watchlist_api.reorder_groups(
watchlist_api.GroupReorderRequest(ordered_ids=["missing"])
)
assert exc_info.value.status_code == 400
def test_group_api_contract(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
request = _request()
+10 -2
View File
@@ -20,7 +20,7 @@
- 扩展数据与声明式分析页面:适合不需要自定义 React 交互的页面。
- 前端源码扩展注册:`frontend/src/custom/<namespace>/extension.tsx`,支持静态页面、导航和已开放插槽。
- 后端源码扩展注册:`backend/app/custom/<module>.py`,支持 FastAPI 路由、启动钩子和通知格式化器。
- 当前前端插槽:`layout.navigation.extra`
- 当前前端插槽:`layout.navigation.extra``stock-preview.footer``watchlist.toolbar`
- 当前后端继承点:`NotificationFormatter`
尚未实现、只能在真实需求出现后增加的能力:
@@ -111,12 +111,20 @@ export default extension
- 注册顺序确定,使用 `order` 后再按 `id` 排序,避免加载顺序导致界面漂移。
- 插槽内容必须遵守项目现有设计系统、响应式和可访问性要求。
当前开放:
当前开放:
```text
layout.navigation.extra
stock-preview.footer
watchlist.toolbar
```
各插槽 context 契约(均要求 `apiVersion: 1`,定义见 `frontend/src/extensions/types.ts``FrontendSlotContextMap`):
- `layout.navigation.extra``{ collapsed, pathname }`,侧边栏导航底部。
- `stock-preview.footer``{ symbol, name, view }`,个股详情对话框底部(日K/分时图表下方);`view``'daily' | 'intraday'`。适合个股附加面板:龙虎榜、资金流、外部研究链接等。
- `watchlist.toolbar``{ symbols, viewMode, selectedGroup, refresh }`,自选页工具栏末尾;`symbols` 为当前筛选视图中的标的,`refresh` 在扩展修改数据后调用以刷新自选增强数据。适合批量操作入口:自定义分析、导出、组合计算等。
新增插槽前必须有真实用例,并同时定义 context 类型、异常隔离和测试;不能只在类型表中预留名字。
### 3.3 当前路由与导航契约
@@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from 'react'
import { Minus, Plus, SlidersHorizontal } from 'lucide-react'
import {
GROUP_METRICS,
GROUP_SORT_OPTIONS,
GROUP_CARD_TOP_N_MAX,
GROUP_CARD_TOP_N_MIN,
type GroupStatsConfig,
type GroupStatsConfigPatch,
} from '@/lib/watchlistGroupStats'
/**
* 分组「指标 + 排序」设置弹层 — 分组统计条与分组卡片共用。
* 状态由父级持有并持久化, 这里只负责弹层交互与展示。
* showCardLimit 为真时额外暴露分组卡片显示项 (条数/头部彩条/序号, 仅卡片视图有意义)。
*/
export function GroupStatsSettings({
config,
onChange,
ariaLabel = '分组统计设置',
showCardLimit = false,
}: {
config: GroupStatsConfig
onChange: (patch: GroupStatsConfigPatch) => void
ariaLabel?: string
showCardLimit?: boolean
}) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
// 点击面板外部关闭 (与自选页搜索框同模式)
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen(v => !v)}
aria-label={ariaLabel}
title={ariaLabel}
className={`inline-flex h-5 w-5 items-center justify-center rounded transition-colors ${
open
? 'text-accent bg-accent/10'
: 'text-muted hover:text-foreground hover:bg-elevated'
}`}
>
<SlidersHorizontal className="h-3 w-3" />
</button>
{open && (
<div className="absolute right-0 top-full z-30 mt-1 w-64 rounded-card border border-border bg-base p-3 shadow-xl">
<div className="text-[10px] uppercase tracking-wider text-muted"></div>
<div className="mt-1 flex flex-wrap gap-1">
{GROUP_METRICS.map(m => (
<button
key={m.id}
type="button"
title={m.hint}
onClick={() => onChange({ metric: m.id })}
className={`px-2 py-0.5 rounded text-[11px] transition-colors ${
config.metric === m.id
? 'bg-accent/15 text-accent'
: 'bg-elevated text-secondary hover:text-foreground hover:bg-elevated/80'
}`}
>
{m.label}
</button>
))}
</div>
<div className="mt-2.5 text-[10px] uppercase tracking-wider text-muted"></div>
<div className="mt-1 flex flex-wrap gap-1">
{GROUP_SORT_OPTIONS.map(s => (
<button
key={s.id}
type="button"
onClick={() => onChange({ sort: s.id })}
className={`px-2 py-0.5 rounded text-[11px] transition-colors ${
config.sort === s.id
? 'bg-accent/15 text-accent'
: 'bg-elevated text-secondary hover:text-foreground hover:bg-elevated/80'
}`}
>
{s.label}
</button>
))}
</div>
{showCardLimit && (
<>
<div className="mt-2.5 text-[10px] uppercase tracking-wider text-muted"></div>
<div className="mt-1 flex items-center gap-2" title="分组卡片默认展示组内前 N 条, 可展开查看全部">
<button
type="button"
aria-label="减少卡片显示条数"
disabled={config.cardTopN <= GROUP_CARD_TOP_N_MIN}
onClick={() => onChange({ cardTopN: config.cardTopN - 1 })}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-elevated text-secondary transition-colors hover:text-foreground hover:bg-elevated/80 disabled:cursor-not-allowed disabled:opacity-40"
>
<Minus className="h-3 w-3" />
</button>
<span className="w-16 text-center font-mono text-[11px] tabular-nums text-secondary">
{config.cardTopN}
</span>
<button
type="button"
aria-label="增加卡片显示条数"
disabled={config.cardTopN >= GROUP_CARD_TOP_N_MAX}
onClick={() => onChange({ cardTopN: config.cardTopN + 1 })}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-elevated text-secondary transition-colors hover:text-foreground hover:bg-elevated/80 disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus className="h-3 w-3" />
</button>
</div>
<div className="mt-2 space-y-1.5">
<div className="flex items-center justify-between" title="分组卡片头部是否显示分组颜色底条">
<span className="text-[11px] text-secondary"></span>
<button
type="button"
role="switch"
aria-checked={config.cardColorBar}
aria-label="切换卡片头部颜色"
onClick={() => onChange({ cardColorBar: !config.cardColorBar })}
className={`relative h-4 w-7 shrink-0 rounded-full transition-colors ${
config.cardColorBar ? 'bg-accent/60' : 'bg-elevated hover:bg-elevated/80'
}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full transition-all ${
config.cardColorBar ? 'left-[14px] bg-white' : 'left-0.5 bg-muted'
}`}
/>
</button>
</div>
<div className="flex items-center justify-between" title="成员行左侧是否显示排名序号">
<span className="text-[11px] text-secondary"></span>
<button
type="button"
role="switch"
aria-checked={config.cardRank}
aria-label="切换卡片序号显示"
onClick={() => onChange({ cardRank: !config.cardRank })}
className={`relative h-4 w-7 shrink-0 rounded-full transition-colors ${
config.cardRank ? 'bg-accent/60' : 'bg-elevated hover:bg-elevated/80'
}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full transition-all ${
config.cardRank ? 'left-[14px] bg-white' : 'left-0.5 bg-muted'
}`}
/>
</button>
</div>
</div>
</>
)}
<div className="mt-2.5 text-[10px] leading-relaxed text-muted">
{GROUP_METRICS.find(m => m.id === config.metric)?.hint}
</div>
</div>
)}
</div>
)
}
@@ -17,6 +17,7 @@ import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
import { storage } from '@/lib/storage'
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
interface Props {
symbol: string | null
@@ -423,6 +424,14 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
)}
</div>
{/* 扩展插槽: 对话框底部二开区 (无注册时不渲染) */}
<div className="shrink-0">
<ExtensionSlot
name="stock-preview.footer"
context={{ symbol, name: name ?? null, view }}
/>
</div>
{/* 加监控编辑器弹层 */}
<AnimatePresence>
{showMonitorEditor && symbol && (
+5 -1
View File
@@ -104,7 +104,11 @@ export function WatchlistGroupMenu({
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
const closeOnViewportChange = () => setOpen(false)
// 菜单内部分组列表可滚动 (max-h-60), 其 scroll 事件不应触发关闭; 仅页面/祖先容器滚动时关闭
const closeOnViewportChange = (event: Event) => {
if (event.target instanceof Node && menuRef.current?.contains(event.target)) return
setOpen(false)
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
@@ -0,0 +1,277 @@
import React, { useCallback, useMemo, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import type { WatchlistGroup } from '@/lib/api'
import { fmtPrice, fmtPct, priceColorClass } from '@/lib/format'
import {
rowPct,
groupPctColor,
groupMetricTitle,
groupMetricValue,
sortGroupKeys,
GROUP_METRICS,
type GroupPctMap,
type GroupPctInfo,
type GroupStatsConfig,
type GroupStatsConfigPatch,
type GroupMetric,
} from '@/lib/watchlistGroupStats'
import {
resolveWatchlistGroupColor,
type WatchlistGroupColorOption,
} from '@/lib/watchlist-group-colors'
import { boardTag } from '@/components/stock-table/primitives'
import { GroupStatsSettings } from '@/components/GroupStatsSettings'
/**
* 自选「分组卡片」视图 — 每个分组一张卡, 组内按涨跌幅降序排列,
* 默认展示前 N 名 (N 在设置弹层可调), 可展开查看全部。卡片自身顺序、
* 头部数值与显示条数跟随「指标 + 排序」配置 (与分组统计条共享,
* 由自选页统一持有并持久化)。数据全部来自自选页既有查询
* (enriched 行 + 分组归属), 不产生额外请求。
*/
interface GroupCardData {
/** 'ungrouped' 或分组 id */
key: string
name: string
color: WatchlistGroupColorOption | null
/** 组内成员 (已按涨跌幅降序) */
rows: any[]
}
const GroupCard = React.memo(function GroupCard({
data,
pctInfo,
metric,
topN,
showColorBar,
showRank,
expanded,
onToggle,
onPreview,
onOpen,
}: {
data: GroupCardData
pctInfo?: GroupPctInfo
metric: GroupMetric
/** 默认展示的成员条数 (来自持久化配置) */
topN: number
/** 头部是否显示分组颜色底条 (来自持久化配置) */
showColorBar: boolean
/** 成员行是否显示序号 (来自持久化配置) */
showRank: boolean
expanded: boolean
onToggle: (key: string) => void
onPreview: (symbol: string, name: string) => void
onOpen: (key: string) => void
}) {
const visible = expanded ? data.rows : data.rows.slice(0, topN)
const hasMore = data.rows.length > topN
const color = data.color
// 头部数值跟随所选指标; 上涨占比以 0.5 为强弱轴染色
const v = groupMetricValue(pctInfo, metric)
const signed = metric === 'up_ratio' ? (v == null ? null : v - 0.5) : v
const valueLabel = v == null
? '—'
: metric === 'up_ratio'
? `${(v * 100).toFixed(0)}%`
: fmtPct(v)
return (
<div className="flex flex-col self-start w-full overflow-hidden rounded-lg border border-border bg-surface">
{/* 头部: 色点 + 名称 + 指标数值居左, 总数 + 涨跌家数居右; 点击钻取该分组 */}
<button
type="button"
onClick={() => onOpen(data.key)}
className={`group flex w-full items-center gap-1.5 border-b border-border/60 px-3 py-2 text-left transition-colors hover:bg-elevated/60 ${showColorBar && color ? color.background : ''}`}
title={`查看「${data.name}」分组列表`}
>
<span className={`h-2 w-2 shrink-0 rounded-full ${color ? color.dot : 'bg-muted/60'}`} />
<span className={`truncate text-xs font-medium ${color ? color.text : 'text-foreground'}`}>
{data.name}
</span>
{pctInfo && pctInfo.sampled > 0 && (
<span
className={`shrink-0 font-mono text-xs font-semibold tabular-nums ${groupPctColor(signed)}`}
title={groupMetricTitle(pctInfo, metric)}
>
{valueLabel}
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-1.5" title={groupMetricTitle(pctInfo, metric)}>
<span className="font-mono text-[10px] tabular-nums text-muted">{data.rows.length}</span>
{pctInfo && pctInfo.sampled > 0 && (
<span className="text-[10px] tabular-nums">
<span className="text-bull">{pctInfo.up}</span>
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{pctInfo.down}</span>
</span>
)}
</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted/60 transition-transform group-hover:translate-x-0.5" />
</button>
{/* 组内榜单: 按涨跌幅降序 */}
{data.rows.length === 0 ? (
<div className="px-3 py-4 text-center text-[11px] text-muted"></div>
) : (
<div className="flex flex-col">
{visible.map((r: any, i: number) => {
const pct = rowPct(r)
const price = r.rt_price ?? r.close
const cls = priceColorClass(pct)
const board = boardTag(r.symbol)
return (
<button
key={r.symbol}
type="button"
onClick={() => onPreview(r.symbol, r.rt_name ?? r.name ?? '')}
className="flex w-full items-center gap-2 px-3 py-[5px] text-left transition-colors hover:bg-elevated/50"
title={`${r.symbol} ${r.rt_name ?? r.name ?? ''}`}
>
{showRank && (
<span className="w-4 shrink-0 text-right font-mono text-[10px] leading-none tabular-nums text-muted/70">
{i + 1}
</span>
)}
<span className="shrink-0 font-mono text-xs text-foreground">{r.symbol}</span>
<span className="flex min-w-0 flex-1 items-center gap-1">
<span className="min-w-0 truncate text-xs text-secondary">{r.rt_name ?? r.name}</span>
{board && (
<span className={`shrink-0 inline-flex items-center justify-center px-1 h-[16px] rounded text-[9px] font-bold leading-none ${board.color}`}>
{board.label}
</span>
)}
</span>
<span className={`shrink-0 font-mono text-xs tabular-nums ${cls}`}>{fmtPrice(price)}</span>
<span className={`w-[52px] shrink-0 text-right font-mono text-xs font-medium tabular-nums ${cls}`}>
{fmtPct(pct)}
</span>
</button>
)
})}
</div>
)}
{/* 展开/收起 */}
{hasMore && (
<button
type="button"
onClick={() => onToggle(data.key)}
className="flex items-center justify-center gap-1 border-t border-border/60 px-3 py-1.5 text-[10px] text-muted transition-colors hover:bg-elevated/60 hover:text-foreground"
>
{expanded ? '收起' : `显示全部 ${data.rows.length}`}
<ChevronDown className={`h-3 w-3 transition-transform ${expanded ? '' : 'rotate-180'}`} />
</button>
)}
</div>
)
})
interface WatchlistGroupCardsProps {
groups: WatchlistGroup[]
/** enriched 全量行 (未经过分组/板块筛选) */
rows: any[]
/** symbol -> group_id (null = 未分组), 来自自选列表查询 */
groupBySymbol: Map<string, string | null>
/** 分组等权涨跌幅统计 */
pcts: GroupPctMap
onPreview: (symbol: string, name: string) => void
/** 钻取分组 (切换到该分组的卡片列表) */
onOpenGroup: (groupId: string) => void
config: GroupStatsConfig
onConfigChange: (patch: GroupStatsConfigPatch) => void
}
export function WatchlistGroupCards({
groups,
rows,
groupBySymbol,
pcts,
onPreview,
onOpenGroup,
config,
onConfigChange,
}: WatchlistGroupCardsProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const toggleExpanded = useCallback((key: string) => {
setExpanded(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}, [])
// 分桶 + 组内按涨跌幅降序: 仅在 enriched 行或分组归属变化时重算,
// 每组数组引用稳定, 配合 GroupCard memo 避免无关卡片重渲染。
const cards = useMemo<GroupCardData[]>(() => {
const buckets = new Map<string, any[]>()
for (const group of groups) buckets.set(group.id, [])
buckets.set('ungrouped', [])
for (const r of rows) {
const bucket = buckets.get(groupBySymbol.get(r.symbol) ?? 'ungrouped')
if (bucket) bucket.push(r)
}
const sorted = new Map<string, any[]>()
for (const [key, list] of buckets) {
sorted.set(key, [...list].sort((a, b) => {
const pa = rowPct(a)
const pb = rowPct(b)
if (pa == null && pb == null) return 0
if (pa == null) return 1
if (pb == null) return -1
return pb - pa
}))
}
const result: GroupCardData[] = groups.map(group => ({
key: group.id,
name: group.name,
color: resolveWatchlistGroupColor(group.color),
rows: sorted.get(group.id) ?? [],
}))
// 未分组仅在非空时展示
const ungrouped = sorted.get('ungrouped') ?? []
if (ungrouped.length > 0) {
result.push({ key: 'ungrouped', name: '未分组', color: null, rows: ungrouped })
}
return result
}, [groups, rows, groupBySymbol])
// 卡片顺序跟随「指标 + 排序」配置 (与分组统计条同源)
const ordered = useMemo(
() => sortGroupKeys(cards, c => c.key, pcts, config),
[cards, pcts, config],
)
if (cards.length === 0) return null
const metricLabel = GROUP_METRICS.find(m => m.id === config.metric)?.label ?? ''
return (
<div>
<div className="mb-2 flex items-center justify-between">
<div className="text-[10px] uppercase tracking-wider text-muted"> · {metricLabel}</div>
<GroupStatsSettings config={config} onChange={onConfigChange} ariaLabel="分组卡片设置" showCardLimit />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{ordered.map(card => (
<GroupCard
key={card.key}
data={card}
pctInfo={pcts[card.key]}
metric={config.metric}
topN={config.cardTopN}
showColorBar={config.cardColorBar}
showRank={config.cardRank}
expanded={expanded.has(card.key)}
onToggle={toggleExpanded}
onPreview={onPreview}
onOpen={onOpenGroup}
/>
))}
</div>
</div>
)
}
@@ -0,0 +1,161 @@
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>
)
}
+111 -5
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Check, FolderCog, FolderInput, Pencil, Plus, Trash2, X, Eraser } from 'lucide-react'
import { Check, ChevronDown, ChevronUp, FolderCog, FolderInput, Pencil, Plus, Trash2, X, Eraser } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -32,6 +32,8 @@ interface GroupBarProps {
onRename: (groupId: string, name: string, color: WatchlistGroupColor) => Promise<void>
onDelete: (groupId: string) => Promise<void>
onClearGroup?: (groupId: string) => Promise<void>
/** 手动调整分组前后顺序 (持久化到后端) */
onReorder?: (orderedIds: string[]) => Promise<void>
}
export function WatchlistGroupBar({
@@ -45,6 +47,7 @@ export function WatchlistGroupBar({
onRename,
onDelete,
onClearGroup,
onReorder,
}: GroupBarProps) {
const [managerOpen, setManagerOpen] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
@@ -53,22 +56,82 @@ export function WatchlistGroupBar({
{ id: 'ungrouped', name: '未分组', count: counts.ungrouped ?? 0, color: null },
...groups.map(group => ({ id: group.id, name: group.name, count: counts[group.id] ?? 0, color: group.color })),
]
// 拖拽排序状态: dragIndex = 拖动中的分组下标, dropIndex = 插入位置 (均相对 groups 数组)
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dropIndex, setDropIndex] = useState<number | null>(null)
const reorderable = !!onReorder && groups.length > 1
const tablistRef = useRef<HTMLDivElement>(null)
const clearDrag = () => { setDragIndex(null); setDropIndex(null) }
// 分组很多时标签栏横向滚动, 拖到边缘附近自动滚动, 保证能拖到视野外的位置
const autoScroll = (clientX: number) => {
const el = tablistRef.current
if (!el || el.scrollWidth <= el.clientWidth) return
const rect = el.getBoundingClientRect()
const edge = 48
if (clientX < rect.left + edge) el.scrollLeft -= 16
else if (clientX > rect.right - edge) el.scrollLeft += 16
}
const handleDrop = () => {
if (dragIndex == null || dropIndex == null) { clearDrag(); return }
const ids = groups.map(group => group.id)
const [moved] = ids.splice(dragIndex, 1)
if (moved) {
ids.splice(dropIndex > dragIndex ? dropIndex - 1 : dropIndex, 0, moved)
if (ids.join(',') !== groups.map(group => group.id).join(',')) {
void onReorder?.(ids)
}
}
clearDrag()
}
return (
<>
<div className="flex h-10 items-stretch border-b border-border bg-surface/40 px-5">
<div role="tablist" aria-label="自选分组" className="flex min-w-0 flex-1 items-stretch gap-1 overflow-x-auto">
{tabs.map(tab => {
<div
ref={tablistRef}
role="tablist"
aria-label="自选分组"
onDragOver={dragIndex != null ? e => autoScroll(e.clientX) : undefined}
className="flex min-w-0 flex-1 items-stretch gap-1 overflow-x-auto"
>
{tabs.map((tab, tabIndex) => {
const active = selected === tab.id
const color = tab.color ? resolveWatchlistGroupColor(tab.color) : null
// 前两个为固定标签 (全部/未分组), 其后对应 groups 数组 — 可拖拽排序
const groupIndex = tabIndex - 2
const draggable = reorderable && tabIndex >= 2
const dragging = draggable && dragIndex === groupIndex
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
draggable={draggable}
onDragStart={draggable ? e => {
setDragIndex(groupIndex)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', tab.id)
} : undefined}
onDragOver={draggable ? e => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
autoScroll(e.clientX)
const rect = e.currentTarget.getBoundingClientRect()
setDropIndex(groupIndex + (e.clientX > rect.left + rect.width / 2 ? 1 : 0))
} : undefined}
onDrop={draggable ? e => { e.preventDefault(); handleDrop() } : undefined}
onDragEnd={draggable ? clearDrag : undefined}
onClick={() => onSelect(tab.id)}
className={`my-1.5 inline-flex shrink-0 items-center gap-1.5 rounded-btn border px-3 text-xs transition-colors ${
title={draggable ? `${tab.name} — 可拖拽调整分组顺序` : undefined}
className={`relative my-1.5 inline-flex shrink-0 items-center gap-1.5 rounded-btn border px-3 text-xs transition-colors ${
draggable ? 'cursor-grab active:cursor-grabbing' : ''
} ${
dragging ? 'opacity-40' : ''
} ${
active
? color
? `${color.text} ${color.border} ${color.background}`
@@ -78,6 +141,12 @@ export function WatchlistGroupBar({
: 'border-transparent text-secondary hover:bg-elevated hover:text-foreground'
}`}
>
{draggable && dragIndex != null && dropIndex === groupIndex && (
<span className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full bg-accent" />
)}
{draggable && dragIndex != null && dropIndex === groupIndex + 1 && groupIndex === groups.length - 1 && (
<span className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full bg-accent" />
)}
{color && <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${color.dot}`} />}
<span>{tab.name}</span>
<span className={`font-mono text-[10px] tabular-nums ${active && !color ? 'text-accent/80' : 'text-muted'}`}>
@@ -160,6 +229,7 @@ export function WatchlistGroupBar({
onCreate={onCreate}
onRename={onRename}
onDelete={onDelete}
onReorder={onReorder}
/>
)}
</>
@@ -207,6 +277,7 @@ function GroupManagerDialog({
onCreate,
onRename,
onDelete,
onReorder,
}: Omit<GroupBarProps, 'selected' | 'total' | 'onSelect' | 'onClearGroup'> & { onClose: () => void }) {
const inputRef = useRef<HTMLInputElement>(null)
const [newName, setNewName] = useState('')
@@ -278,6 +349,17 @@ function GroupManagerDialog({
})
}
// 上移/下移: 与相邻分组交换位置, 新顺序由后端持久化 (json 数组顺序即定义顺序)
const move = async (groupId: string, dir: -1 | 1) => {
if (!onReorder) return
const ids = groups.map(group => group.id)
const index = ids.indexOf(groupId)
const target = index + dir
if (index < 0 || target < 0 || target >= ids.length) return
;[ids[index], ids[target]] = [ids[target], ids[index]]
await run(async () => { await onReorder(ids) })
}
return (
<Modal
onClose={onClose}
@@ -347,7 +429,7 @@ function GroupManagerDialog({
<div className="max-h-[360px] overflow-y-auto border-t border-border px-4">
{groups.length === 0 ? (
<div className="py-10 text-center text-xs text-muted"></div>
) : groups.map(group => {
) : groups.map((group, index) => {
const color = resolveWatchlistGroupColor(group.color)
return (
<div key={group.id} className="flex min-h-12 items-center gap-2 border-b border-border/60 last:border-0">
@@ -395,6 +477,30 @@ function GroupManagerDialog({
<>
<span className={`min-w-0 flex-1 truncate text-xs ${color.text}`}>{group.name}</span>
<span className="font-mono text-[10px] text-muted tabular-nums">{counts[group.id] ?? 0} </span>
{onReorder && groups.length > 1 && (
<>
<button
type="button"
disabled={pending || index === 0}
onClick={() => void move(group.id, -1)}
className="p-1 text-muted hover:text-accent disabled:opacity-30 disabled:hover:text-muted"
title="上移"
aria-label={`上移分组 ${group.name}`}
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
disabled={pending || index === groups.length - 1}
onClick={() => void move(group.id, 1)}
className="p-1 text-muted hover:text-accent disabled:opacity-30 disabled:hover:text-muted"
title="下移"
aria-label={`下移分组 ${group.name}`}
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</>
)}
<button
type="button"
onClick={() => {
+94 -2
View File
@@ -1,11 +1,12 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Activity, Building2, ChartNoAxesCombined, Check, Layers3, Plus, RadioTower, Save, Search, Tags, TrendingUp, Waypoints, X } from 'lucide-react'
import { Activity, Building2, ChartNoAxesCombined, Check, Layers3, ListPlus, Plus, RadioTower, Save, Search, Tags, TrendingUp, Waypoints, X } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition, type SectorKind, type SectorMonitorTarget, type StrategyNotifyEvent } from '@/lib/api'
import { DEFAULT_STRATEGY_NOTIFY_EVENTS, LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS } from '@/lib/strategyMonitorEvents'
import { QK } from '@/lib/queryKeys'
import { boardTag } from '@/components/stock-table/primitives'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { MONITOR_INTRADAY_SIGNAL_OPTIONS, SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
import { usePreferences } from '@/lib/useSharedQueries'
@@ -109,6 +110,29 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
})
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
// 「自选导入」下拉: 从自选/自选分组批量并入标的 (与自选页共用查询缓存)
const [watchMenuOpen, setWatchMenuOpen] = useState(false)
const watchMenuRef = useRef<HTMLDivElement>(null)
const watchlistQ = useQuery({
queryKey: QK.watchlist,
queryFn: api.watchlistList,
enabled: watchMenuOpen,
})
const watchGroupsQ = useQuery({
queryKey: QK.watchlistGroups,
queryFn: api.watchlistGroups,
enabled: watchMenuOpen,
})
useEffect(() => {
if (!watchMenuOpen) return
const handleClick = (e: MouseEvent) => {
if (watchMenuRef.current && !watchMenuRef.current.contains(e.target as Node)) {
setWatchMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [watchMenuOpen])
const [sectorQuery, setSectorQuery] = useState('')
const [industryLevel, setIndustryLevel] = useState<1 | 2 | 3>(() => {
const level = rule?.sector_targets?.[0]?.level
@@ -199,6 +223,39 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
setSymbolQuery('')
}
// 并入一组标的 (去重); 选择后关闭自选导入下拉
const importSymbols = (syms: string[]) => {
setDraft(d => {
const merged = [...d.symbols]
for (const s of syms) {
if (!merged.includes(s)) merged.push(s)
}
return { ...d, symbols: merged }
})
setWatchMenuOpen(false)
}
// 自选导入选项: 全部自选 + 各分组 (空分组隐藏) + 未分组
const watchImportOptions = (() => {
const entries = watchlistQ.data?.symbols ?? []
if (entries.length === 0) return []
const options = [{
key: 'all',
name: '全部自选',
dot: 'bg-muted/60',
symbols: entries.map(e => e.symbol),
}]
for (const group of watchGroupsQ.data?.groups ?? []) {
const syms = entries.filter(e => e.group_id === group.id).map(e => e.symbol)
if (syms.length === 0) continue
options.push({ key: group.id, name: group.name, dot: resolveWatchlistGroupColor(group.color).dot, symbols: syms })
}
const ungrouped = entries.filter(e => !e.group_id).map(e => e.symbol)
if (ungrouped.length > 0) {
options.push({ key: 'ungrouped', name: '未分组', dot: 'bg-muted/60', symbols: ungrouped })
}
return options
})()
const selectSectorKind = (kind: SectorKind) => {
setDraft(d => ({ ...d, sector_kind: kind, sector_targets: [] }))
setSectorQuery('')
@@ -684,6 +741,41 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</button>
</span>
))}
{/* 从自选/自选分组批量导入标的 */}
<div className="relative" ref={watchMenuRef}>
<button
type="button"
onClick={() => setWatchMenuOpen(v => !v)}
title="从自选 / 自选分组导入标的 (导入当前成员, 后续增删自选不影响本规则)"
className={`inline-flex h-7 items-center gap-1 rounded border px-2 text-[11px] transition-colors cursor-pointer ${
watchMenuOpen
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-base text-secondary hover:border-accent/30 hover:text-foreground'
}`}
>
<ListPlus className="h-3 w-3" />
</button>
{watchMenuOpen && (
<div className="absolute z-10 mt-1 max-h-56 w-44 overflow-y-auto rounded border border-border bg-surface py-1 shadow-lg">
{watchlistQ.isLoading ? (
<div className="px-2.5 py-2 text-[11px] text-muted">...</div>
) : watchImportOptions.length === 0 ? (
<div className="px-2.5 py-2 text-[11px] text-muted"></div>
) : watchImportOptions.map(option => (
<button
key={option.key}
type="button"
onClick={() => importSymbols(option.symbols)}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[11px] text-secondary transition-colors hover:bg-elevated hover:text-foreground cursor-pointer"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${option.dot}`} />
<span className="min-w-0 flex-1 truncate">{option.name}</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-muted">{option.symbols.length}</span>
</button>
))}
</div>
)}
</div>
<div className="relative">
<input
value={symbolQuery}
@@ -30,6 +30,8 @@ export interface StockDataTableProps {
/** 排序:外部受控时传入(含当前 sort 与 toggle);不传则表头不可排序 */
sort?: SortState | null
onSortToggle?: (colId: string) => void
/** 实例级放行: 让 UNSORTABLE_KEYS 中的 builtin 列在本表也可排序 (如自选页分时列) */
extraSortableKeys?: ReadonlySet<string>
/** 追加在每行末尾的额外单元格(如自选页的操作列) */
renderExtraCol?: (r: any) => ReactNode
/** 追加的表头单元格(对应 renderExtraCol */
@@ -54,6 +56,7 @@ export function StockDataTable({
minWidth,
sort,
onSortToggle,
extraSortableKeys,
renderExtraCol,
extraHeader,
renderHeaderContent,
@@ -85,7 +88,7 @@ export function StockDataTable({
const isColSortable = (col: ColumnConfig): boolean => {
// 排序能力由调用方是否提供 onSortToggle 决定;sort 是否为 null 只影响当前指示器
if (!onSortToggle) return false
if (col.source.type === 'builtin' && UNSORTABLE_KEYS.has(col.source.key)) return false
if (col.source.type === 'builtin' && UNSORTABLE_KEYS.has(col.source.key) && !extraSortableKeys?.has(col.source.key)) return false
return true
}
@@ -5,7 +5,7 @@
*/
import { useCallback, useState } from 'react'
import type { ColumnConfig } from '@/lib/list-columns'
import { UNSORTABLE_KEYS, getSortValue as defaultGetSortValue } from '@/lib/stock-table'
import { getSortValue as defaultGetSortValue } from '@/lib/stock-table'
export interface SortState {
key: string // 列 id
@@ -24,16 +24,17 @@ export function useTableSort<T>(getSortValue: (r: T, col: ColumnConfig) => any =
})
}, [])
/** 对行集合按当前 sort 排序(返回新数组)。无 sort 或列为不可排序时原样返回。 */
/** 对行集合按当前 sort 排序(返回新数组)。无 sort 或列不存在时原样返回;
* 取值为 null 的行排在最后。表头是否可点由 StockDataTable 控制。 */
const sortRows = useCallback((rows: T[], columns: ColumnConfig[]): T[] => {
if (!sort) return rows
const col = columns.find(c => c.id === sort.key)
if (!col) return rows
if (col.source.type === 'builtin' && UNSORTABLE_KEYS.has(col.source.key)) return rows
const { dir } = sort
return [...rows].sort((a, b) => {
const va = getSortValue(a, col)
const vb = getSortValue(b, col)
if (va == null && vb == null) return 0
if (va == null) return 1
if (vb == null) return -1
const na = typeof va === 'number' ? va : Number(va)
@@ -9,6 +9,28 @@ function NavigationExtra({ collapsed }: { collapsed: boolean; pathname: string }
return collapsed ? null : <div className="px-3 py-1 text-xs text-muted">二开内容</div>
}
/** 个股详情对话框底部: 自带分隔与内边距 */
function StockPreviewFooter({ symbol, name }: { symbol: string; name: string | null; view: 'daily' | 'intraday' }) {
return (
<div className="border-t border-border px-4 py-2 text-xs text-muted">
{symbol} {name} · 二开面板示例
</div>
)
}
/** 自选页工具栏: 按钮尺寸与核心工具栏一致 */
function WatchlistToolbar({ symbols, refresh }: { symbols: string[]; viewMode: 'table' | 'card'; selectedGroup: string; refresh: () => void }) {
return (
<button
type="button"
onClick={refresh}
className="inline-flex items-center h-8 px-2.5 rounded-btn bg-elevated text-xs text-secondary hover:bg-elevated/80 hover:text-foreground transition-colors duration-150 ease-smooth"
>
二开操作 ({symbols.length})
</button>
)
}
const extension: FrontendExtension = {
id: 'company.risk',
apiVersion: 1,
@@ -24,6 +46,16 @@ const extension: FrontendExtension = {
id: 'company-risk-summary',
component: NavigationExtra,
},
{
name: 'stock-preview.footer',
id: 'company-risk-stock-footer',
component: StockPreviewFooter,
},
{
name: 'watchlist.toolbar',
id: 'company-risk-watchlist-action',
component: WatchlistToolbar,
},
],
}
+2 -1
View File
@@ -174,7 +174,8 @@ export function getFrontendExtensionNavigation() {
export function getFrontendSlotRegistrations<K extends FrontendSlotName>(name: K) {
if (!frozen) throw new Error('读取扩展插槽前必须冻结注册表')
return (slots.get(name) ?? []) as Array<FrontendSlotRegistration<K> & { extensionId: string }>
// 存储按槽位名分桶, 桶内注册项的 name 必与键一致, 断言安全
return (slots.get(name) ?? []) as unknown as Array<FrontendSlotRegistration<K> & { extensionId: string }>
}
export function getFrontendExtensionLoadErrors() {
+15
View File
@@ -8,6 +8,21 @@ export interface FrontendSlotContextMap {
collapsed: boolean
pathname: string
}
/** 个股详情对话框底部扩展区 (日K/分时图表下方) */
'stock-preview.footer': {
symbol: string
name: string | null
view: 'daily' | 'intraday'
}
/** 自选页工具栏扩展区 (按钮行末尾) */
'watchlist.toolbar': {
/** 当前筛选/排序后视图中的标的 */
symbols: string[]
viewMode: 'table' | 'card'
selectedGroup: string
/** 刷新自选增强数据 (扩展修改数据后调用) */
refresh: () => void
}
}
export type FrontendSlotName = keyof FrontendSlotContextMap
+5
View File
@@ -1950,6 +1950,11 @@ export const api = {
`/api/watchlist/groups/${encodeURIComponent(groupId)}`,
{ method: 'PUT', body: JSON.stringify({ name, color }) },
),
watchlistGroupReorder: (orderedIds: string[]) =>
request<{ groups: WatchlistGroup[] }>('/api/watchlist/groups/reorder', {
method: 'PUT',
body: JSON.stringify({ ordered_ids: orderedIds }),
}),
watchlistGroupDelete: (groupId: string) =>
request<{ groups: WatchlistGroup[]; symbols: WatchlistEntry[] }>(
`/api/watchlist/groups/${encodeURIComponent(groupId)}`,
+11
View File
@@ -5,6 +5,7 @@
* 自选页 13 个,缺一个均线金叉/死叉变体)。
*/
import type { ColumnConfig } from '@/lib/list-columns'
import type { MinuteKlineRow } from '@/lib/api'
// ===== 信号 =====
@@ -102,6 +103,16 @@ export function getSortValue(r: any, col: ColumnConfig): any {
}
}
/**
* 分时列排序标量: 最新一根分钟收盘相对昨收的涨跌幅 (小数), 与分时图最后一点同口径。
* 无分钟数据或昨收缺失时返回 null (排序时排在最后)。
*/
export function getIntradaySortValue(r: any, minuteRows: MinuteKlineRow[] | undefined): number | null {
if (!minuteRows?.length || !r.prev_close) return null
const last = minuteRows[minuteRows.length - 1]
return last.close / r.prev_close - 1
}
// ===== 共享样式 =====
/** 数值单元格统一样式(含 tabular-nums 等宽数字) */
+4 -1
View File
@@ -42,7 +42,7 @@ export const storage = {
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
/** 自选列表视图模式 table | card */
/** 自选列表视图模式 table | card (分组卡片为临时模式, 不持久化) */
watchlistView: kv<string>('watchlist_view'),
/** 自选列表日K蜡烛图显示状态 */
@@ -60,6 +60,9 @@ export const storage = {
/** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
/** 自选分组统计条配置 (metric: 统计指标, sort: 排序方式, card*: 分组卡片显示项) */
watchlistGroupStats: kv<{ metric: string; sort: string; cardTopN?: number; cardColorBar?: boolean; cardRank?: boolean }>('watchlist_groupStats'),
/** Screener 卡片尺寸 */
screenerCardSize: kv<string>('screener-card-size'),
+152 -3
View File
@@ -7,6 +7,7 @@
*/
import { fmtPct } from '@/lib/format'
import { storage } from '@/lib/storage'
export interface GroupPctInfo {
/** 等权平均涨跌幅(小数, 0.0123 = +1.23%, 与 enriched change_pct 同单位); 无有效样本为 null */
@@ -16,11 +17,28 @@ export interface GroupPctInfo {
flat: number
/** 参与统计的样本数(涨跌幅非空的成员) */
sampled: number
/** 中位数涨跌幅(小数); 无有效样本为 null */
median: number | null
/** 组内最大涨跌幅(小数); 无有效样本为 null */
max: number | null
/** 组内最小涨跌幅(小数); 无有效样本为 null */
min: number | null
}
/** key: 'all' | 'ungrouped' | 分组 id */
export type GroupPctMap = Record<string, GroupPctInfo>
/**
* 单只标的的展示涨跌幅: 实时优先、收盘兜底(与自选表格/卡片展示同源,
* 小数单位), 无有效数据为 null。分组统计与分组卡片排序共用此口径。
*/
export function rowPct(
row: { rt_pct?: number | null; change_pct?: number | null } | undefined,
): number | null {
const pct = row ? row.rt_pct ?? row.change_pct : null
return pct == null || !Number.isFinite(pct) ? null : pct
}
export function computeGroupPcts(
entries: { symbol: string; group_id?: string | null }[],
rowsBySymbol: Map<string, { rt_pct?: number | null; change_pct?: number | null }>,
@@ -37,18 +55,27 @@ export function computeGroupPcts(
}
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)
add('all', rowPct(row))
add(entry.group_id ?? 'ungrouped', rowPct(row))
}
const out: GroupPctMap = {}
for (const [key, b] of buckets) {
const sortedPcts = [...b.pcts].sort((a, c) => a - c)
const mid = Math.floor(sortedPcts.length / 2)
const median = sortedPcts.length === 0
? null
: sortedPcts.length % 2 === 1
? sortedPcts[mid]
: (sortedPcts[mid - 1] + sortedPcts[mid]) / 2
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,
median,
max: sortedPcts.length ? sortedPcts[sortedPcts.length - 1] : null,
min: sortedPcts.length ? sortedPcts[0] : null,
}
}
return out
@@ -60,6 +87,128 @@ export function groupPctColor(pct: number | null): string {
return pct > 0 ? 'text-bull' : 'text-bear'
}
// ===== 分组统计条指标契约 =====
/** 分组统计指标: 等权平均 / 中位数 / 上涨占比(以50%为轴) / 组内最强 / 组内最弱 */
export type GroupMetric = 'mean' | 'median' | 'up_ratio' | 'max' | 'min'
export const GROUP_METRICS: ReadonlyArray<{ id: GroupMetric; label: string; hint: string }> = [
{ id: 'mean', label: '等权平均', hint: '组内涨跌幅算术平均' },
{ id: 'median', label: '中位数', hint: '组内涨跌幅中位值, 抗极值' },
{ id: 'up_ratio', label: '上涨占比', hint: '上涨家数占有效样本比例, 50% 为强弱轴' },
{ id: 'max', label: '组内最强', hint: '组内最大涨幅 (龙头强度)' },
{ id: 'min', label: '组内最弱', hint: '组内最小涨幅' },
]
export function isGroupMetric(v: unknown): v is GroupMetric {
return typeof v === 'string' && GROUP_METRICS.some(m => m.id === v)
}
/** 分组排序方式: 定义顺序 / 按指标降序 / 升序 (分组统计条与分组卡片共享) */
export type GroupSort = 'default' | 'desc' | 'asc'
export const GROUP_SORT_OPTIONS: ReadonlyArray<{ id: GroupSort; label: string }> = [
{ id: 'default', label: '定义顺序' },
{ id: 'desc', label: '降序' },
{ id: 'asc', label: '升序' },
]
export function isGroupSort(v: unknown): v is GroupSort {
return v === 'default' || v === 'desc' || v === 'asc'
}
/** 分组指标+排序配置 (分组统计条 / 分组卡片两个视图共享同一份持久化) */
export interface GroupStatsConfig {
metric: GroupMetric
sort: GroupSort
/** 分组卡片默认展示的成员条数 (前 N, 可展开全部) */
cardTopN: number
/** 分组卡片头部是否显示分组颜色底条 */
cardColorBar: boolean
/** 分组卡片成员行是否显示序号 */
cardRank: boolean
}
/** 卡片默认条数与上下限 (超出范围的持久化值会被夹回) */
export const GROUP_CARD_TOP_N_DEFAULT = 8
export const GROUP_CARD_TOP_N_MIN = 1
export const GROUP_CARD_TOP_N_MAX = 50
/** 卡片头部彩条 / 成员行序号默认开启 (旧持久化缺失该字段时回退到默认) */
export const GROUP_CARD_COLOR_BAR_DEFAULT = true
export const GROUP_CARD_RANK_DEFAULT = true
function normalizeBool(v: unknown, fallback: boolean): boolean {
return typeof v === 'boolean' ? v : fallback
}
export function normalizeGroupCardTopN(v: unknown): number {
const n = typeof v === 'number' ? Math.round(v) : Number.NaN
if (!Number.isFinite(n)) return GROUP_CARD_TOP_N_DEFAULT
return Math.min(GROUP_CARD_TOP_N_MAX, Math.max(GROUP_CARD_TOP_N_MIN, n))
}
export function loadGroupStatsConfig(): GroupStatsConfig {
const saved = storage.watchlistGroupStats.get({
metric: 'mean',
sort: 'default',
cardTopN: GROUP_CARD_TOP_N_DEFAULT,
cardColorBar: GROUP_CARD_COLOR_BAR_DEFAULT,
cardRank: GROUP_CARD_RANK_DEFAULT,
})
return {
metric: isGroupMetric(saved.metric) ? saved.metric : 'mean',
sort: isGroupSort(saved.sort) ? saved.sort : 'default',
cardTopN: normalizeGroupCardTopN(saved.cardTopN),
cardColorBar: normalizeBool(saved.cardColorBar, GROUP_CARD_COLOR_BAR_DEFAULT),
cardRank: normalizeBool(saved.cardRank, GROUP_CARD_RANK_DEFAULT),
}
}
/** 配置局部更新 (设置弹层 -> 持有方), 新增卡片显示项时在此处扩展 */
export type GroupStatsConfigPatch = Partial<Pick<GroupStatsConfig, 'metric' | 'sort' | 'cardTopN' | 'cardColorBar' | 'cardRank'>>
/** 按配置排序分组键列表 (null 排最后), sort='default' 时原序返回 */
export function sortGroupKeys<T>(items: T[], keyOf: (item: T) => string, pcts: GroupPctMap, config: GroupStatsConfig): T[] {
if (config.sort === 'default') return items
return [...items].sort((a, b) => {
const va = groupMetricValue(pcts[keyOf(a)], config.metric)
const vb = groupMetricValue(pcts[keyOf(b)], config.metric)
if (va == null && vb == null) return 0
if (va == null) return 1
if (vb == null) return -1
return config.sort === 'desc' ? vb - va : va - vb
})
}
/**
* 取分组在指定指标下的数值。
* 涨跌幅类指标返回小数 (0.0123 = +1.23%); 上涨占比返回 0~1 占比,
* 条形渲染时以 0.5 为强弱轴。无有效样本为 null。
*/
export function groupMetricValue(info: GroupPctInfo | undefined, metric: GroupMetric): number | null {
if (!info || info.sampled === 0) return null
switch (metric) {
case 'mean': return info.pct
case 'median': return info.median
case 'up_ratio': return info.up / info.sampled
case 'max': return info.max
case 'min': return info.min
}
}
/** 悬停明细: 按当前指标给出数值 + 全套统计, 任意指标下信息完整 */
export function groupMetricTitle(info: GroupPctInfo | undefined, metric: GroupMetric): string {
if (!info || info.sampled === 0) return '暂无涨跌幅数据'
const value = groupMetricValue(info, metric)
const metricLabel = GROUP_METRICS.find(m => m.id === metric)?.label ?? ''
const valueText = value == null
? '—'
: metric === 'up_ratio'
? `${(value * 100).toFixed(1)}%`
: fmtPct(value)
return `${metricLabel} ${valueText} · 等权 ${fmtPct(info.pct)} · 中位 ${fmtPct(info.median)} · 最强 ${fmtPct(info.max)} · 最弱 ${fmtPct(info.min)} · 上涨${info.up} 下跌${info.down}${info.flat} (共${info.sampled}只)`
}
/** 悬停明细: 等权平均 +1.23% · 上涨12 下跌5 平1 (格式化复用全站 fmtPct) */
export function groupPctTitle(info: GroupPctInfo | undefined): string {
if (!info || info.pct == null) return '暂无涨跌幅数据'
+9
View File
@@ -271,6 +271,15 @@ export function Data() {
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
// 同步任务结束后 regime 覆盖范围可能变化, 一并刷新画像
qc.invalidateQueries({ queryKey: QK.regimeCoverage })
// 同步重写了指数日K/enriched/日K → 失效消费这些数据的查询。
// 侧边栏指数查询挂在 Layout 常驻不重挂载 (refetchOnWindowFocus 已关),
// 不失效会一直显示同步前的旧值; 自选相关查询在页面正打开时同理。
if (job.data.status === 'succeeded') {
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['kline-batch'] })
}
const t = setTimeout(() => setActiveJobId(null), 5_000)
return () => clearTimeout(t)
}
+120 -12
View File
@@ -3,12 +3,12 @@ import { useSearchParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { motion, AnimatePresence } from 'framer-motion'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus } from 'lucide-react'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type WatchlistGroupColor } from '@/lib/api'
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 { computeGroupPcts, loadGroupStatsConfig, type GroupStatsConfigPatch } from '@/lib/watchlistGroupStats'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
@@ -24,6 +24,12 @@ import {
WatchlistGroupPicker,
type WatchlistGroupFilter,
} from '@/components/WatchlistGroups'
import { WatchlistGroupCards } from '@/components/WatchlistGroupCards'
import { WatchlistGroupStatsBar } from '@/components/WatchlistGroupStatsBar'
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
// 分时列开放排序 (StockDataTable 实例级白名单; 表头眼睛/刷新按钮已 stopPropagation)
const INTRADAY_SORTABLE_KEYS = new Set(['intraday'])
import { getOcrInstallHint } from '@/lib/ocrInstallHint'
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
import { StockDataTable } from '@/components/stock-table/StockDataTable'
@@ -32,7 +38,7 @@ import { useTableSort } from '@/components/stock-table/useTableSort'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { MiniIntraday } from '@/components/stock-table/MiniIntraday'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table'
import { getSignals, signalCls, getSortValue, getIntradaySortValue, UNSORTABLE_KEYS } from '@/lib/stock-table'
import { resolveCandleConfig, resolveIntradayConfig } from '@/lib/list-columns'
import { useQuoteStatus, useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import {
@@ -534,7 +540,7 @@ const StockCard = React.memo(function StockCard({
</span>
{pct != null && (
<span className={`shrink-0 inline-flex items-center px-1.5 py-[2px] rounded text-[11px] tabular-nums ${pctBg}`}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
{fmtPct(pct)}
</span>
)}
</div>
@@ -558,7 +564,7 @@ const StockCard = React.memo(function StockCard({
return (
<span key={col.id} title={col.label}>
<span className="text-secondary">{fieldName}</span>
<span className="text-secondary">{col.label}</span>
<span className="font-mono ml-0.5">
{renderExtValue(
val,
@@ -608,6 +614,10 @@ export function Watchlist() {
const [viewMode, setViewMode] = useState<'table' | 'card'>(() => {
return (storage.watchlistView.get('table') as 'table' | 'card')
})
// 分组卡片总览: 临时整页模式, 不持久化; 关闭(含刷新)后回到原视图设置
const [groupCardsOpen, setGroupCardsOpen] = useState(false)
// 分组统计条: 顶部图形化分组涨跌概览, 会话内开关, 不影响个股视图设置
const [groupStatsOpen, setGroupStatsOpen] = useState(false)
const [dailyKChartVisible, setDailyKChartVisible] = useState(() => {
return storage.watchlistCandle.get(true)
})
@@ -696,12 +706,20 @@ export function Watchlist() {
const extColumnsParam = useMemo(() => buildExtColumnsParam(columns), [columns])
const toggleView = useCallback(() => {
setGroupCardsOpen(false)
setViewMode(v => {
const next = v === 'table' ? 'card' : 'table'
storage.watchlistView.set(next)
return next
})
}, [])
// 分组卡片: 整页临时展示, 开关不触碰个股视图设置
const toggleGroupView = useCallback(() => {
setGroupCardsOpen(open => !open)
}, [])
const toggleGroupStats = useCallback(() => {
setGroupStatsOpen(open => !open)
}, [])
const toggleDailyKChart = useCallback(() => {
setDailyKChartVisible(v => {
const next = !v
@@ -780,11 +798,11 @@ export function Watchlist() {
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
// 批量日k数据 (天数由列配置决定)
// 批量日k数据 (天数由列配置决定; 分组卡片视图不展示蜡烛, 挂起请求)
const klineBatch = useQuery({
queryKey: QK.watchlistKlineBatch(`${symbolsKey}|${candleDays}`),
queryFn: () => api.klineDailyBatch(symbols, candleDays),
enabled: dailyKVisible && symbols.length > 0,
enabled: dailyKVisible && symbols.length > 0 && !groupCardsOpen,
staleTime: 5 * 60_000, // 5 分钟内不重请求
})
@@ -824,7 +842,7 @@ export function Watchlist() {
const minuteBatch = useQuery({
queryKey: QK.minuteBatch(minuteSymbolsKey),
queryFn: () => api.klineMinuteBatch(minuteSymbols),
enabled: intradayVisible && minuteSymbols.length > 0,
enabled: intradayVisible && minuteSymbols.length > 0 && !groupCardsOpen,
staleTime: 10_000,
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false,
})
@@ -895,6 +913,11 @@ export function Watchlist() {
onSuccess: data => qc.setQueryData(QK.watchlistGroups, data),
})
const reorderGroup = useMutation({
mutationFn: (orderedIds: string[]) => api.watchlistGroupReorder(orderedIds),
onSuccess: data => qc.setQueryData(QK.watchlistGroups, data),
})
const deleteGroup = useMutation({
mutationFn: (groupId: string) => api.watchlistGroupDelete(groupId),
onSuccess: (data, groupId) => {
@@ -931,6 +954,12 @@ export function Watchlist() {
const handleGroupChange = useCallback((symbol: string, groupId: string | null) => {
assignGroup.mutate({ symbol, groupId })
}, [assignGroup])
// 分组卡片总览下点击分组 tab / 卡片头 = 钻取该分组: 关闭总览并选中分组,
// 个股视图设置(table/card)保持用户原选择
const handleGroupSelect = useCallback((group: WatchlistGroupFilter) => {
setSelectedGroup(group)
setGroupCardsOpen(false)
}, [])
const listEntries = list.data?.symbols ?? []
const allSymbols = listEntries.map(s => s.symbol)
@@ -947,6 +976,15 @@ export function Watchlist() {
),
[listEntries, rows],
)
// 分组「指标 + 排序 + 卡片显示项」配置: 分组统计条与分组卡片共享同一份持久化设置
const [groupStatsConfig, setGroupStatsConfig] = useState(loadGroupStatsConfig)
const updateGroupStatsConfig = useCallback((patch: GroupStatsConfigPatch) => {
setGroupStatsConfig(prev => {
const next = { ...prev, ...patch }
storage.watchlistGroupStats.set(next)
return next
})
}, [])
const groupCounts = useMemo(() => {
const counts: Record<string, number> = { ungrouped: 0 }
for (const entry of listEntries) {
@@ -1078,8 +1116,15 @@ export function Watchlist() {
const hasBoardFilter = boardFilter.size > 0 && boardFilter.size < BOARDS.length
const hasActiveFilters = activeFilterCount > 0 || hasBoardFilter
// 排序(复用共享三态排序 hook)
const { sort, toggle: handleSortToggle, sortRows } = useTableSort()
// 排序(复用共享三态排序 hook)。分时列按「最新分钟收盘 vs 昨收」排序(分时图最后一点同口径),
// 其余列走共享取值;眼睛关闭时不拉分钟数据,取值为 null → 保持原序。
const getWatchlistSortValue = useCallback((r: any, col: ColumnConfig) => {
if (col.source.type === 'builtin' && col.source.key === 'intraday') {
return getIntradaySortValue(r, minuteData[r.symbol])
}
return getSortValue(r, col)
}, [minuteData])
const { sort, toggle: handleSortToggle, sortRows } = useTableSort(getWatchlistSortValue)
const sortedRows = useMemo(
() => sortRows(filteredRows, columns),
@@ -1088,7 +1133,7 @@ export function Watchlist() {
const cardColumns = useCardColumnCount()
const cardGridRef = useRef<HTMLDivElement>(null)
const virtualizeCards = viewMode === 'card' && sortedRows.length > VIRTUAL_LIST_THRESHOLD
const virtualizeCards = viewMode === 'card' && !groupCardsOpen && sortedRows.length > VIRTUAL_LIST_THRESHOLD
const cardRowCount = Math.ceil(sortedRows.length / cardColumns)
const { getScrollElement: getCardScrollElement, scrollMargin: cardScrollMargin } = useParentScroll(
cardGridRef,
@@ -1234,6 +1279,34 @@ export function Watchlist() {
>
{viewMode === 'table' ? <LayoutGrid className="h-4 w-4" /> : <List className="h-4 w-4" />}
</button>
{/* 分组卡片视图 */}
<button
onClick={toggleGroupView}
aria-pressed={groupCardsOpen}
className={`inline-flex items-center justify-center h-8 w-8 rounded-btn transition-colors duration-150 ease-smooth ${
groupCardsOpen
? 'bg-accent/15 text-accent hover:bg-accent/25'
: 'bg-elevated text-secondary hover:bg-elevated/80 hover:text-foreground'
}`}
title={groupCardsOpen ? '退出分组卡片' : '分组卡片视图'}
aria-label={groupCardsOpen ? '退出分组卡片' : '分组卡片视图'}
>
<Rows3 className="h-4 w-4" />
</button>
{/* 分组统计条 */}
<button
onClick={toggleGroupStats}
aria-pressed={groupStatsOpen}
className={`inline-flex items-center justify-center h-8 w-8 rounded-btn transition-colors duration-150 ease-smooth ${
groupStatsOpen
? 'bg-accent/15 text-accent hover:bg-accent/25'
: 'bg-elevated text-secondary hover:bg-elevated/80 hover:text-foreground'
}`}
title={groupStatsOpen ? '收起分组统计' : '分组统计'}
aria-label={groupStatsOpen ? '收起分组统计' : '分组统计'}
>
<BarChart3 className="h-4 w-4" />
</button>
<div className="w-px h-5 bg-border" />
{/* 自定义列 / 刷新 */}
<button
@@ -1263,21 +1336,44 @@ export function Watchlist() {
</button>
</>
)}
{/* 扩展插槽: 自选页工具栏二开区 (无注册时不渲染) */}
<ExtensionSlot
name="watchlist.toolbar"
context={{
symbols: sortedRows.map((row: any) => row.symbol),
viewMode,
selectedGroup,
refresh: () => enriched.refetch(),
}}
/>
</div>
}
/>
{groupStatsOpen && (
<WatchlistGroupStatsBar
groups={groups}
counts={groupCounts}
pcts={groupPcts}
selected={selectedGroup}
onSelect={handleGroupSelect}
config={groupStatsConfig}
onConfigChange={updateGroupStatsConfig}
/>
)}
<WatchlistGroupBar
groups={groups}
counts={groupCounts}
selected={selectedGroup}
total={allSymbols.length}
pcts={groupPcts}
onSelect={setSelectedGroup}
onSelect={handleGroupSelect}
onCreate={(name, color) => createGroup.mutateAsync({ name, color }).then(() => undefined)}
onRename={(groupId, name, color) => renameGroup.mutateAsync({ groupId, name, color }).then(() => undefined)}
onDelete={groupId => deleteGroup.mutateAsync(groupId).then(() => undefined)}
onClearGroup={groupId => clearGroup.mutateAsync(groupId).then(() => undefined)}
onReorder={orderedIds => reorderGroup.mutateAsync(orderedIds).then(() => undefined)}
/>
{/* 筛选栏 */}
@@ -1374,6 +1470,17 @@ export function Watchlist() {
title="该分组暂无标的"
hint="使用右上角搜索添加,或通过股票旁的分组按钮移入当前分组。"
/>
) : groupCardsOpen ? (
<WatchlistGroupCards
groups={groups}
rows={rows}
groupBySymbol={groupBySymbol}
pcts={groupPcts}
onPreview={handleCardPreview}
onOpenGroup={handleGroupSelect}
config={groupStatsConfig}
onConfigChange={updateGroupStatsConfig}
/>
) : viewMode === 'table' ? (
<StockDataTable
columns={visibleColumns}
@@ -1381,6 +1488,7 @@ export function Watchlist() {
headerSticky
sort={sort}
onSortToggle={handleSortToggle}
extraSortableKeys={INTRADAY_SORTABLE_KEYS}
rowKey={(r: any) => r.symbol}
rowClassName={() => 'border-t border-border hover:bg-elevated/50 transition-colors duration-150 ease-smooth'}
// 日k列表头:标签 + 显示/隐藏眼睛按钮