feat(home): 添加市场数据概览
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { requestJson } from "@/shared/api/request-json"
|
||||
|
||||
import type { HomeOverview } from "./home.types"
|
||||
|
||||
export function getHomeOverview(signal?: AbortSignal) {
|
||||
return requestJson<HomeOverview>("/api/v1/home/overview", { signal })
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import { getHomeOverview } from "./home.api"
|
||||
|
||||
export const homeOverviewQueryKey = ["home", "overview"] as const
|
||||
|
||||
export function useHomeOverview() {
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getHomeOverview(signal),
|
||||
queryKey: homeOverviewQueryKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type HomeOverviewStatus =
|
||||
"updating" | "success" | "partial_success" | "failed" | "no_data"
|
||||
|
||||
export type HomeUpdateStatus = Exclude<HomeOverviewStatus, "no_data">
|
||||
|
||||
export interface OverviewFailure {
|
||||
ts_code: string
|
||||
name: string
|
||||
failure_type: string
|
||||
reason: string
|
||||
trade_date: string
|
||||
}
|
||||
|
||||
export interface OverviewBatchError {
|
||||
item_key: string
|
||||
error_type: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface LatestMarketDataUpdate {
|
||||
trade_date: string
|
||||
completed_at: string | null
|
||||
status: Exclude<HomeOverviewStatus, "no_data">
|
||||
target_count: number
|
||||
successful_count: number
|
||||
failed_count: number
|
||||
valid_count: number
|
||||
coverage_rate: number
|
||||
failures: OverviewFailure[]
|
||||
batch_errors: OverviewBatchError[]
|
||||
}
|
||||
|
||||
export interface HomeOverview {
|
||||
status: HomeOverviewStatus
|
||||
stock_pool_count: number
|
||||
latest_update: LatestMarketDataUpdate | null
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/ui/scroll-area"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { OverviewFailure } from "../api/home.types"
|
||||
|
||||
interface FailedStocksDialogProps {
|
||||
failures: OverviewFailure[]
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function FailedStocksDialog({
|
||||
failures,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: FailedStocksDialogProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>失败股票详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
以下股票尚未在目标交易日同时具备完整的行情和估值数据。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-80 pr-3">
|
||||
<div className="space-y-3">
|
||||
{failures.map((failure) => (
|
||||
<div
|
||||
key={`${failure.ts_code}-${failure.failure_type}`}
|
||||
className="rounded-lg border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{failure.name}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{failure.ts_code}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
目标交易日:{failure.trade_date}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{formatFailureType(failure.failure_type)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{failure.reason}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>关闭</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFailureType(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
missing_daily_bar: "缺少日线行情",
|
||||
missing_daily_bar_and_valuation_snapshot: "缺少日线行情和估值快照",
|
||||
missing_valuation_snapshot: "缺少估值快照",
|
||||
recorded_stock_failure: "股票级同步失败",
|
||||
}
|
||||
|
||||
return labels[value] ?? value
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
BarChart3,
|
||||
BrainCircuit,
|
||||
ClipboardCheck,
|
||||
LayoutDashboard,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/shared/ui/avatar"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Separator } from "@/shared/ui/separator"
|
||||
|
||||
interface HomeShellProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ label: "首页", icon: LayoutDashboard, active: true },
|
||||
{ label: "行情数据", icon: BarChart3, active: false },
|
||||
{ label: "同步任务", icon: ClipboardCheck, active: false },
|
||||
{ label: "选股策略", icon: BrainCircuit, active: false },
|
||||
]
|
||||
|
||||
export function HomeShell({ children }: HomeShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-muted/30 text-foreground">
|
||||
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
|
||||
<div className="flex items-center gap-3 px-1 md:px-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
||||
<BarChart3 className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="hidden min-w-0 md:block">
|
||||
<p className="truncate text-sm font-semibold tracking-wide">
|
||||
知行量化
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">研究工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<nav aria-label="主导航" className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Button
|
||||
key={item.label}
|
||||
className="w-full justify-start gap-3 px-2 md:px-3"
|
||||
disabled={!item.active}
|
||||
variant={item.active ? "secondary" : "ghost"}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto hidden rounded-xl bg-muted/60 p-4 md:block">
|
||||
<p className="text-xs font-medium">数据研究空间</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
从市场数据概览开始,掌握当前研究基础。
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="flex h-16 items-center justify-between gap-4 border-b bg-card/80 px-4 backdrop-blur md:px-8">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
aria-label="搜索"
|
||||
className="h-9 bg-muted/40 pl-9"
|
||||
placeholder="搜索股票、数据或功能"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<Avatar aria-label="用户头像">
|
||||
<AvatarFallback>研</AvatarFallback>
|
||||
</Avatar>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-7xl p-4 md:p-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AlertCircle, CheckCircle2, Database, RefreshCw } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card"
|
||||
import { Progress } from "@/shared/ui/progress"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { LatestMarketDataUpdate } from "../api/home.types"
|
||||
|
||||
interface MarketDataOverviewCardProps {
|
||||
stockPoolCount: number
|
||||
update: LatestMarketDataUpdate
|
||||
onShowFailures: () => void
|
||||
}
|
||||
|
||||
const statusPresentation = {
|
||||
updating: {
|
||||
label: "更新中",
|
||||
description: "同步批次正在处理,数据仍可能变化。",
|
||||
icon: RefreshCw,
|
||||
variant: "outline" as const,
|
||||
},
|
||||
success: {
|
||||
label: "已更新",
|
||||
description: "目标股票池已完整同步到最近交易日。",
|
||||
icon: CheckCircle2,
|
||||
variant: "default" as const,
|
||||
},
|
||||
partial_success: {
|
||||
label: "部分更新",
|
||||
description: "部分股票尚未完成行情或估值数据同步。",
|
||||
icon: AlertCircle,
|
||||
variant: "outline" as const,
|
||||
},
|
||||
failed: {
|
||||
label: "未更新",
|
||||
description: "最近同步未形成可用的完整数据结果。",
|
||||
icon: AlertCircle,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
} as const
|
||||
|
||||
function formatShanghaiDateTime(value: string | null) {
|
||||
if (!value) return "同步进行中"
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function formatCoverage(value: number) {
|
||||
return `${(value * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function MarketDataOverviewCard({
|
||||
onShowFailures,
|
||||
stockPoolCount,
|
||||
update,
|
||||
}: MarketDataOverviewCardProps) {
|
||||
const presentation = statusPresentation[update.status]
|
||||
const StatusIcon = presentation.icon
|
||||
const progressValue = Math.min(100, Math.max(0, update.coverage_rate * 100))
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="gap-4 border-b bg-card md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Database className="size-5 text-primary" aria-hidden="true" />
|
||||
市场数据概览
|
||||
</CardTitle>
|
||||
<CardDescription>{presentation.description}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={presentation.variant} className="w-fit gap-1.5">
|
||||
<StatusIcon className="size-3.5" aria-hidden="true" />
|
||||
{presentation.label}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric label="当前目标股票池" value={String(stockPoolCount)} />
|
||||
<Metric label="最近更新交易日" value={update.trade_date} />
|
||||
<Metric
|
||||
label="同步完成时间"
|
||||
value={formatShanghaiDateTime(update.completed_at)}
|
||||
/>
|
||||
<Metric label="目标股票数" value={String(update.target_count)} />
|
||||
<Metric label="成功股票数" value={String(update.successful_count)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 rounded-xl border bg-muted/20 p-4 sm:grid-cols-3">
|
||||
<Metric label="失败股票数" value={String(update.failed_count)} />
|
||||
<Metric label="有效股票数" value={String(update.valid_count)} />
|
||||
<div className="space-y-2 sm:col-span-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">数据覆盖率</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatCoverage(update.coverage_rate)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="数据覆盖率"
|
||||
value={progressValue}
|
||||
className="gap-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
只有同时具备日线行情和估值快照的股票计入有效股票。
|
||||
</p>
|
||||
<Button
|
||||
disabled={update.failed_count === 0}
|
||||
onClick={onShowFailures}
|
||||
variant="outline"
|
||||
>
|
||||
<AlertCircle aria-hidden="true" />
|
||||
查看失败股票
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{update.batch_errors.length > 0 ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p className="font-medium text-destructive">批次级错误</p>
|
||||
<ul className="mt-1 space-y-1 text-muted-foreground">
|
||||
{update.batch_errors.map((error) => (
|
||||
<li key={`${error.item_key}-${error.error_type}`}>
|
||||
{error.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-semibold tracking-tight">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { HomePage } from "./home-page"
|
||||
|
||||
const useHomeOverview = vi.fn()
|
||||
|
||||
vi.mock("@/features/home/api/home.query", () => ({
|
||||
useHomeOverview: () => useHomeOverview(),
|
||||
}))
|
||||
|
||||
const overview = {
|
||||
status: "partial_success" as const,
|
||||
stock_pool_count: 3,
|
||||
latest_update: {
|
||||
trade_date: "2026-08-06",
|
||||
completed_at: "2026-08-07T08:30:00Z",
|
||||
status: "partial_success" as const,
|
||||
target_count: 3,
|
||||
successful_count: 1,
|
||||
failed_count: 2,
|
||||
valid_count: 1,
|
||||
coverage_rate: 1 / 3,
|
||||
failures: [
|
||||
{
|
||||
ts_code: "000001.SZ",
|
||||
name: "平安银行",
|
||||
failure_type: "missing_daily_bar",
|
||||
reason: "目标交易日缺少日线行情",
|
||||
trade_date: "2026-08-06",
|
||||
},
|
||||
{
|
||||
ts_code: "600000.SH",
|
||||
name: "浦发银行",
|
||||
failure_type: "recorded_stock_failure",
|
||||
reason: "供应商请求失败",
|
||||
trade_date: "2026-08-06",
|
||||
},
|
||||
],
|
||||
batch_errors: [],
|
||||
},
|
||||
}
|
||||
|
||||
describe("HomePage", () => {
|
||||
beforeEach(() => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: overview,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it("renders the management shell and partial overview", () => {
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByRole("heading", { name: "首页" })).toBeInTheDocument()
|
||||
expect(screen.getByText("市场数据概览")).toBeInTheDocument()
|
||||
expect(screen.getByText("部分更新")).toBeInTheDocument()
|
||||
expect(screen.getByText("当前目标股票池")).toBeInTheDocument()
|
||||
expect(screen.getByText("33.33%")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled()
|
||||
expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled()
|
||||
expect(screen.getByRole("button", { name: "选股策略" })).toBeDisabled()
|
||||
})
|
||||
|
||||
it("opens and closes the accessible failed-stock dialog", () => {
|
||||
render(<HomePage />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看失败股票" }))
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "失败股票详情" })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(within(dialog).getByText("平安银行")).toBeInTheDocument()
|
||||
expect(within(dialog).getByText("缺少日线行情")).toBeInTheDocument()
|
||||
expect(within(dialog).getAllByText("目标交易日:2026-08-06")).toHaveLength(
|
||||
2,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "关闭" }))
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders a loading placeholder", () => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByLabelText("正在加载市场数据概览")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("offers retry when the overview request fails", () => {
|
||||
const refetch = vi.fn()
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
refetch,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
["updating", "更新中"],
|
||||
["success", "已更新"],
|
||||
["failed", "未更新"],
|
||||
] as const)("renders the %s overview status", (status, label) => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: {
|
||||
...overview,
|
||||
status,
|
||||
latest_update: { ...overview.latest_update, status },
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("closes the failed-stock dialog with Escape", () => {
|
||||
render(<HomePage />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看失败股票" }))
|
||||
const dialog = screen.getByRole("dialog", { name: "失败股票详情" })
|
||||
|
||||
fireEvent.keyDown(dialog, { key: "Escape" })
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps the shell and explains the no-data state", () => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: { status: "no_data", stock_pool_count: 7, latest_update: null },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByText("暂无市场数据")).toBeInTheDocument()
|
||||
expect(screen.getByText(/当前目标股票池:/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { useHomeOverview } from "@/features/home/api/home.query"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card"
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { FailedStocksDialog } from "../components/failed-stocks-dialog"
|
||||
import { HomeShell } from "../components/home-shell"
|
||||
import { MarketDataOverviewCard } from "../components/market-data-overview-card"
|
||||
|
||||
export function HomePage() {
|
||||
const overview = useHomeOverview()
|
||||
const [failureDialogOpen, setFailureDialogOpen] = useState(false)
|
||||
const update = overview.data?.latest_update
|
||||
|
||||
return (
|
||||
<HomeShell>
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-primary">欢迎回到知行量化</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">首页</h1>
|
||||
<p className="max-w-2xl text-muted-foreground">
|
||||
从当前目标股票池和市场数据同步状态开始,快速判断研究数据是否准备就绪。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{overview.isPending ? <OverviewLoading /> : null}
|
||||
|
||||
{overview.isError ? (
|
||||
<OverviewError onRetry={() => void overview.refetch()} />
|
||||
) : null}
|
||||
|
||||
{!overview.isPending &&
|
||||
!overview.isError &&
|
||||
overview.data?.status === "no_data" ? (
|
||||
<NoDataState stockPoolCount={overview.data.stock_pool_count} />
|
||||
) : null}
|
||||
|
||||
{!overview.isPending && !overview.isError && update ? (
|
||||
<MarketDataOverviewCard
|
||||
onShowFailures={() => setFailureDialogOpen(true)}
|
||||
stockPoolCount={overview.data.stock_pool_count}
|
||||
update={update}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{update ? (
|
||||
<FailedStocksDialog
|
||||
failures={update.failures}
|
||||
onOpenChange={setFailureDialogOpen}
|
||||
open={failureDialogOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</HomeShell>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewLoading() {
|
||||
return (
|
||||
<Card aria-label="正在加载市场数据概览">
|
||||
<CardHeader className="gap-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-72 max-w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<AlertTriangle
|
||||
className="size-5 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
市场数据暂时不可用
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
概览请求失败,可能是服务或网络暂时异常。请稍后重试。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
<RefreshCw aria-hidden="true" />
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NoDataState({ stockPoolCount }: { stockPoolCount: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Database className="size-5 text-primary" aria-hidden="true" />
|
||||
暂无市场数据
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
当前还没有同步批次,完成第一次同步后这里会显示更新详情。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前目标股票池:
|
||||
<span className="font-semibold text-foreground">
|
||||
{stockPoolCount}
|
||||
</span>{" "}
|
||||
只
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
import { SystemStatusPage } from "@/features/system/pages/system-status-page"
|
||||
import { HomePage } from "@/features/home/pages/home-page"
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
@@ -9,7 +9,7 @@ const rootRoute = createRootRoute({
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: SystemStatusPage,
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
export const routeTree = rootRoute.addChildren([indexRoute])
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -1,39 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import type { ButtonHTMLAttributes } from "react"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
icon: "size-9",
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: "default", variant: "default" },
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export function Button({
|
||||
function Button({
|
||||
className,
|
||||
size,
|
||||
type = "button",
|
||||
variant,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>) {
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ size, variant }), className)}
|
||||
type={type}
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button }
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className,
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { cleanup } from "@testing-library/react"
|
||||
import { afterEach } from "vitest"
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user