feat(web): 按原型调整响应式研究工作台
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user