feat(web): 用紧凑卡片替换选股表格并精简资金雷达列
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量门禁与测试规范"}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件可访问性与列表交互规范"}
|
||||
{"file": ".trellis/tasks/09-01-optimize-stock-list-radar/prd.md", "reason": "用户需求与验收标准"}
|
||||
@@ -0,0 +1,19 @@
|
||||
# 技术设计
|
||||
|
||||
## 边界与组件结构
|
||||
|
||||
选股模块保留 `SelectionResultsWorkbench` 的筛选、分页、选中状态和详情面板结构,将当前桌面 `SignalTable` 与移动 `SignalRecordList` 的双实现收敛为一套响应式紧凑卡片列表。列表继续位于左侧结果区内部滚动;条目用语义化 `article` 和真实按钮承载选择、展开交互,不改变路由搜索参数或 API 类型。
|
||||
|
||||
资金雷达继续使用语义化表格与现有无限滚动容器,只删除展示层中的资金覆盖率和质量表头/单元格,并同步空状态 `colSpan`。通过缩小表头和数据行的垂直 padding、减少板块名称与代码间距来提高密度,不改动返回数据类型或质量计算。
|
||||
|
||||
## 数据流与兼容性
|
||||
|
||||
两处改动均消费现有响应字段,不改变 query、路由和后端契约。选股卡片仍用 `getStockKey`、`getStockJValue`、`PatternScoreSummary`、`SignalDetails` 和现有 category 样式函数,确保内容与详情行为保持一致。资金雷达保留 `quality` 和 `moneyflow_coverage` 在 TypeScript 响应类型中,仅不展示,以避免形成不必要的接口破坏。
|
||||
|
||||
## 取舍
|
||||
|
||||
选股列表采用单一响应式组件,消除桌面/移动两套 DOM 和测试分叉;卡片通过横向信息区和较小间距兼顾密度与窄屏换行。资金雷达保留 table,因为用户仅要求缩减列和行高,表格仍最适合对齐排名数据。
|
||||
|
||||
## 回滚
|
||||
|
||||
改动局限于选股展示组件、工作台装配、资金雷达页面及对应测试;如出现视觉或交互回归,可逐文件回退,不涉及数据迁移。
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "前端开发入口与检查清单"}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件、样式与可访问性规范"}
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "测试与质量门禁,约束卡片列表与雷达表格的断言方式"}
|
||||
@@ -0,0 +1,10 @@
|
||||
# 实施计划
|
||||
|
||||
1. 将选股结果收敛为单一紧凑卡片列表,保留选中、键盘、展开详情、空状态与内部滚动。
|
||||
2. 更新工作台装配,移除 table 展示路径和重复组件引用,并按需要删除不再使用的文件。
|
||||
3. 更新选股页面测试,改为断言卡片列表结构、核心字段和交互,不再依赖 table 表头。
|
||||
4. 从资金雷达表格移除资金覆盖率与质量两列,修正 `colSpan` 并缩小表头、数据行间距。
|
||||
5. 更新资金雷达测试,明确断言精简列不存在而保留百分位和样本;保留无限滚动与排名变化回归覆盖。
|
||||
6. 在 `zhixing-web/` 运行目标 Vitest,然后运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`;必要时运行 `pnpm build` 验证产物。
|
||||
|
||||
风险点是选股卡片按钮嵌套和键盘行为、断点下滚动高度链,以及资金雷达 rank-change 模式的动态列数。实现时以语义结构和现有页面布局约束为准,出现回归时优先回滚对应组件的局部改动。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 优化选股列表与资金雷达看板布局
|
||||
|
||||
## Goal
|
||||
|
||||
提升选股结果和资金雷达的浏览密度与可读性,让用户在同一屏看到更多条目,并减少不必要的辅助指标占用。
|
||||
|
||||
## Background and confirmed facts
|
||||
|
||||
当前选股结果在桌面端由 `features/selection/components/signal-table.tsx` 以 `<table>` 展示,移动端另有 `signal-record-list.tsx` 卡片列表;工作台同时挂载两者并通过断点切换。资金雷达页面的 `RadarTable` 表头和行包含“排名百分位、样本、资金覆盖率、质量”等列,行使用较大的垂直 padding。
|
||||
|
||||
## Requirements
|
||||
|
||||
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和详情展开能力。列表容器应继续支持内部滚动和空结果提示,分页与筛选行为保持不变。
|
||||
2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。
|
||||
3. 现有筛选、排序、加载更多和选中股票详情的数据契约不变;仅调整展示层结构和样式。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。
|
||||
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项;“查看详情/收起详情”可展开信号细节。
|
||||
- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。
|
||||
- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。
|
||||
- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。
|
||||
|
||||
## Out of scope
|
||||
|
||||
不调整后端接口、指标计算、筛选/排序语义、分页参数、详情面板字段或全局主题 token。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "optimize-stock-list-radar",
|
||||
"name": "optimize-stock-list-radar",
|
||||
"title": "优化选股列表与资金雷达看板布局",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "codex",
|
||||
"assignee": "codex",
|
||||
"createdAt": "2026-09-01",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -175,8 +175,22 @@ describe("SectorRadarPage", () => {
|
||||
|
||||
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("样本有限")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("95%")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("19/20")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("100%")[0]).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole("columnheader", { name: "排名百分位" }),
|
||||
).toHaveLength(2)
|
||||
expect(screen.getAllByRole("columnheader", { name: "样本" })).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole("columnheader", { name: "资金覆盖率" }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole("columnheader", { name: "质量" }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("样本有限")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("95%")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/features/sector-radar/api/sector-radar.query"
|
||||
import type {
|
||||
RadarMetricKind,
|
||||
RadarMetricQuality,
|
||||
RadarMetricUnit,
|
||||
RadarPublication,
|
||||
RadarRankingRow,
|
||||
@@ -18,7 +17,6 @@ import type {
|
||||
RadarView,
|
||||
SectorRadarRouteSearch,
|
||||
} from "@/features/sector-radar/api/sector-radar.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -398,83 +396,71 @@ function RadarTable({
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
>
|
||||
<table className="w-full min-w-[1280px] border-collapse text-sm">
|
||||
<table className="w-full min-w-[960px] border-collapse text-sm">
|
||||
<caption className="sr-only">
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
||||
</caption>
|
||||
<colgroup>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<col
|
||||
key={i}
|
||||
className={i === 5 || i === 6 ? "w-[120px]" : "w-[100px]"}
|
||||
className={i === 3 || i === 4 ? "w-[120px]" : "w-[100px]"}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
|
||||
<tr>
|
||||
<th
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
colSpan={5}
|
||||
className="px-3 py-1 text-left font-medium"
|
||||
colSpan={3}
|
||||
scope="colgroup"
|
||||
>
|
||||
资金进攻榜 TOP 10%
|
||||
</th>
|
||||
<th
|
||||
className="border-x border-border px-3 py-2 text-center font-medium"
|
||||
className="border-x border-border px-3 py-1 text-center font-medium"
|
||||
colSpan={2}
|
||||
scope="colgroup"
|
||||
>
|
||||
{rankChangeView ? "排名变化" : response.definition.label}
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-right font-medium"
|
||||
colSpan={5}
|
||||
className="px-3 py-1 text-right font-medium"
|
||||
colSpan={3}
|
||||
scope="colgroup"
|
||||
>
|
||||
BOTTOM 10% 资金撤离榜
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
||||
<th className="px-3 py-1 text-left font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
资金覆盖率
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
||||
质量
|
||||
</th>
|
||||
<th
|
||||
className="border-l border-border px-3 py-2 text-right font-medium"
|
||||
className="border-l border-border px-3 py-1 text-right font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名上升" : "流入"}
|
||||
</th>
|
||||
<th
|
||||
className="border-r border-border px-3 py-2 text-left font-medium"
|
||||
className="border-r border-border px-3 py-1 text-left font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名下降" : "流出"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
质量
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
资金覆盖率
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
</tr>
|
||||
@@ -491,8 +477,8 @@ function RadarTable({
|
||||
{rowCount === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-3 py-12 text-center text-muted-foreground"
|
||||
colSpan={12}
|
||||
className="px-3 py-8 text-center text-muted-foreground"
|
||||
colSpan={8}
|
||||
>
|
||||
没有符合当前筛选条件的板块。
|
||||
</td>
|
||||
@@ -582,42 +568,49 @@ function RankCells({
|
||||
row?: RadarRankingRow
|
||||
align: "left" | "right"
|
||||
}) {
|
||||
if (!row)
|
||||
return (
|
||||
<>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">—</td>
|
||||
<td />
|
||||
<td />
|
||||
<td />
|
||||
<td />
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} font-semibold tabular-nums`}
|
||||
>
|
||||
const rankClass = align === "right" ? "text-right" : "text-left"
|
||||
const rankCell = row ? (
|
||||
<td className={`px-3 py-1.5 ${rankClass} font-semibold tabular-nums`}>
|
||||
{row.rank_position ?? "—"}
|
||||
<span className="mx-1 font-normal text-muted-foreground">/</span>
|
||||
<span className="mx-0.5 font-normal text-muted-foreground">/</span>
|
||||
<span className="font-medium text-foreground">{row.sector_name}</span>
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
<span className="ml-0.5 text-[11px] font-normal text-muted-foreground">
|
||||
{row.sector_code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
) : (
|
||||
<td className="px-3 py-1.5 text-muted-foreground">—</td>
|
||||
)
|
||||
const percentileCell = row ? (
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||
{row.rank_percentile === null
|
||||
? "—"
|
||||
: `${formatNumber(row.rank_percentile, 2)}%`}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
) : (
|
||||
<td />
|
||||
)
|
||||
const sampleCell = row ? (
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||
{row.valid_sample_count}/{row.member_count}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{formatCoverage(row.moneyflow_coverage)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<QualityBadge quality={row.quality} />
|
||||
</td>
|
||||
) : (
|
||||
<td />
|
||||
)
|
||||
if (align === "right") {
|
||||
return (
|
||||
<>
|
||||
{sampleCell}
|
||||
{percentileCell}
|
||||
{rankCell}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{rankCell}
|
||||
{percentileCell}
|
||||
{sampleCell}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -652,7 +645,7 @@ function MetricCell({
|
||||
: "text-foreground bg-muted/30"
|
||||
return (
|
||||
<td
|
||||
className={`${borderClass} border-border px-3 py-2.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
||||
className={`${borderClass} border-border px-3 py-1.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
@@ -673,19 +666,6 @@ function formatDirectionalMetric(
|
||||
return `${direction === "in" ? "+" : "−"}${formatted}`
|
||||
}
|
||||
|
||||
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
||||
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
||||
if (quality === "available_limited_sample")
|
||||
return (
|
||||
<Badge
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
||||
variant="outline"
|
||||
>
|
||||
样本有限
|
||||
</Badge>
|
||||
)
|
||||
return <Badge variant="destructive">不可用</Badge>
|
||||
}
|
||||
function RadarLoading() {
|
||||
return (
|
||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||
@@ -768,9 +748,6 @@ function formatRankChange(value: number | null) {
|
||||
if (value < 0) return `${value} 名`
|
||||
return "0 名"
|
||||
}
|
||||
function formatCoverage(value: number) {
|
||||
return `${formatNumber(value * 100, 1)}%`
|
||||
}
|
||||
function formatNumber(value: number, maximumFractionDigits: number) {
|
||||
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
signalCategoryOptions,
|
||||
type SignalCategoryFilter,
|
||||
} from "./selection-presentation"
|
||||
import { SignalTable } from "./signal-table"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
||||
const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
||||
@@ -195,15 +194,11 @@ export function SelectionResultsWorkbench({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
||||
<SignalTable
|
||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||
stocks={currentStocks}
|
||||
/>
|
||||
<SignalRecordList
|
||||
expandedKeys={expandedKeys}
|
||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||
stocks={currentStocks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
||||
import type { KeyboardEvent } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
|
||||
@@ -15,6 +17,7 @@ import { SignalDetails } from "./signal-details"
|
||||
|
||||
interface SignalRecordListProps {
|
||||
stocks: SelectionStockResult[]
|
||||
selectedKey: string | null
|
||||
expandedKeys: ReadonlySet<string>
|
||||
onSelect: (stock: SelectionStockResult) => void
|
||||
onToggleExpanded: (stock: SelectionStockResult) => void
|
||||
@@ -22,47 +25,64 @@ interface SignalRecordListProps {
|
||||
|
||||
export function SignalRecordList({
|
||||
stocks,
|
||||
selectedKey,
|
||||
expandedKeys,
|
||||
onSelect,
|
||||
onToggleExpanded,
|
||||
}: SignalRecordListProps) {
|
||||
function handleSelectKeyDown(
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
stock: SelectionStockResult,
|
||||
) {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
onSelect(stock)
|
||||
}
|
||||
|
||||
if (stocks.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
|
||||
<div className="flex min-h-32 flex-1 items-center justify-center overflow-auto px-4 text-sm text-muted-foreground">
|
||||
没有符合当前筛选条件的信号。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/60 md:hidden">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="divide-y divide-border/60">
|
||||
{stocks.map((stock) => {
|
||||
const key = getStockKey(stock)
|
||||
const selected = selectedKey === key
|
||||
const expanded = expandedKeys.has(key)
|
||||
return (
|
||||
<article key={key} className="space-y-2 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
onClick={() => onSelect(stock)}
|
||||
<article
|
||||
key={key}
|
||||
className={cn(
|
||||
"space-y-1 px-2.5 py-1.5 transition-colors hover:bg-muted/50",
|
||||
selected && "bg-muted/70",
|
||||
)}
|
||||
data-selected={selected}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
className="min-h-11 min-w-0 flex-1 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
onClick={() => onSelect(stock)}
|
||||
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
{stock.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{stock.ts_code}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-medium tabular-nums text-foreground">
|
||||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="text-xs">
|
||||
<PatternScoreSummary score={stock.score} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
@@ -72,17 +92,19 @@ export function SignalRecordList({
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
</span>
|
||||
<PatternScoreSummary score={stock.score} />
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
J
|
||||
<strong className="font-semibold text-foreground">
|
||||
{getStockJValue(stock)}
|
||||
</strong>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
||||
className="min-h-11 shrink-0 gap-1 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onToggleExpanded(stock)}
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -94,7 +116,6 @@ export function SignalRecordList({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="space-y-3">
|
||||
<section className="space-y-2">
|
||||
@@ -120,5 +141,6 @@ export function SignalRecordList({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { KeyboardEvent } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import { PatternScoreSummary } from "./pattern-score"
|
||||
import {
|
||||
categoryToneClass,
|
||||
getCategoryLabel,
|
||||
getStockJValue,
|
||||
getStockKey,
|
||||
} from "./selection-presentation"
|
||||
|
||||
interface SignalTableProps {
|
||||
stocks: SelectionStockResult[]
|
||||
selectedKey: string | null
|
||||
onSelect: (stock: SelectionStockResult) => void
|
||||
}
|
||||
|
||||
export function SignalTable({
|
||||
stocks,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
}: SignalTableProps) {
|
||||
function handleRowKeyDown(
|
||||
event: KeyboardEvent<HTMLTableRowElement>,
|
||||
stock: SelectionStockResult,
|
||||
) {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
onSelect(stock)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden min-h-0 flex-1 overflow-auto md:block">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<caption className="sr-only">知行 B1 命中信号明细</caption>
|
||||
<thead className="sticky top-0 z-10 bg-card text-[11px] text-muted-foreground">
|
||||
<tr className="border-b border-border/70">
|
||||
<th className="h-9 px-3 font-medium">股票</th>
|
||||
<th className="h-9 px-3 font-medium">信号类型</th>
|
||||
<th className="h-9 px-3 text-right font-medium">图形评分</th>
|
||||
<th className="h-9 px-3 text-right font-medium">J 值</th>
|
||||
<th className="h-9 px-3 text-right font-medium">收盘价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.length > 0 ? (
|
||||
stocks.map((stock) => {
|
||||
const selected = selectedKey === getStockKey(stock)
|
||||
return (
|
||||
<tr
|
||||
key={getStockKey(stock)}
|
||||
aria-selected={selected}
|
||||
className="cursor-pointer border-b border-border/50 outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/60 data-[selected=true]:bg-muted/70"
|
||||
data-selected={selected}
|
||||
onClick={() => onSelect(stock)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, stock)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
<p className="font-medium text-foreground">{stock.name}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{stock.ts_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 align-middle">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle">
|
||||
<PatternScoreSummary score={stock.score} />
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
|
||||
{getStockJValue(stock)}
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-32 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={5}
|
||||
>
|
||||
没有符合当前筛选条件的信号。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -143,31 +143,18 @@ describe("SelectionResultsPage", () => {
|
||||
|
||||
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
||||
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
||||
const table = screen.getByRole("table")
|
||||
expect(table).toBeInTheDocument()
|
||||
expect(
|
||||
within(table).getByRole("columnheader", { name: "信号类型" }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(table).getByRole("columnheader", { name: "J 值" }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(table).getByRole("columnheader", { name: "图形评分" }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(table).queryByRole("columnheader", { name: "关键详情" }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
||||
expect(within(table).queryByText("13")).not.toBeInTheDocument()
|
||||
expect(within(table).getByText("平安银行")).toBeInTheDocument()
|
||||
expect(within(table).getByText("86.40 分")).toBeInTheDocument()
|
||||
expect(within(table).getByText("华纳药厂")).toBeInTheDocument()
|
||||
|
||||
const mobileRecord = screen.getAllByRole("article")[0]
|
||||
expect(within(mobileRecord).getByText("12")).toBeInTheDocument()
|
||||
expect(within(mobileRecord).queryByText("13")).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument()
|
||||
const record = screen.getByRole("article")
|
||||
expect(within(record).getByText("平安银行")).toBeInTheDocument()
|
||||
expect(within(record).getByText("000001.SZ")).toBeInTheDocument()
|
||||
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(record).getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(record).getByText("86.40 分")).toBeInTheDocument()
|
||||
expect(within(record).getByText("12")).toBeInTheDocument()
|
||||
expect(within(record).queryByText("13")).not.toBeInTheDocument()
|
||||
expect(within(record).getByText("¥ 10.50")).toBeInTheDocument()
|
||||
expect(within(record).getByText("华纳药厂")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument()
|
||||
|
||||
const detailPanel = screen.getByRole("complementary", {
|
||||
name: "当前股票详情",
|
||||
@@ -249,9 +236,9 @@ describe("SelectionResultsPage", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getAllByText(expected).length).toBeGreaterThan(0)
|
||||
const table = screen.getByRole("table")
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
||||
const record = screen.getByRole("article")
|
||||
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(record).getByText("12")).toBeInTheDocument()
|
||||
if (score?.status === "below_threshold") {
|
||||
const detailPanel = screen.getByRole("complementary", {
|
||||
name: "当前股票详情",
|
||||
@@ -558,16 +545,27 @@ describe("SelectionResultsPage", () => {
|
||||
fireEvent.change(screen.getByLabelText("搜索命中股票"), {
|
||||
target: { value: "招商" },
|
||||
})
|
||||
const table = screen.getByRole("table")
|
||||
expect(within(table).getByText("招商银行")).toBeInTheDocument()
|
||||
fireEvent.click(within(table).getByText("招商银行"))
|
||||
const extraRecord = screen
|
||||
.getAllByRole("article")
|
||||
.find((article) => within(article).queryByText("招商银行"))
|
||||
expect(extraRecord).toBeTruthy()
|
||||
if (!extraRecord) {
|
||||
throw new Error("expected 招商银行 card to be visible")
|
||||
}
|
||||
const selectButton = within(extraRecord).getByRole("button", {
|
||||
name: /招商银行/,
|
||||
})
|
||||
fireEvent.keyDown(selectButton, { key: "Enter" })
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole("complementary", { name: "当前股票详情" }),
|
||||
).getByText("招商银行"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1]
|
||||
fireEvent.keyDown(selectButton, { key: " " })
|
||||
const expandButton = within(extraRecord).getByRole("button", {
|
||||
name: "查看详情",
|
||||
})
|
||||
fireEvent.click(expandButton)
|
||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
||||
|
||||
Reference in New Issue
Block a user