feat(web): 按原型调整响应式研究工作台

This commit is contained in:
yuxuanhui
2026-08-10 10:07:55 +08:00
parent 5b650166b5
commit ed7bdda515
16 changed files with 1319 additions and 350 deletions
@@ -0,0 +1,55 @@
import type { SelectionSignal } from "../api/selection.types"
export const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B",
zhixing_b1_original_b1: "原始 B1",
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
zhixing_b1_oversold_volume: "超卖缩量 B",
zhixing_b1_pullback_super: "回踩超级 B",
zhixing_b1_pullback_white: "回踩白线 B",
zhixing_b1_pullback_yellow: "回踩黄线 B",
}
export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original"
export const signalCategoryOptions: ReadonlyArray<{
label: string
value: SignalCategoryFilter
}> = [
{ label: "全部信号", value: "all" },
{ label: "回踩类", value: "pullback" },
{ label: "超卖类", value: "oversold" },
{ label: "原始 B1", value: "original" },
]
export function getCategoryLabel(category: string) {
return categoryLabels[category] ?? category
}
export function getSignalKey(signal: SelectionSignal) {
return `${signal.ts_code}-${signal.category}`
}
export function formatDetails(details: SelectionSignal["details"]) {
return Object.entries(details)
.map(([key, value]) => `${key}: ${value ?? "—"}`)
.join(" · ")
}
export function matchesCategory(
signal: SelectionSignal,
category: SignalCategoryFilter,
) {
if (category === "all") return true
return signal.category.includes(category)
}
export function categoryToneClass(category: string) {
if (category.includes("pullback")) {
return "border-warning/30 bg-warning/10 text-warning"
}
if (category.includes("oversold")) {
return "border-primary/15 bg-primary/5 text-primary"
}
return "border-success/30 bg-success/10 text-success"
}
@@ -0,0 +1,151 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { Input } from "@/shared/ui/input"
import { Pagination } from "@/shared/ui/pagination"
import type { SelectionResults, SelectionSignal } from "../api/selection.types"
import { SignalDetailPanel } from "./signal-detail-panel"
import { SignalRecordList } from "./signal-record-list"
import {
getSignalKey,
matchesCategory,
signalCategoryOptions,
type SignalCategoryFilter,
} from "./selection-presentation"
import { SignalTable } from "./signal-table"
const PAGE_SIZE_OPTIONS = [5, 10, 20] as const
interface SelectionResultsWorkbenchProps {
result: SelectionResults
}
export function SelectionResultsWorkbench({
result,
}: SelectionResultsWorkbenchProps) {
const search = useSearch({ from: "/_workspace/selection" })
const navigate = useNavigate({ from: "/selection" })
const [query, setQuery] = useState("")
const [category, setCategory] = useState<SignalCategoryFilter>("all")
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
new Set(),
)
const filteredSignals = useMemo(() => {
const normalized = query.trim().toLowerCase()
return result.signals.filter((signal) => {
const matchesQuery =
normalized.length === 0 ||
signal.name.toLowerCase().includes(normalized) ||
signal.ts_code.toLowerCase().includes(normalized)
return matchesQuery && matchesCategory(signal, category)
})
}, [category, query, result.signals])
const page = search.page
const pageSize = search.pageSize
const pageCount = Math.max(1, Math.ceil(filteredSignals.length / pageSize))
const currentPage = Math.min(page, pageCount)
const currentSignals = filteredSignals.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
)
const selectedSignal =
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
currentSignals[0]
useEffect(() => {
if (page > pageCount) {
void navigate({
search: (previous) => ({ ...previous, page: pageCount }),
})
}
}, [navigate, page, pageCount])
function updateSearch(next: { page?: number; pageSize?: number }) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
}
function handleQueryChange(value: string) {
setQuery(value)
updateSearch({ page: 1 })
}
function handleCategoryChange(value: SignalCategoryFilter) {
setCategory(value)
updateSearch({ page: 1 })
}
function handleToggleExpanded(signal: SelectionSignal) {
const key = getSignalKey(signal)
setExpandedKeys((previous) => {
const next = new Set(previous)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]">
<section className="flex min-h-[420px] min-w-0 flex-1 flex-col overflow-visible rounded-lg border border-border/80 bg-card md:min-h-0 md:overflow-hidden">
<div className="grid shrink-0 gap-2 border-b border-border/60 p-3 sm:grid-cols-[minmax(0,260px)_auto_1fr] sm:items-center">
<Input
aria-label="搜索命中信号"
className="h-11 bg-background text-sm md:h-8"
onChange={(event) => handleQueryChange(event.target.value)}
placeholder="搜索股票名称或代码"
type="search"
value={query}
/>
<select
aria-label="筛选信号分类"
className="h-11 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:h-8"
onChange={(event) =>
handleCategoryChange(event.target.value as SignalCategoryFilter)
}
value={category}
>
{signalCategoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
筛选结果 {filteredSignals.length} 条
</span>
</div>
<div className="flex min-w-0 flex-col md:min-h-0 md:flex-1 md:overflow-hidden">
<SignalTable
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null}
signals={currentSignals}
/>
<SignalRecordList
expandedKeys={expandedKeys}
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
onToggleExpanded={handleToggleExpanded}
signals={currentSignals}
/>
</div>
<Pagination
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
onPageSizeChange={(nextPageSize) =>
updateSearch({ page: 1, pageSize: nextPageSize })
}
page={currentPage}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={filteredSignals.length}
/>
</section>
<SignalDetailPanel signal={selectedSignal} />
</div>
)
}
@@ -0,0 +1,92 @@
import { Database, Search } from "lucide-react"
import { Badge } from "@/shared/ui/badge"
import { Card } from "@/shared/ui/card"
import type { SelectionSignal } from "../api/selection.types"
import {
categoryToneClass,
formatDetails,
getCategoryLabel,
} from "./selection-presentation"
interface SignalDetailPanelProps {
signal: SelectionSignal | undefined
}
export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
if (!signal) {
return (
<Card
aria-label="当前股票详情"
className="hidden min-h-0 overflow-auto rounded-lg border-border/80 bg-card p-4 md:flex md:flex-col md:justify-center"
>
<div className="space-y-2 text-center">
<Search
className="mx-auto size-5 text-muted-foreground"
aria-hidden="true"
/>
<p className="font-medium">暂无选中信号</p>
<p className="text-xs leading-5 text-muted-foreground">
从左侧列表选择一条信号查看详情。
</p>
</div>
</Card>
)
}
return (
<aside
aria-label="当前股票详情"
className="hidden min-h-0 overflow-auto rounded-lg border border-border/80 bg-card p-4 md:block"
>
<p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
Selected signal
</p>
<div className="mt-2 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-xl font-semibold tracking-tight">
{signal.name}
</h2>
<p className="font-mono text-xs text-muted-foreground">
{signal.ts_code}
</p>
</div>
<Database
className="mt-1 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</div>
<p className="mt-5 text-3xl font-semibold tabular-nums tracking-tight">
¥ {signal.close.toFixed(2)}
</p>
<Badge
className={`mt-3 ${categoryToneClass(signal.category)}`}
variant="outline"
>
{getCategoryLabel(signal.category)}
</Badge>
<dl className="mt-5 divide-y divide-border/60 border-t border-border/60 text-xs">
<div className="flex items-center justify-between gap-3 py-2.5">
<dt className="text-muted-foreground">目标交易日</dt>
<dd className="font-medium tabular-nums">
{signal.target_trade_date}
</dd>
</div>
<div className="flex items-center justify-between gap-3 py-2.5">
<dt className="text-muted-foreground">策略</dt>
<dd className="font-medium">知行 B1</dd>
</div>
<div className="space-y-1.5 py-2.5">
<dt className="text-muted-foreground">关键依据</dt>
<dd className="leading-5 text-foreground">
{formatDetails(signal.details)}
</dd>
</div>
</dl>
</aside>
)
}
@@ -0,0 +1,90 @@
import { ChevronDown, ChevronUp } from "lucide-react"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import type { SelectionSignal } from "../api/selection.types"
import {
categoryToneClass,
formatDetails,
getCategoryLabel,
getSignalKey,
} from "./selection-presentation"
interface SignalRecordListProps {
signals: SelectionSignal[]
expandedKeys: ReadonlySet<string>
onSelect: (signal: SelectionSignal) => void
onToggleExpanded: (signal: SelectionSignal) => void
}
export function SignalRecordList({
signals,
expandedKeys,
onSelect,
onToggleExpanded,
}: SignalRecordListProps) {
if (signals.length === 0) {
return (
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
没有符合当前筛选条件的信号。
</div>
)
}
return (
<div className="divide-y divide-border/60 md:hidden">
{signals.map((signal) => {
const key = getSignalKey(signal)
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(signal)}
>
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">
{signal.name}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{signal.ts_code}
</span>
</span>
<span className="shrink-0 font-medium tabular-nums text-foreground">
¥ {signal.close.toFixed(2)}
</span>
</button>
<div className="flex items-center justify-between gap-3">
<Badge
className={categoryToneClass(signal.category)}
variant="outline"
>
{getCategoryLabel(signal.category)}
</Badge>
<Button
aria-expanded={expanded}
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => onToggleExpanded(signal)}
variant="ghost"
>
{expanded ? "收起详情" : "查看详情"}
{expanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</Button>
</div>
{expanded ? (
<p className="text-xs leading-5 text-muted-foreground">
{formatDetails(signal.details)}
</p>
) : null}
</article>
)
})}
</div>
)
}
@@ -0,0 +1,98 @@
import type { KeyboardEvent } from "react"
import { Badge } from "@/shared/ui/badge"
import type { SelectionSignal } from "../api/selection.types"
import {
categoryToneClass,
formatDetails,
getCategoryLabel,
getSignalKey,
} from "./selection-presentation"
interface SignalTableProps {
signals: SelectionSignal[]
selectedKey: string | null
onSelect: (signal: SelectionSignal) => void
}
export function SignalTable({
signals,
selectedKey,
onSelect,
}: SignalTableProps) {
function handleRowKeyDown(
event: KeyboardEvent<HTMLTableRowElement>,
signal: SelectionSignal,
) {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
onSelect(signal)
}
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 font-medium">收盘价</th>
<th className="h-9 px-3 font-medium">关键详情</th>
</tr>
</thead>
<tbody>
{signals.length > 0 ? (
signals.map((signal) => {
const selected = selectedKey === getSignalKey(signal)
return (
<tr
key={getSignalKey(signal)}
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(signal)}
onKeyDown={(event) => handleRowKeyDown(event, signal)}
tabIndex={0}
>
<td className="h-11 px-3 align-middle">
<p className="font-medium text-foreground">{signal.name}</p>
<p className="font-mono text-[10px] text-muted-foreground">
{signal.ts_code}
</p>
</td>
<td className="px-3 align-middle">
<Badge
className={categoryToneClass(signal.category)}
variant="outline"
>
{getCategoryLabel(signal.category)}
</Badge>
</td>
<td className="px-3 align-middle font-medium tabular-nums text-foreground">
¥ {signal.close.toFixed(2)}
</td>
<td className="max-w-[360px] px-3 align-middle text-muted-foreground">
<span className="line-clamp-2">
{formatDetails(signal.details)}
</span>
</td>
</tr>
)
})
) : (
<tr>
<td
className="h-32 px-3 text-center text-sm text-muted-foreground"
colSpan={4}
>
没有符合当前筛选条件的信号。
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}