feat(selection): 重构选股结果筛选与状态入口
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { XIcon } from "lucide-react"
|
||||
import type { RefObject } from "react"
|
||||
|
||||
import type { SelectionResults } from "../api/selection.types"
|
||||
import { IncompleteEvaluationTable } from "./incomplete-evaluation-table"
|
||||
import { getSelectionRunStatusPresentation } from "./selection-presentation"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -13,7 +12,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { SelectionResults } from "../api/selection.types"
|
||||
import { IncompleteEvaluationTable } from "./incomplete-evaluation-table"
|
||||
import { getSelectionRunStatusPresentation } from "./selection-presentation"
|
||||
|
||||
interface ExecutionStatusDrawerProps {
|
||||
finalFocus: RefObject<HTMLElement | null>
|
||||
@@ -50,6 +52,37 @@ export function ExecutionStatusDrawer({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-4">
|
||||
<section
|
||||
aria-label="执行摘要"
|
||||
className="mb-4 rounded-lg border border-border/70 bg-muted/20 p-3"
|
||||
>
|
||||
<h2 className="sr-only">执行摘要</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<SummaryMetric label="目标股票数" value={result.target_count} />
|
||||
<SummaryMetric label="实际参与数" value={result.eligible_count} />
|
||||
<SummaryMetric
|
||||
label="命中股票数"
|
||||
value={result.selected_stock_count}
|
||||
/>
|
||||
<SummaryMetric label="信号条数" value={result.signal_count} />
|
||||
<SummaryMetric
|
||||
label="数据覆盖率"
|
||||
value={`${(result.coverage * 100).toFixed(2)}%`}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] text-muted-foreground">执行状态</dt>
|
||||
<dd>
|
||||
{statusPresentation ? (
|
||||
<Badge variant={statusPresentation.variant}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm font-semibold">{statusLabel}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<IncompleteEvaluationTable failures={result.failures} />
|
||||
</div>
|
||||
|
||||
@@ -73,3 +106,20 @@ export function ExecutionStatusDrawer({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
||||
<dd className="text-base font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import type { RefObject } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { SelectionResults } from "../api/selection.types"
|
||||
import { getSelectionRunStatusPresentation } from "./selection-presentation"
|
||||
|
||||
interface ExecutionStatusTriggerProps {
|
||||
drawerOpen: boolean
|
||||
onOpen: () => void
|
||||
result: SelectionResults
|
||||
triggerRef: RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
export function ExecutionStatusTrigger({
|
||||
drawerOpen,
|
||||
onOpen,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ExecutionStatusTriggerProps) {
|
||||
const statusPresentation = getSelectionRunStatusPresentation(result.status)
|
||||
|
||||
if (!statusPresentation) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-controls="selection-execution-status-drawer"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`查看执行状态:${statusPresentation.label},未完成评估 ${result.failures.length} 条`}
|
||||
className="h-auto min-h-11 shrink-0 justify-start gap-1.5 px-2 md:min-h-8"
|
||||
onClick={onOpen}
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
>
|
||||
<Badge className="gap-1" variant={statusPresentation.variant}>
|
||||
{result.status === "success" ? (
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
) : null}
|
||||
{statusPresentation.label}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{result.failures.length} 条未完成
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Pagination } from "@/shared/ui/pagination"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select"
|
||||
|
||||
import type { SelectionResults, SelectionSignal } from "../api/selection.types"
|
||||
import { ExecutionStatusTrigger } from "./execution-status-trigger"
|
||||
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||
import { SignalRecordList } from "./signal-record-list"
|
||||
import {
|
||||
@@ -18,11 +27,17 @@ import { SignalTable } from "./signal-table"
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
||||
|
||||
interface SelectionResultsWorkbenchProps {
|
||||
drawerOpen: boolean
|
||||
onOpenExecutionStatus: () => void
|
||||
result: SelectionResults
|
||||
triggerRef: RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
export function SelectionResultsWorkbench({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: SelectionResultsWorkbenchProps) {
|
||||
const search = useSearch({ from: "/_workspace/selection" })
|
||||
const navigate = useNavigate({ from: "/selection" })
|
||||
@@ -53,7 +68,6 @@ export function SelectionResultsWorkbench({
|
||||
const selectedSignal =
|
||||
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
|
||||
currentSignals[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (page > pageCount) {
|
||||
void navigate({
|
||||
@@ -92,32 +106,49 @@ export function SelectionResultsWorkbench({
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 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-0 min-w-0 flex-1 flex-col overflow-visible rounded-lg border border-border/80 bg-card md:overflow-hidden">
|
||||
<div className="grid shrink-0 gap-1.5 border-b border-border/60 p-2.5 sm:grid-cols-[minmax(0,260px)_auto_1fr] sm:items-center">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
||||
<Input
|
||||
aria-label="搜索命中信号"
|
||||
className="h-11 bg-background text-sm md:h-8"
|
||||
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] 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)
|
||||
}
|
||||
<Select
|
||||
items={signalCategoryOptions}
|
||||
onValueChange={(value) => {
|
||||
if (typeof value === "string") {
|
||||
handleCategoryChange(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">
|
||||
<SelectTrigger
|
||||
aria-label="筛选信号分类"
|
||||
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
|
||||
>
|
||||
<SelectValue placeholder="筛选信号分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{signalCategoryOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
|
||||
筛选结果 {signalsTotal} 条
|
||||
</span>
|
||||
<ExecutionStatusTrigger
|
||||
drawerOpen={drawerOpen}
|
||||
onOpen={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
||||
|
||||
@@ -99,6 +99,7 @@ describe("SelectionResultsPage", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
||||
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
||||
const table = screen.getByRole("table")
|
||||
expect(table).toBeInTheDocument()
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
@@ -130,11 +131,25 @@ describe("SelectionResultsPage", () => {
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "查看执行状态:部分成功,未完成评估 1 条",
|
||||
})
|
||||
expect(trigger).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"selection-execution-status-drawer",
|
||||
)
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "知行 B1 执行状态",
|
||||
})
|
||||
const summary = within(dialog).getByRole("region", { name: "执行摘要" })
|
||||
expect(within(summary).getByText("目标股票数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("实际参与数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("命中股票数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("信号条数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("数据覆盖率")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("100.00%")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("部分成功")).toBeInTheDocument()
|
||||
const table = within(dialog).getByRole("table")
|
||||
expect(
|
||||
within(table).getByRole("columnheader", { name: "股票名称" }),
|
||||
@@ -153,6 +168,7 @@ describe("SelectionResultsPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
@@ -175,6 +191,111 @@ describe("SelectionResultsPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "查看执行状态:执行完成,未完成评估 0 条",
|
||||
}),
|
||||
).toHaveFocus()
|
||||
})
|
||||
|
||||
it("keeps execution status accessible when no signals are hit", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
signals_total: 0,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "查看执行状态:执行完成,未完成评估 0 条",
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "知行 B1 执行状态",
|
||||
})
|
||||
expect(within(dialog).getByText("信号条数")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps execution status accessible for a failed result", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
failed_count: 2,
|
||||
failures: [
|
||||
{
|
||||
name: "招商银行",
|
||||
reason: "目标日数据读取失败",
|
||||
status: "data_error",
|
||||
ts_code: "600036.SZ",
|
||||
},
|
||||
{
|
||||
name: "宁德时代",
|
||||
reason: "指标计算失败",
|
||||
status: "calculation_error",
|
||||
ts_code: "300750.SZ",
|
||||
},
|
||||
],
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
status: "failed",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "查看执行状态:执行失败,未完成评估 2 条",
|
||||
}),
|
||||
)
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "知行 B1 执行状态",
|
||||
})
|
||||
expect(within(dialog).getByText("执行失败")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("uses shared selects and resets pagination when the category changes", async () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent(
|
||||
"知行 B1",
|
||||
)
|
||||
const categoryTrigger = screen.getByRole("combobox", {
|
||||
name: "筛选信号分类",
|
||||
})
|
||||
expect(categoryTrigger).toHaveTextContent("全部信号")
|
||||
|
||||
fireEvent.click(categoryTrigger)
|
||||
const oversoldOption = await screen.findByRole("option", { name: "超卖类" })
|
||||
fireEvent.pointerDown(oversoldOption, { pointerType: "mouse" })
|
||||
fireEvent.click(oversoldOption)
|
||||
|
||||
expect(routerNavigate).toHaveBeenCalled()
|
||||
const lastCall =
|
||||
routerNavigate.mock.calls[routerNavigate.mock.calls.length - 1]
|
||||
const searchUpdate = lastCall[0].search as (previous: {
|
||||
category: string
|
||||
page: number
|
||||
pageSize: number
|
||||
search: string
|
||||
}) => Record<string, unknown>
|
||||
expect(
|
||||
searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }),
|
||||
).toEqual({
|
||||
category: "oversold",
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
search: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
||||
@@ -298,14 +419,14 @@ describe("SelectionResultsPage", () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders a no-data state and triggers an initial run after date selection", () => {
|
||||
it("renders a no-data state and triggers an initial run after date selection", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
run_id: null,
|
||||
signals: [],
|
||||
status: "no_data",
|
||||
target_trade_date: null,
|
||||
target_trade_date: "2026-08-08",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
@@ -314,9 +435,12 @@ describe("SelectionResultsPage", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("暂无策略结果")).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText("目标交易日"), {
|
||||
target: { value: "2026-08-08" },
|
||||
fireEvent.click(screen.getByRole("button", { name: "目标交易日" }))
|
||||
const calendar = await screen.findByRole("grid")
|
||||
const dateButton = within(calendar).getByRole("button", {
|
||||
name: "2026年8月9日 星期日",
|
||||
})
|
||||
fireEvent.click(dateButton)
|
||||
fireEvent.click(screen.getByRole("button", { name: "执行策略" }))
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
@@ -324,13 +448,28 @@ describe("SelectionResultsPage", () => {
|
||||
request: {
|
||||
rerun: false,
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
target_trade_date: "2026-08-09",
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps the target trade date empty after clearing the date picker", async () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
const dateTrigger = screen.getByRole("button", { name: "目标交易日" })
|
||||
fireEvent.click(dateTrigger)
|
||||
const calendar = await screen.findByRole("grid")
|
||||
fireEvent.click(
|
||||
within(calendar).getByRole("button", {
|
||||
name: "2026年8月8日 星期六,已选择",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(dateTrigger).toHaveTextContent("选择日期")
|
||||
})
|
||||
|
||||
it("continues polling a persisted running run after page reload", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: { ...selectedResult, signals: [], status: "running" },
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
import { AlertTriangle, Play, RefreshCw, Search } from "lucide-react"
|
||||
import { useSearch } from "@tanstack/react-router"
|
||||
import { useRef, useState, type RefObject } from "react"
|
||||
|
||||
@@ -18,7 +12,6 @@ import type {
|
||||
SelectionResults,
|
||||
SelectionResultsQuery,
|
||||
} from "@/features/selection/api/selection.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -35,17 +28,28 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { DatePicker } from "@/shared/ui/date-picker"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select"
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { ExecutionStatusDrawer } from "../components/execution-status-drawer"
|
||||
import { getSelectionRunStatusPresentation } from "../components/selection-presentation"
|
||||
import { ExecutionStatusTrigger } from "../components/execution-status-trigger"
|
||||
import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
|
||||
|
||||
const STRATEGY = "zhixing_b1" as const
|
||||
|
||||
export function SelectionResultsPage() {
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
|
||||
// undefined follows the persisted result; null means the user cleared the picker.
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<
|
||||
string | null | undefined
|
||||
>(undefined)
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
||||
const [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] =
|
||||
useState(false)
|
||||
@@ -71,7 +75,9 @@ export function SelectionResultsPage() {
|
||||
const trigger = useTriggerSelectionRun()
|
||||
|
||||
const selectedTargetTradeDate =
|
||||
targetTradeDate ?? results.data?.target_trade_date ?? ""
|
||||
targetTradeDate === undefined
|
||||
? (results.data?.target_trade_date ?? "")
|
||||
: (targetTradeDate ?? "")
|
||||
|
||||
const selectedDateResult =
|
||||
targetTradeDate &&
|
||||
@@ -116,25 +122,13 @@ export function SelectionResultsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function handleDateChange(value: string) {
|
||||
setTargetTradeDate(value)
|
||||
function handleDateChange(value: Date | undefined) {
|
||||
setTargetTradeDate(value ? formatLocalTradeDate(value) : null)
|
||||
setExecutionStatusDrawerOpen(false)
|
||||
setActiveRunId(null)
|
||||
trigger.reset()
|
||||
}
|
||||
|
||||
const resultMetrics =
|
||||
displayedResult &&
|
||||
displayedResult.status !== "no_data" &&
|
||||
displayedResult.status !== "running" ? (
|
||||
<ResultMetrics
|
||||
drawerOpen={executionStatusDrawerOpen}
|
||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||
result={displayedResult}
|
||||
triggerRef={executionStatusTriggerRef}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
@@ -149,7 +143,6 @@ export function SelectionResultsPage() {
|
||||
triggerError={trigger.isError}
|
||||
/>
|
||||
}
|
||||
metrics={resultMetrics}
|
||||
mode="bounded-workspace"
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:overflow-hidden">
|
||||
@@ -169,14 +162,24 @@ export function SelectionResultsPage() {
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
displayedResult?.status === "failed" ? (
|
||||
<FailureState result={displayedResult} />
|
||||
<FailureState
|
||||
drawerOpen={executionStatusDrawerOpen}
|
||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||
result={displayedResult}
|
||||
triggerRef={executionStatusTriggerRef}
|
||||
/>
|
||||
) : null}
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
displayedResult &&
|
||||
displayedResult.status !== "no_data" &&
|
||||
displayedResult.status !== "failed" ? (
|
||||
<ResultState result={displayedResult} />
|
||||
<ResultState
|
||||
drawerOpen={executionStatusDrawerOpen}
|
||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||
result={displayedResult}
|
||||
triggerRef={executionStatusTriggerRef}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -227,7 +230,7 @@ interface ExecutionToolbarProps {
|
||||
hasExistingResult: boolean
|
||||
isFailedResult: boolean
|
||||
isRunning: boolean
|
||||
onDateChange: (value: string) => void
|
||||
onDateChange: (value: Date | undefined) => void
|
||||
onExecute: () => void
|
||||
selectedTargetTradeDate: string
|
||||
triggerError: boolean
|
||||
@@ -245,33 +248,35 @@ function ExecutionToolbar({
|
||||
return (
|
||||
<div className="w-full space-y-1.5">
|
||||
<div className="grid gap-1.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
|
||||
<label
|
||||
className="space-y-1 text-xs font-medium"
|
||||
htmlFor="target-trade-date"
|
||||
>
|
||||
目标交易日
|
||||
<Input
|
||||
className="mt-1 h-11 bg-background md:h-8"
|
||||
id="target-trade-date"
|
||||
onChange={(event) => onDateChange(event.target.value)}
|
||||
type="date"
|
||||
value={selectedTargetTradeDate}
|
||||
<div className="space-y-1 text-xs font-medium">
|
||||
<span className="block">目标交易日</span>
|
||||
<DatePicker
|
||||
aria-label="目标交易日"
|
||||
className="mt-1 h-11 w-full bg-background md:h-8"
|
||||
onChange={onDateChange}
|
||||
value={parseLocalTradeDate(selectedTargetTradeDate)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="space-y-1 text-xs font-medium"
|
||||
htmlFor="strategy-name"
|
||||
>
|
||||
策略
|
||||
<select
|
||||
className="mt-1 h-11 w-full 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 disabled:opacity-50 md:h-8"
|
||||
</div>
|
||||
<div className="space-y-1 text-xs font-medium">
|
||||
<span className="block">策略</span>
|
||||
<Select
|
||||
disabled
|
||||
id="strategy-name"
|
||||
items={[{ label: "知行 B1", value: STRATEGY }]}
|
||||
value={STRATEGY}
|
||||
>
|
||||
<option value={STRATEGY}>知行 B1</option>
|
||||
</select>
|
||||
</label>
|
||||
<SelectTrigger
|
||||
aria-label="策略"
|
||||
className="mt-1 h-11 w-full bg-background md:h-8"
|
||||
>
|
||||
<SelectValue placeholder="选择策略" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={STRATEGY}>知行 B1</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
className="h-11 w-full sm:w-auto md:h-8"
|
||||
disabled={!selectedTargetTradeDate || isRunning}
|
||||
@@ -302,56 +307,36 @@ function ExecutionToolbar({
|
||||
)
|
||||
}
|
||||
|
||||
function ResultMetrics({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: {
|
||||
interface ResultStateProps {
|
||||
drawerOpen: boolean
|
||||
onOpenExecutionStatus: () => void
|
||||
result: SelectionResults
|
||||
triggerRef: RefObject<HTMLButtonElement | null>
|
||||
}) {
|
||||
const presentation = getSelectionRunStatusPresentation(result.status)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
|
||||
<Metric label="目标股票数" value={result.target_count} />
|
||||
<Metric label="实际参与数" value={result.eligible_count} />
|
||||
<Metric label="命中股票数" value={result.selected_stock_count} />
|
||||
<Metric label="信号条数" value={result.signal_count} />
|
||||
<Metric
|
||||
label="数据覆盖率"
|
||||
value={`${(result.coverage * 100).toFixed(2)}%`}
|
||||
/>
|
||||
{presentation ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] text-muted-foreground">执行状态</p>
|
||||
<Button
|
||||
aria-controls="selection-execution-status-drawer"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`查看执行状态:${presentation.label},未完成评估 ${result.failures.length} 条`}
|
||||
className="h-auto min-h-11 justify-start gap-1.5 px-2 md:min-h-8"
|
||||
onClick={onOpenExecutionStatus}
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
>
|
||||
<Badge className="gap-1" variant={presentation.variant}>
|
||||
{result.status === "success" ? (
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
) : null}
|
||||
{presentation.label}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{result.failures.length} 条未完成
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
function parseLocalTradeDate(value: string): Date | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||
if (!match) return undefined
|
||||
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const date = new Date(year, month - 1, day)
|
||||
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
function formatLocalTradeDate(value: Date): string {
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(value.getDate()).padStart(2, "0")
|
||||
return `${value.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
@@ -423,7 +408,12 @@ function NoDataState() {
|
||||
)
|
||||
}
|
||||
|
||||
function FailureState({ result }: { result: SelectionResults }) {
|
||||
function FailureState({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ResultStateProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1.5 p-4">
|
||||
@@ -440,49 +430,76 @@ function FailureState({ result }: { result: SelectionResults }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
目标交易日:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.target_trade_date}
|
||||
</span>
|
||||
,失败股票:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.failed_count}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
目标交易日:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.target_trade_date}
|
||||
</span>
|
||||
,失败股票:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.failed_count}
|
||||
</span>
|
||||
</p>
|
||||
<ExecutionStatusTrigger
|
||||
drawerOpen={drawerOpen}
|
||||
onOpen={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultState({ result }: { result: SelectionResults }) {
|
||||
function ResultState({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ResultStateProps) {
|
||||
return result.signal_count === 0 ? (
|
||||
<NoSignalState />
|
||||
<NoSignalState
|
||||
drawerOpen={drawerOpen}
|
||||
onOpenExecutionStatus={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
) : (
|
||||
<SelectionResultsWorkbench result={result} />
|
||||
<SelectionResultsWorkbench
|
||||
drawerOpen={drawerOpen}
|
||||
onOpenExecutionStatus={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoSignalState() {
|
||||
function NoSignalState({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ResultStateProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1.5 p-4">
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ExecutionStatusTrigger
|
||||
drawerOpen={drawerOpen}
|
||||
onOpen={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] text-muted-foreground">{label}</p>
|
||||
<p className="text-base font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"
|
||||
import { DayPicker, type DayPickerProps } from "react-day-picker"
|
||||
import { zhCN } from "react-day-picker/locale"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: DayPickerProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col gap-4 sm:flex-row",
|
||||
month: "flex w-full flex-col gap-4",
|
||||
month_caption: "flex items-center justify-center pt-1",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1",
|
||||
button_previous:
|
||||
"absolute left-1 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
|
||||
button_next:
|
||||
"absolute right-1 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground",
|
||||
week: "mt-2 flex w-full",
|
||||
day: "relative size-9 p-0 text-center text-sm [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-muted/50 [&:has([aria-selected])]:bg-muted first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day_button:
|
||||
"inline-flex size-9 items-center justify-center rounded-md p-0 font-normal outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
range_start: "day-range-start rounded-l-md bg-muted",
|
||||
range_middle: "rounded-none bg-muted",
|
||||
range_end: "day-range-end rounded-r-md bg-muted",
|
||||
selected: "rounded-md bg-primary text-primary-foreground",
|
||||
today: "bg-muted text-foreground",
|
||||
outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-muted/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
disabled: "text-muted-foreground opacity-50",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ className: chevronClassName, orientation, style }) => {
|
||||
const Icon =
|
||||
orientation === "left"
|
||||
? ChevronLeft
|
||||
: orientation === "right"
|
||||
? ChevronRight
|
||||
: orientation === "up"
|
||||
? ChevronUp
|
||||
: ChevronDown
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cn("size-4", chevronClassName)}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}}
|
||||
locale={zhCN}
|
||||
showOutsideDays={showOutsideDays}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { format } from "date-fns"
|
||||
import { CalendarDays } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { Calendar } from "@/shared/ui/calendar"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"
|
||||
|
||||
export interface DatePickerProps extends Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"children" | "onChange" | "value"
|
||||
> {
|
||||
value?: Date
|
||||
onChange?: (value: Date | undefined) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
className,
|
||||
disabled,
|
||||
onChange,
|
||||
placeholder = "选择日期",
|
||||
value,
|
||||
...props
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
function handleSelect(nextValue: Date | undefined) {
|
||||
onChange?.(nextValue)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={disabled}
|
||||
render={
|
||||
<Button
|
||||
data-empty={!value}
|
||||
data-slot="date-picker-trigger"
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CalendarDays aria-hidden="true" data-icon="inline-start" />
|
||||
{value ? format(value, "yyyy-MM-dd") : <span>{placeholder}</span>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<Calendar
|
||||
defaultMonth={value}
|
||||
mode="single"
|
||||
onSelect={handleSelect}
|
||||
selected={value}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
align = "center",
|
||||
className,
|
||||
children,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props & {
|
||||
align?: PopoverPrimitive.Positioner.Props["align"]
|
||||
sideOffset?: number
|
||||
}) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
data-slot="popover-positioner"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className="z-50 outline-none"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--transform-origin) rounded-lg border border-border bg-card p-4 text-card-foreground shadow-lg ring-1 ring-foreground/10 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</PopoverPrimitive.Popup>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) {
|
||||
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverClose, PopoverContent, PopoverTrigger }
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Select<Value, Multiple extends boolean | undefined = false>(
|
||||
props: SelectPrimitive.Root.Props<Value, Multiple>,
|
||||
) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-xs font-medium text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
size = "default",
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 [&>span]:line-clamp-1 [&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
data-slot="select-icon"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("line-clamp-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props & {
|
||||
sideOffset?: number
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
data-slot="select-positioner"
|
||||
sideOffset={sideOffset}
|
||||
className="z-50 outline-none"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--available-height) min-w-[8rem] origin-(--transform-origin) overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-lg ring-1 ring-foreground/10 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List
|
||||
data-slot="select-list"
|
||||
className="max-h-72 overflow-y-auto p-1"
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex min-h-8 w-full cursor-default items-center rounded-md py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator data-slot="select-item-indicator">
|
||||
<Check aria-hidden="true" className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText data-slot="select-item-text">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.ScrollUpArrow.Props) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex h-6 items-center justify-center bg-card text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp aria-hidden="true" className="size-4" />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.ScrollDownArrow.Props) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex h-6 items-center justify-center bg-card text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown aria-hidden="true" className="size-4" />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
Reference in New Issue
Block a user