mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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 等宽数字) */
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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 '暂无涨跌幅数据'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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列表头:标签 + 显示/隐藏眼睛按钮
|
||||
|
||||
Reference in New Issue
Block a user