From a8a573a8348a1273edc7bb8edc24e6d1c1b828e8 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Wed, 19 Aug 2026 22:14:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(watchlist):=20=E5=88=86=E7=BB=84=E5=8D=A1?= =?UTF-8?q?=E7=89=87/=E7=BB=9F=E8=AE=A1=E6=9D=A1=E5=AE=8C=E5=96=84=20+=20?= =?UTF-8?q?=E5=88=86=E7=BB=84=E6=8E=92=E5=BA=8F=20+=20=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E8=87=AA=E9=80=89=E5=AF=BC=E5=85=A5=20+=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=8F=92=E6=A7=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组卡片视图 (WatchlistGroupCards): - 新增整页卡片总览: 组内按涨跌幅降序, 默认前 N 条可展开 - 头部重排: 名称+指标数值居左, 总数+N涨/N跌居右 (红涨绿跌着色) - 成员行: 创/科/北板块标识移至名称后; 序号列可开关 - 卡片设置 (GroupStatsSettings): 指标/排序/前N条/头部颜色/序号, 与统计条共享持久化 分组统计条 (WatchlistGroupStatsBar): - 中轴分叉条形图概览, 指标与排序可配置 - 行尾 N涨/N跌 红绿着色 分组管理: - 后端分组重排端点 + service + 测试 (9 passed) - 管理弹窗上移/下移 + 拖拽排序, 顺序贯通标签栏/统计条/卡片/侧栏 自选交互: - 修复 WatchlistAddMenu 内部滚动误关闭 (capture 目标包含判断) - 监控规则编辑器: 指定标的支持从自选/自选分组批量导入 (去重合并) 前端扩展插槽: - 新增 stock-preview.footer / watchlist.toolbar 插槽及 context 契约, 更新二开文档 --- backend/app/api/watchlist.py | 14 + backend/app/services/watchlist.py | 12 + backend/tests/test_watchlist_groups.py | 38 +++ docs/secondary-development.md | 12 +- .../src/components/GroupStatsSettings.tsx | 170 +++++++++++ .../src/components/StockPreviewDialog.tsx | 9 + frontend/src/components/WatchlistAddMenu.tsx | 6 +- .../src/components/WatchlistGroupCards.tsx | 277 ++++++++++++++++++ .../src/components/WatchlistGroupStatsBar.tsx | 161 ++++++++++ frontend/src/components/WatchlistGroups.tsx | 116 +++++++- .../src/components/monitor/RuleEditor.tsx | 96 +++++- .../components/stock-table/StockDataTable.tsx | 5 +- .../components/stock-table/useTableSort.ts | 7 +- .../custom/_template/extension.tsx.example | 32 ++ frontend/src/extensions/registry.ts | 3 +- frontend/src/extensions/types.ts | 15 + frontend/src/lib/api.ts | 5 + frontend/src/lib/stock-table.ts | 11 + frontend/src/lib/storage.ts | 5 +- frontend/src/lib/watchlistGroupStats.ts | 155 +++++++++- frontend/src/pages/Data.tsx | 9 + frontend/src/pages/Watchlist.tsx | 132 ++++++++- 22 files changed, 1259 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/GroupStatsSettings.tsx create mode 100644 frontend/src/components/WatchlistGroupCards.tsx create mode 100644 frontend/src/components/WatchlistGroupStatsBar.tsx diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py index 2ff2966..b812e9c 100644 --- a/backend/app/api/watchlist.py +++ b/backend/app/api/watchlist.py @@ -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: diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py index 7444cbb..212c9f9 100644 --- a/backend/app/services/watchlist.py +++ b/backend/app/services/watchlist.py @@ -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: diff --git a/backend/tests/test_watchlist_groups.py b/backend/tests/test_watchlist_groups.py index 6452e87..a20c42b 100644 --- a/backend/tests/test_watchlist_groups.py +++ b/backend/tests/test_watchlist_groups.py @@ -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() diff --git a/docs/secondary-development.md b/docs/secondary-development.md index 644ac41..c7e1081 100644 --- a/docs/secondary-development.md +++ b/docs/secondary-development.md @@ -20,7 +20,7 @@ - 扩展数据与声明式分析页面:适合不需要自定义 React 交互的页面。 - 前端源码扩展注册:`frontend/src/custom//extension.tsx`,支持静态页面、导航和已开放插槽。 - 后端源码扩展注册:`backend/app/custom/.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 当前路由与导航契约 diff --git a/frontend/src/components/GroupStatsSettings.tsx b/frontend/src/components/GroupStatsSettings.tsx new file mode 100644 index 0000000..d024232 --- /dev/null +++ b/frontend/src/components/GroupStatsSettings.tsx @@ -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(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 ( +
+ + {open && ( +
+
指标
+
+ {GROUP_METRICS.map(m => ( + + ))} +
+
排序
+
+ {GROUP_SORT_OPTIONS.map(s => ( + + ))} +
+ {showCardLimit && ( + <> +
卡片显示
+
+ + + 前 {config.cardTopN} 条 + + +
+
+
+ 头部颜色 + +
+
+ 序号 + +
+
+ + )} +
+ {GROUP_METRICS.find(m => m.id === config.metric)?.hint} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 2f49403..6aca10e 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -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 )} + {/* 扩展插槽: 对话框底部二开区 (无注册时不渲染) */} +
+ +
+ {/* 加监控编辑器弹层 */} {showMonitorEditor && symbol && ( diff --git a/frontend/src/components/WatchlistAddMenu.tsx b/frontend/src/components/WatchlistAddMenu.tsx index bea247d..1728906 100644 --- a/frontend/src/components/WatchlistAddMenu.tsx +++ b/frontend/src/components/WatchlistAddMenu.tsx @@ -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() diff --git a/frontend/src/components/WatchlistGroupCards.tsx b/frontend/src/components/WatchlistGroupCards.tsx new file mode 100644 index 0000000..28371b9 --- /dev/null +++ b/frontend/src/components/WatchlistGroupCards.tsx @@ -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 ( +
+ {/* 头部: 色点 + 名称 + 指标数值居左, 总数 + 涨跌家数居右; 点击钻取该分组 */} + + + {/* 组内榜单: 按涨跌幅降序 */} + {data.rows.length === 0 ? ( +
暂无标的
+ ) : ( +
+ {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 ( + + ) + })} +
+ )} + + {/* 展开/收起 */} + {hasMore && ( + + )} +
+ ) +}) + +interface WatchlistGroupCardsProps { + groups: WatchlistGroup[] + /** enriched 全量行 (未经过分组/板块筛选) */ + rows: any[] + /** symbol -> group_id (null = 未分组), 来自自选列表查询 */ + groupBySymbol: Map + /** 分组等权涨跌幅统计 */ + 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>(() => 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(() => { + const buckets = new Map() + 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() + 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 ( +
+
+
分组卡片 · {metricLabel}
+ +
+
+ {ordered.map(card => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/WatchlistGroupStatsBar.tsx b/frontend/src/components/WatchlistGroupStatsBar.tsx new file mode 100644 index 0000000..0aa12b9 --- /dev/null +++ b/frontend/src/components/WatchlistGroupStatsBar.tsx @@ -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 + 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((acc, r) => { + const v = valueOf(r.key) + if (v != null) acc.push(magnitude(v)) + return acc + }, [0.0001])) + + return ( +
+
+
分组涨跌 · {metricLabel}
+ +
+
+ {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 ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/WatchlistGroups.tsx b/frontend/src/components/WatchlistGroups.tsx index f636488..d525b42 100644 --- a/frontend/src/components/WatchlistGroups.tsx +++ b/frontend/src/components/WatchlistGroups.tsx @@ -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 onDelete: (groupId: string) => Promise onClearGroup?: (groupId: string) => Promise + /** 手动调整分组前后顺序 (持久化到后端) */ + onReorder?: (orderedIds: string[]) => Promise } 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(null) + const [dropIndex, setDropIndex] = useState(null) + const reorderable = !!onReorder && groups.length > 1 + const tablistRef = useRef(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 ( <>
-
- {tabs.map(tab => { +
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 ( + + + )} ))} + {/* 从自选/自选分组批量导入标的 */} +
+ + {watchMenuOpen && ( +
+ {watchlistQ.isLoading ? ( +
正在加载自选...
+ ) : watchImportOptions.length === 0 ? ( +
自选列表为空
+ ) : watchImportOptions.map(option => ( + + ))} +
+ )} +
void + /** 实例级放行: 让 UNSORTABLE_KEYS 中的 builtin 列在本表也可排序 (如自选页分时列) */ + extraSortableKeys?: ReadonlySet /** 追加在每行末尾的额外单元格(如自选页的操作列) */ 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 } diff --git a/frontend/src/components/stock-table/useTableSort.ts b/frontend/src/components/stock-table/useTableSort.ts index aea5073..20f62f2 100644 --- a/frontend/src/components/stock-table/useTableSort.ts +++ b/frontend/src/components/stock-table/useTableSort.ts @@ -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(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) diff --git a/frontend/src/custom/_template/extension.tsx.example b/frontend/src/custom/_template/extension.tsx.example index b9596e6..2e4c063 100644 --- a/frontend/src/custom/_template/extension.tsx.example +++ b/frontend/src/custom/_template/extension.tsx.example @@ -9,6 +9,28 @@ function NavigationExtra({ collapsed }: { collapsed: boolean; pathname: string } return collapsed ? null :
二开内容
} +/** 个股详情对话框底部: 自带分隔与内边距 */ +function StockPreviewFooter({ symbol, name }: { symbol: string; name: string | null; view: 'daily' | 'intraday' }) { + return ( +
+ {symbol} {name} · 二开面板示例 +
+ ) +} + +/** 自选页工具栏: 按钮尺寸与核心工具栏一致 */ +function WatchlistToolbar({ symbols, refresh }: { symbols: string[]; viewMode: 'table' | 'card'; selectedGroup: string; refresh: () => void }) { + return ( + + ) +} + 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, + }, ], } diff --git a/frontend/src/extensions/registry.ts b/frontend/src/extensions/registry.ts index 4934904..f5e5a60 100644 --- a/frontend/src/extensions/registry.ts +++ b/frontend/src/extensions/registry.ts @@ -174,7 +174,8 @@ export function getFrontendExtensionNavigation() { export function getFrontendSlotRegistrations(name: K) { if (!frozen) throw new Error('读取扩展插槽前必须冻结注册表') - return (slots.get(name) ?? []) as Array & { extensionId: string }> + // 存储按槽位名分桶, 桶内注册项的 name 必与键一致, 断言安全 + return (slots.get(name) ?? []) as unknown as Array & { extensionId: string }> } export function getFrontendExtensionLoadErrors() { diff --git a/frontend/src/extensions/types.ts b/frontend/src/extensions/types.ts index 9ab4962..cb28489 100644 --- a/frontend/src/extensions/types.ts +++ b/frontend/src/extensions/types.ts @@ -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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a652ede..f0a91f4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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)}`, diff --git a/frontend/src/lib/stock-table.ts b/frontend/src/lib/stock-table.ts index 50dfdc7..8953a12 100644 --- a/frontend/src/lib/stock-table.ts +++ b/frontend/src/lib/stock-table.ts @@ -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 等宽数字) */ diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index b6f9457..85d9d77 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -42,7 +42,7 @@ export const storage = { /** 策略结果列表列配置 */ screenerResultColumns: kv('screener_result_columns'), - /** 自选列表视图模式 table | card */ + /** 自选列表视图模式 table | card (分组卡片为临时模式, 不持久化) */ watchlistView: kv('watchlist_view'), /** 自选列表日K蜡烛图显示状态 */ @@ -60,6 +60,9 @@ export const storage = { /** 自选列表板块筛选 */ watchlistBoardFilter: kv('watchlist_boardFilter'), + /** 自选分组统计条配置 (metric: 统计指标, sort: 排序方式, card*: 分组卡片显示项) */ + watchlistGroupStats: kv<{ metric: string; sort: string; cardTopN?: number; cardColorBar?: boolean; cardRank?: boolean }>('watchlist_groupStats'), + /** Screener 卡片尺寸 */ screenerCardSize: kv('screener-card-size'), diff --git a/frontend/src/lib/watchlistGroupStats.ts b/frontend/src/lib/watchlistGroupStats.ts index 4bab1e9..4c6e165 100644 --- a/frontend/src/lib/watchlistGroupStats.ts +++ b/frontend/src/lib/watchlistGroupStats.ts @@ -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 +/** + * 单只标的的展示涨跌幅: 实时优先、收盘兜底(与自选表格/卡片展示同源, + * 小数单位), 无有效数据为 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, @@ -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> + +/** 按配置排序分组键列表 (null 排最后), sort='default' 时原序返回 */ +export function sortGroupKeys(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 '暂无涨跌幅数据' diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index 726907a..7be680d 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -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) } diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index 6df9502..5bd7a91 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -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({ {pct != null && ( - {isUp ? '+' : ''}{pct.toFixed(2)}% + {fmtPct(pct)} )}
@@ -558,7 +564,7 @@ const StockCard = React.memo(function StockCard({ return ( - {fieldName} + {col.label} {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 = { 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(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' ? : } + {/* 分组卡片视图 */} + + {/* 分组统计条 */} +
{/* 自定义列 / 刷新 */}
} /> + {groupStatsOpen && ( + + )} + 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 ? ( + ) : viewMode === 'table' ? ( r.symbol} rowClassName={() => 'border-t border-border hover:bg-elevated/50 transition-colors duration-150 ease-smooth'} // 日k列表头:标签 + 显示/隐藏眼睛按钮