feat(market-data): 优化同步并增加完整性检查

This commit is contained in:
yuxuanhui
2026-08-11 11:20:13 +08:00
parent 3ce186f977
commit 7ce11543af
42 changed files with 4837 additions and 197 deletions
+2 -1
View File
@@ -24,6 +24,7 @@ import {
function useActiveRoutePath() {
const matchRoute = useMatchRoute()
if (matchRoute({ to: "/selection", fuzzy: true })) return "/selection"
if (matchRoute({ to: "/sync", fuzzy: true })) return "/sync"
if (matchRoute({ to: "/components", fuzzy: true })) return "/components"
if (matchRoute({ to: "/", fuzzy: false })) return "/"
return "/"
@@ -244,7 +245,7 @@ function MoreSheet({
<DialogHeader>
<DialogTitle>更多功能</DialogTitle>
<DialogDescription>
行情数据与同步任务尚未上线,暂无更多可用入口。
行情数据尚未上线,暂无更多可用入口。
</DialogDescription>
</DialogHeader>
</DialogContent>
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest"
import {
mobilePrimaryNavigation,
primaryNavigation,
routePresentation,
} from "./navigation"
describe("sync navigation", () => {
it("exposes the sync route in desktop and mobile navigation", () => {
const sync = primaryNavigation.find((item) => item.id === "sync")
expect(sync).toMatchObject({
availability: "available",
label: "同步任务",
to: "/sync",
})
expect(mobilePrimaryNavigation).toContain(sync)
})
it("provides the active route presentation for sync", () => {
expect(routePresentation["/sync"]).toEqual({
breadcrumb: "研究工作台",
id: "sync",
title: "市场数据完整性检查",
})
})
})
+7 -2
View File
@@ -35,9 +35,9 @@ export const primaryNavigation: readonly NavigationItem[] = [
{
id: "sync",
label: "同步任务",
to: null,
to: "/sync",
icon: ClipboardCheck,
availability: "unavailable",
availability: "available",
},
{
id: "selection",
@@ -79,6 +79,11 @@ export const routePresentation: Record<string, RoutePresentation> = {
breadcrumb: "研究工作台",
title: "知行 B1 执行结果",
},
"/sync": {
id: "sync",
breadcrumb: "研究工作台",
title: "市场数据完整性检查",
},
"/components": {
id: "components",
breadcrumb: "研究工作台",
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import {
getIntegrityCheck,
getLatestIntegrityCheck,
triggerIntegrityCheck,
} from "./sync.api"
describe("sync API adapters", () => {
beforeEach(() => {
requestJson.mockReset()
})
it("triggers one read-only integrity check through the POST endpoint", async () => {
const signal = new AbortController().signal
await triggerIntegrityCheck(signal)
expect(requestJson).toHaveBeenCalledWith(
"/api/v1/market-data/integrity-checks",
{
method: "POST",
signal,
},
)
})
it("uses the latest report pagination contract", async () => {
await getLatestIntegrityCheck({ page: 2, pageSize: 20 })
const [input, init] = requestJson.mock.calls[0] as [
string,
{ signal?: AbortSignal },
]
const params = new URL(input, "http://localhost").searchParams
expect(input).toContain("/api/v1/market-data/integrity-checks/latest?")
expect(params.get("page")).toBe("2")
expect(params.get("page_size")).toBe("20")
expect(init).toEqual({ signal: undefined })
})
it("encodes a check id while keeping the paged check contract", async () => {
await getIntegrityCheck("check/with spaces", { page: 3, pageSize: 50 })
const [input] = requestJson.mock.calls[0] as [string]
const params = new URL(input, "http://localhost").searchParams
expect(input).toContain(
"/api/v1/market-data/integrity-checks/check%2Fwith%20spaces?",
)
expect(params.get("page")).toBe("3")
expect(params.get("page_size")).toBe("50")
})
})
@@ -0,0 +1,50 @@
import { requestJson } from "@/shared/api/request-json"
import {
defaultMarketIntegrityQuery,
type IntegrityCheckAccepted,
type MarketIntegrityCheck,
type MarketIntegrityQuery,
} from "./sync.types"
const integrityChecksPath = "/api/v1/market-data/integrity-checks"
export function triggerIntegrityCheck(signal?: AbortSignal) {
return requestJson<IntegrityCheckAccepted>(integrityChecksPath, {
method: "POST",
signal,
})
}
export function getLatestIntegrityCheck(
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
signal?: AbortSignal,
) {
return requestJson<MarketIntegrityCheck>(
`${integrityChecksPath}/latest?${buildQuery(query)}`,
{ signal },
)
}
export function getIntegrityCheck(
checkId: string,
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
signal?: AbortSignal,
) {
return requestJson<MarketIntegrityCheck>(
`${integrityChecksPath}/${encodeURIComponent(checkId)}?${buildQuery(query)}`,
{ signal },
)
}
function buildQuery(query: MarketIntegrityQuery) {
const params = new URLSearchParams({
page: String(query.page),
page_size: String(query.pageSize),
})
return params.toString()
}
export const getMarketIntegrityCheck = getIntegrityCheck
export const getLatestMarketIntegrityCheck = getLatestIntegrityCheck
export const triggerMarketIntegrityCheck = triggerIntegrityCheck
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ApiError } from "@/shared/api/request-json"
const queryHooks = vi.hoisted(() => ({
useMutation: vi.fn(),
useQuery: vi.fn(),
useQueryClient: vi.fn(),
}))
const api = vi.hoisted(() => ({
getIntegrityCheck: vi.fn(),
getLatestIntegrityCheck: vi.fn(),
triggerIntegrityCheck: vi.fn(),
}))
vi.mock("@tanstack/react-query", () => queryHooks)
vi.mock("./sync.api", () => api)
import {
marketIntegrityCheckQueryKey,
marketIntegrityLatestQueryKey,
useIntegrityCheck,
useLatestIntegrityCheck,
useTriggerIntegrityCheck,
} from "./sync.query"
interface QueryOptionsForTest {
queryKey: readonly unknown[]
enabled?: boolean
refetchInterval?: (query: {
state: { data?: { status?: string } }
}) => number | false
}
interface MutationOptionsForTest {
onError?: (error: unknown) => void
onSuccess?: () => void
}
describe("sync query hooks", () => {
const queryClient = { invalidateQueries: vi.fn() }
beforeEach(() => {
vi.clearAllMocks()
queryHooks.useQuery.mockImplementation((options) => options)
queryHooks.useQueryClient.mockReturnValue(queryClient)
queryHooks.useMutation.mockImplementation((options) => options)
})
it("keeps latest and check keys under marketIntegrity", () => {
expect(marketIntegrityLatestQueryKey({ page: 2, pageSize: 20 })).toEqual([
"marketIntegrity",
"latest",
2,
20,
])
expect(
marketIntegrityCheckQueryKey("check-1", { page: 3, pageSize: 50 }),
).toEqual(["marketIntegrity", "check", "check-1", 3, 50])
})
it("polls only while a check response is running", () => {
useLatestIntegrityCheck({ page: 1, pageSize: 10 })
useIntegrityCheck("check-1", { page: 1, pageSize: 10 })
const latest = queryHooks.useQuery.mock.calls[0]?.[0] as QueryOptionsForTest
const check = queryHooks.useQuery.mock.calls[1]?.[0] as QueryOptionsForTest
expect(latest.queryKey).toEqual(["marketIntegrity", "latest", 1, 10])
expect(check.queryKey).toEqual([
"marketIntegrity",
"check",
"check-1",
1,
10,
])
expect(check.enabled).toBe(true)
if (!check.refetchInterval) throw new Error("polling callback is missing")
expect(
check.refetchInterval({ state: { data: { status: "running" } } }),
).toBe(1500)
expect(
check.refetchInterval({ state: { data: { status: "passed" } } }),
).toBe(false)
})
it("disables a check query without an active id", () => {
useIntegrityCheck(null)
const check = queryHooks.useQuery.mock.calls[0]?.[0] as QueryOptionsForTest
expect(check.enabled).toBe(false)
expect(check.queryKey).toEqual(["marketIntegrity", "check", "none", 1, 10])
})
it("invalidates latest reports after success and on a 409 conflict", () => {
useTriggerIntegrityCheck()
const mutation = queryHooks.useMutation.mock
.calls[0]?.[0] as MutationOptionsForTest
mutation.onSuccess?.()
mutation.onError?.(new ApiError(409, "already running"))
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, {
queryKey: ["marketIntegrity", "latest"],
})
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, {
queryKey: ["marketIntegrity", "latest"],
})
})
})
@@ -0,0 +1,93 @@
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query"
import { ApiError } from "@/shared/api/request-json"
import {
getIntegrityCheck,
getLatestIntegrityCheck,
triggerIntegrityCheck,
} from "./sync.api"
import {
defaultMarketIntegrityQuery,
type IntegrityCheckAccepted,
type MarketIntegrityCheck,
type MarketIntegrityQuery,
} from "./sync.types"
export const marketIntegrityQueryKey = ["marketIntegrity"] as const
export const marketIntegrityLatestQueryPrefix = [
"marketIntegrity",
"latest",
] as const
export const marketIntegrityLatestQueryKey = (
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
) => [...marketIntegrityLatestQueryPrefix, query.page, query.pageSize] as const
export const marketIntegrityCheckQueryKey = (
checkId: string,
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
) => ["marketIntegrity", "check", checkId, query.page, query.pageSize] as const
const MARKET_INTEGRITY_POLL_INTERVAL_MS = 1500
export function useLatestIntegrityCheck(
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
) {
return useQuery({
queryFn: ({ signal }) => getLatestIntegrityCheck(query, signal),
queryKey: marketIntegrityLatestQueryKey(query),
})
}
export function useIntegrityCheck(
checkId: string | null,
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
) {
return useQuery({
enabled: Boolean(checkId),
queryFn: ({ signal }) => getIntegrityCheck(checkId ?? "", query, signal),
queryKey: marketIntegrityCheckQueryKey(checkId ?? "none", query),
refetchInterval: (currentQuery) =>
currentQuery.state.data?.status === "running"
? MARKET_INTEGRITY_POLL_INTERVAL_MS
: false,
})
}
export function useTriggerIntegrityCheck() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ signal }: { signal?: AbortSignal } = {}) =>
triggerIntegrityCheck(signal),
onError: (error) => {
if (error instanceof ApiError && error.status === 409) {
void invalidateLatestIntegrityChecks(queryClient)
}
},
onSuccess: () => {
void invalidateLatestIntegrityChecks(queryClient)
},
})
}
export function invalidateLatestIntegrityChecks(queryClient: QueryClient) {
return queryClient.invalidateQueries({
queryKey: marketIntegrityLatestQueryPrefix,
})
}
export const useLatestMarketIntegrityCheck = useLatestIntegrityCheck
export const useMarketIntegrityCheck = useIntegrityCheck
export const useTriggerMarketIntegrityCheck = useTriggerIntegrityCheck
export type MarketIntegrityMutation = ReturnType<
typeof useTriggerIntegrityCheck
>
export type MarketIntegrityQueryResult = ReturnType<typeof useIntegrityCheck>
export type MarketIntegrityAccepted = IntegrityCheckAccepted
export type MarketIntegrityData = MarketIntegrityCheck
@@ -0,0 +1,45 @@
export type MarketIntegrityStatus =
"no_data" | "running" | "passed" | "issues_found" | "failed"
export interface MarketIntegrityQuery {
page: number
pageSize: number
}
export interface IntegrityCheckAccepted {
check_id: string
status: "running"
window_start: string
window_end: string
}
export interface IntegrityIssue {
issue_key: string
item_kind: string
item_key: string
issue_type: string
message: string
}
export interface MarketIntegrityCheck {
check_id: string | null
status: MarketIntegrityStatus
window_start: string | null
window_end: string | null
target_count: number
checked_count: number
issue_count: number
error_type: string | null
error_message: string | null
created_at: string | null
finished_at: string | null
page: number
page_size: number
issues_total: number
issues: IntegrityIssue[]
}
export const defaultMarketIntegrityQuery: MarketIntegrityQuery = {
page: 1,
pageSize: 10,
}
@@ -0,0 +1,214 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ApiError } from "@/shared/api/request-json"
import { SyncPage } from "./sync-page"
import type {
IntegrityIssue,
MarketIntegrityCheck,
MarketIntegrityStatus,
} from "../api/sync.types"
const syncHooks = vi.hoisted(() => ({
useIntegrityCheck: vi.fn(),
useLatestIntegrityCheck: vi.fn(),
useTriggerIntegrityCheck: vi.fn(),
}))
vi.mock("../api/sync.query", () => syncHooks)
const issue: IntegrityIssue = {
issue_key: "issue-1",
item_kind: "bar",
item_key: "000001.SZ:2026-08-08",
issue_type: "missing_csv_row",
message: "仅报告 PostgreSQL 与 CSV 的差异,不会自动修复。",
}
function buildReport(
status: MarketIntegrityStatus,
overrides: Partial<MarketIntegrityCheck> = {},
): MarketIntegrityCheck {
const hasRun = status !== "no_data"
return {
check_id: hasRun ? "check-1" : null,
status,
window_start: hasRun ? "2026-08-01" : null,
window_end: hasRun ? "2026-08-08" : null,
target_count: 10,
checked_count: 10,
issue_count: status === "issues_found" ? 1 : 0,
error_type: null,
error_message: null,
created_at: "2026-08-08T09:00:00+08:00",
finished_at: "2026-08-08T09:02:00+08:00",
page: 1,
page_size: 10,
issues_total: status === "issues_found" ? 1 : 0,
issues: status === "issues_found" ? [issue] : [],
...overrides,
}
}
function configureHooks(
latestData: MarketIntegrityCheck | undefined,
options: {
checkData?: MarketIntegrityCheck
latestError?: unknown
triggerError?: unknown
} = {},
) {
const refetchLatest = vi.fn()
const refetchCheck = vi.fn()
const mutate = vi.fn()
const reset = vi.fn()
syncHooks.useLatestIntegrityCheck.mockReturnValue({
data: latestData,
error: options.latestError,
isError: Boolean(options.latestError),
isPending: false,
refetch: refetchLatest,
})
syncHooks.useIntegrityCheck.mockReturnValue({
data: options.checkData,
error: undefined,
isError: false,
isPending: false,
refetch: refetchCheck,
})
syncHooks.useTriggerIntegrityCheck.mockReturnValue({
data: undefined,
error: options.triggerError,
isError: Boolean(options.triggerError),
isPending: false,
mutate,
reset,
})
return { mutate, refetchCheck, refetchLatest, reset }
}
describe("SyncPage", () => {
beforeEach(() => {
vi.clearAllMocks()
configureHooks(buildReport("no_data"))
})
it("explains the no-data state and starts a check once", () => {
const { mutate } = configureHooks(buildReport("no_data"))
render(<SyncPage />)
expect(screen.getByText("暂无检查记录")).toBeInTheDocument()
expect(screen.getAllByText(/只报告,不自动修复/).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole("button", { name: "开始完整性检查" }))
expect(mutate).toHaveBeenCalledOnce()
})
it("shows running progress and disables duplicate triggers", () => {
configureHooks(buildReport("running", { checked_count: 4 }))
render(<SyncPage />)
expect(screen.getByText("完整性检查进行中")).toBeInTheDocument()
expect(screen.getByText("4 / 10")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "检查进行中" })).toBeDisabled()
})
it("recovers a running check id from the latest report after refresh", async () => {
configureHooks(buildReport("running"), {
checkData: buildReport("running", { checked_count: 3 }),
})
render(<SyncPage />)
await waitFor(() => {
expect(syncHooks.useIntegrityCheck).toHaveBeenCalledWith("check-1", {
page: 1,
pageSize: 10,
})
})
})
it("refreshes latest once after a running check reaches a terminal state", async () => {
const { refetchLatest } = configureHooks(buildReport("running"), {
checkData: buildReport("passed"),
})
render(<SyncPage />)
await waitFor(() => {
expect(refetchLatest).toHaveBeenCalledOnce()
})
})
it("renders a passed report without an issue table", () => {
configureHooks(buildReport("passed"))
render(<SyncPage />)
expect(screen.getByText("检查通过")).toBeInTheDocument()
expect(screen.getAllByText(/未发现 PostgreSQL\/CSV 不一致/)).toHaveLength(2)
expect(screen.queryByRole("table")).not.toBeInTheDocument()
})
it("renders paginated issue details and the safe-report notice", () => {
configureHooks(buildReport("issues_found"))
render(<SyncPage />)
expect(
screen.getByRole("table", { name: "完整性检查问题报告" }),
).toBeInTheDocument()
expect(screen.getByText("bar")).toBeInTheDocument()
expect(screen.getByText("000001.SZ:2026-08-08")).toBeInTheDocument()
expect(screen.getByText("missing_csv_row")).toBeInTheDocument()
expect(screen.getByText(/只展示对象和差异说明/)).toBeInTheDocument()
expect(
screen.getByRole("contentinfo", { name: "表格分页" }),
).toBeInTheDocument()
})
it("shows a failed report and offers a retry entry", () => {
const { mutate } = configureHooks(
buildReport("failed", {
error_type: "check_error",
error_message: "存储暂时不可用",
}),
)
render(<SyncPage />)
expect(screen.getByText("检查未完成")).toBeInTheDocument()
expect(screen.getByText(/不能据此判断市场数据已经损坏/)).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "重新发起检查" }))
expect(mutate).toHaveBeenCalledOnce()
})
it("makes 503 and network failures visible with a retry button", () => {
const { refetchLatest } = configureHooks(undefined, {
latestError: new ApiError(503, "unavailable"),
})
render(<SyncPage />)
expect(screen.getByText("完整性检查结果暂时不可用")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "重试" }))
expect(refetchLatest).toHaveBeenCalledOnce()
})
it("explains a 409 and lets the page take over the running report", () => {
configureHooks(buildReport("running"), {
triggerError: new ApiError(409, "conflict"),
})
render(<SyncPage />)
expect(screen.getByText(/已刷新并接管该检查/)).toBeInTheDocument()
expect(screen.getByText("完整性检查进行中")).toBeInTheDocument()
})
})
@@ -0,0 +1,665 @@
import {
AlertTriangle,
CheckCircle2,
ClipboardCheck,
Database,
RefreshCw,
ShieldCheck,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { PageLayout } from "@/app/layout/page-layout"
import { ApiError } from "@/shared/api/request-json"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import { Pagination } from "@/shared/ui/pagination"
import { Progress, ProgressLabel, ProgressValue } from "@/shared/ui/progress"
import {
useIntegrityCheck,
useLatestIntegrityCheck,
useTriggerIntegrityCheck,
} from "../api/sync.query"
import {
defaultMarketIntegrityQuery,
type IntegrityCheckAccepted,
type MarketIntegrityCheck,
} from "../api/sync.types"
export function SyncPage() {
const [triggeredCheckId, setTriggeredCheckId] = useState<string | null>(null)
const [page, setPage] = useState(defaultMarketIntegrityQuery.page)
const [pageSize, setPageSize] = useState(defaultMarketIntegrityQuery.pageSize)
const query = { page, pageSize }
const latest = useLatestIntegrityCheck(query)
const latestRunningCheckId =
latest.data?.status === "running" ? latest.data.check_id : null
const activeCheckId = triggeredCheckId ?? latestRunningCheckId
const check = useIntegrityCheck(activeCheckId, query)
const trigger = useTriggerIntegrityCheck()
const { refetch: refetchLatest } = latest
const terminalRefreshId = useRef<string | null>(null)
useEffect(() => {
if (!activeCheckId || !check.data || check.data.status === "running") {
if (!activeCheckId) terminalRefreshId.current = null
return
}
if (terminalRefreshId.current === activeCheckId) return
terminalRefreshId.current = activeCheckId
void refetchLatest()
}, [activeCheckId, check.data, refetchLatest])
const acceptedReport = trigger.data
? acceptedCheckAsReport(trigger.data)
: undefined
const latestMatchesActive =
!activeCheckId || latest.data?.check_id === activeCheckId
const report =
check.data ??
(activeCheckId && latestMatchesActive ? latest.data : undefined) ??
(trigger.data ? acceptedReport : latest.data)
function startCheck() {
setPage(1)
trigger.reset()
trigger.mutate(
{},
{
onError: (error) => {
if (getErrorStatus(error) === 409) setTriggeredCheckId(null)
},
onSuccess: (accepted) => setTriggeredCheckId(accepted.check_id),
},
)
}
function retryActiveCheck() {
void check.refetch()
}
function retryLatest() {
void latest.refetch()
}
const triggerStatus = getErrorStatus(trigger.error)
const triggerError =
trigger.isError && triggerStatus !== 409 ? trigger.error : null
const queryPending = activeCheckId ? check.isPending : latest.isPending
const queryError = activeCheckId ? check.error : latest.error
const queryHasData = Boolean(report)
return (
<PageLayout>
<div className="mx-auto w-full max-w-6xl space-y-4">
<header className="space-y-1">
<p className="text-xs font-medium tracking-[0.08em] text-muted-foreground uppercase">
只读数据校验
</p>
<h1 className="text-2xl font-semibold tracking-tight">
市场数据完整性检查
</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
检查 PostgreSQL 与 CSV 快照中的市场数据是否一致,帮助定位数据问题。
</p>
</header>
<ReadOnlyNotice />
{triggerStatus === 409 ? <ConflictNotice /> : null}
{triggerError ? (
<RequestError
description={getTriggerErrorDescription(triggerError)}
onRetry={startCheck}
title="完整性检查暂时无法发起"
/>
) : null}
{!queryHasData && queryPending ? <LoadingState /> : null}
{!queryPending && queryError ? (
<RequestError
description={getQueryErrorDescription(queryError)}
onRetry={activeCheckId ? retryActiveCheck : retryLatest}
title="完整性检查结果暂时不可用"
/>
) : null}
{report?.status === "no_data" ? (
<NoDataState isPending={trigger.isPending} onStart={startCheck} />
) : null}
{report?.status === "running" ? <RunningState report={report} /> : null}
{report?.status === "passed" ? (
<PassedState
isPending={trigger.isPending}
onStart={startCheck}
report={report}
/>
) : null}
{report?.status === "issues_found" ? (
<IssuesFoundState
onPageChange={setPage}
onPageSizeChange={(nextPageSize) => {
setPage(1)
setPageSize(nextPageSize)
}}
report={report}
selectedPage={page}
selectedPageSize={pageSize}
isPending={trigger.isPending}
onStart={startCheck}
/>
) : null}
{report?.status === "failed" ? (
<FailedState
isPending={trigger.isPending}
onStart={startCheck}
report={report}
/>
) : null}
</div>
</PageLayout>
)
}
function ReadOnlyNotice() {
return (
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex gap-3 p-4">
<ShieldCheck
className="mt-0.5 size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<div className="space-y-1">
<p className="font-medium">只读检查,安全报告</p>
<p className="text-sm text-muted-foreground">
本检查只比较 PostgreSQL 与 CSV,不访问
Tushare,不修改市场事实。发现差异时只报告,不自动修复。
</p>
</div>
</CardContent>
</Card>
)
}
function ConflictNotice() {
return (
<div
role="status"
className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 p-3 text-sm text-foreground"
>
<RefreshCw
className="mt-0.5 size-4 shrink-0 text-warning"
aria-hidden="true"
/>
<p>已有完整性检查正在运行,页面已刷新并接管该检查的进度。</p>
</div>
)
}
function LoadingState() {
return (
<Card aria-label="正在加载完整性检查" role="status">
<CardHeader className="gap-3">
<div className="h-6 w-52 animate-pulse rounded-md bg-muted" />
<div className="h-4 w-80 max-w-full animate-pulse rounded-md bg-muted" />
</CardHeader>
<CardContent>
<div className="h-20 animate-pulse rounded-md bg-muted" />
</CardContent>
</Card>
)
}
function NoDataState({
isPending,
onStart,
}: {
isPending: boolean
onStart: () => void
}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Database className="size-5 text-primary" aria-hidden="true" />
暂无检查记录
</CardTitle>
<CardDescription>
当前没有可查看的完整性检查。发起后,系统会比较最近完成同步窗口中的
PostgreSQL 与 CSV 数据。
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button disabled={isPending} onClick={onStart}>
<ClipboardCheck aria-hidden="true" />
{isPending ? "正在发起检查…" : "开始完整性检查"}
</Button>
<p className="text-xs text-muted-foreground">
检查只读数据并生成报告,不会访问 Tushare,也不会自动修复。
</p>
</CardContent>
</Card>
)
}
function RunningState({ report }: { report: MarketIntegrityCheck }) {
const percentage = progressPercentage(
report.checked_count,
report.target_count,
)
return (
<Card aria-live="polite">
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-xl">
<ClipboardCheck
className="size-5 text-primary"
aria-hidden="true"
/>
完整性检查进行中
</CardTitle>
<CardDescription>
检查完成后会自动停止刷新;运行期间不会修改 PostgreSQL 或 CSV
数据。
</CardDescription>
</div>
<Badge variant="outline">运行中</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<Progress aria-label="完整性检查进度" max={100} value={percentage}>
<ProgressLabel>检查进度</ProgressLabel>
<ProgressValue>
{() => `${report.checked_count}/${report.target_count}`}
</ProgressValue>
</Progress>
<div className="grid gap-3 sm:grid-cols-3">
<Metric
label="已检查对象"
value={`${report.checked_count} / ${report.target_count}`}
/>
<Metric label="当前问题数" value={String(report.issue_count)} />
<Metric label="检查窗口" value={formatWindow(report)} />
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
<Timestamp label="开始时间" value={report.created_at} />
<Button disabled variant="outline">
检查进行中
</Button>
</div>
</CardContent>
</Card>
)
}
function PassedState({
isPending,
onStart,
report,
}: {
isPending: boolean
onStart: () => void
report: MarketIntegrityCheck
}) {
return (
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-xl">
<CheckCircle2
className="size-5 text-success"
aria-hidden="true"
/>
检查通过
</CardTitle>
<CardDescription>
未发现 PostgreSQL/CSV 不一致,本次检查只报告结果,没有修改数据。
</CardDescription>
</div>
<Badge className="border-transparent bg-success text-success-foreground">
已通过
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-success/30 bg-success/10 p-4 text-sm">
未发现 PostgreSQL/CSV 不一致。
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Metric
label="已检查对象"
value={`${report.checked_count} / ${report.target_count}`}
/>
<Metric label="问题数" value={String(report.issue_count)} />
<Metric label="检查窗口" value={formatWindow(report)} />
</div>
<div className="flex flex-wrap items-end justify-between gap-3 border-t border-border/60 pt-4">
<div className="flex flex-wrap gap-x-5 gap-y-2">
<Timestamp label="开始时间" value={report.created_at} />
<Timestamp label="完成时间" value={report.finished_at} />
</div>
<Button disabled={isPending} onClick={onStart} variant="outline">
<RefreshCw aria-hidden="true" />
{isPending ? "正在发起…" : "再次检查"}
</Button>
</div>
</CardContent>
</Card>
)
}
function IssuesFoundState({
isPending,
onPageChange,
onPageSizeChange,
onStart,
report,
selectedPage,
selectedPageSize,
}: {
isPending: boolean
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
onStart: () => void
report: MarketIntegrityCheck
selectedPage: number
selectedPageSize: number
}) {
return (
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
className="size-5 text-warning"
aria-hidden="true"
/>
发现数据差异
</CardTitle>
<CardDescription>
检查完成并发现 {report.issue_count}{" "}
个问题。以下内容仅供定位,系统不会自动修复。
</CardDescription>
</div>
<Badge className="border-transparent bg-warning text-warning-foreground">
{report.issue_count} 个问题
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Metric
label="已检查对象"
value={`${report.checked_count} / ${report.target_count}`}
/>
<Metric label="问题数" value={String(report.issue_count)} />
<Metric label="检查窗口" value={formatWindow(report)} />
</div>
<div className="rounded-md border border-warning/40 bg-warning/10 p-4 text-sm">
安全说明:问题报告只展示对象和差异说明,不会执行修复或重新同步。
</div>
<IssueTable report={report} />
<Pagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
page={selectedPage}
pageSize={selectedPageSize}
pageSizeOptions={[10, 20, 50]}
total={report.issues_total}
/>
<div className="flex flex-wrap items-end justify-between gap-3 border-t border-border/60 pt-4">
<div className="flex flex-wrap gap-x-5 gap-y-2">
<Timestamp label="开始时间" value={report.created_at} />
<Timestamp label="完成时间" value={report.finished_at} />
</div>
<Button disabled={isPending} onClick={onStart} variant="outline">
<RefreshCw aria-hidden="true" />
{isPending ? "正在发起…" : "再次检查"}
</Button>
</div>
</CardContent>
</Card>
)
}
function IssueTable({ report }: { report: MarketIntegrityCheck }) {
if (report.issues.length === 0) {
return (
<div className="rounded-md border p-4 text-sm text-muted-foreground">
当前页没有问题记录。
</div>
)
}
return (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[680px] text-left text-sm">
<caption className="sr-only">完整性检查问题报告</caption>
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2.5 font-medium" scope="col">
对象类型
</th>
<th className="px-3 py-2.5 font-medium" scope="col">
对象 key
</th>
<th className="px-3 py-2.5 font-medium" scope="col">
问题类型
</th>
<th className="px-3 py-2.5 font-medium" scope="col">
安全说明
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{report.issues.map((issue) => (
<tr key={issue.issue_key} className="align-top">
<td className="px-3 py-3 font-medium">{issue.item_kind}</td>
<td className="break-all px-3 py-3 font-mono text-xs">
{issue.item_key}
</td>
<td className="px-3 py-3">{issue.issue_type}</td>
<td className="max-w-md px-3 py-3 text-muted-foreground">
{issue.message}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function FailedState({
isPending,
onStart,
report,
}: {
isPending: boolean
onStart: () => void
report: MarketIntegrityCheck
}) {
return (
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
className="size-5 text-destructive"
aria-hidden="true"
/>
检查未完成
</CardTitle>
<CardDescription>
本次检查遇到安全错误,不能据此判断市场数据已经损坏。
</CardDescription>
</div>
<Badge variant="destructive">失败</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm"
>
<p className="font-medium">未判断市场数据状态</p>
<p className="mt-1 text-muted-foreground">
{report.error_type
? `错误类型:${report.error_type}`
: "检查服务暂时无法完成比较。"}
</p>
{report.error_message ? (
<p className="mt-1 text-muted-foreground">{report.error_message}</p>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Metric
label="已检查对象"
value={`${report.checked_count} / ${report.target_count}`}
/>
<Metric label="问题数" value={String(report.issue_count)} />
<Metric label="检查窗口" value={formatWindow(report)} />
</div>
<div className="flex flex-wrap items-end justify-between gap-3 border-t border-border/60 pt-4">
<div className="flex flex-wrap gap-x-5 gap-y-2">
<Timestamp label="开始时间" value={report.created_at} />
<Timestamp label="结束时间" value={report.finished_at} />
</div>
<Button disabled={isPending} onClick={onStart}>
<RefreshCw aria-hidden="true" />
{isPending ? "正在重新发起…" : "重新发起检查"}
</Button>
</div>
</CardContent>
</Card>
)
}
function RequestError({
description,
onRetry,
title,
}: {
description: string
onRetry: () => void
title: string
}) {
return (
<Card role="alert">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
className="size-5 text-destructive"
aria-hidden="true"
/>
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={onRetry} variant="outline">
<RefreshCw aria-hidden="true" />
重试
</Button>
</CardContent>
</Card>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 break-words font-medium tabular-nums">{value}</p>
</div>
)
}
function Timestamp({ label, value }: { label: string; value: string | null }) {
return (
<p className="text-xs text-muted-foreground">
{label}:
<time className="text-foreground" dateTime={value ?? undefined}>
{value ?? "—"}
</time>
</p>
)
}
function formatWindow(report: MarketIntegrityCheck) {
if (!report.window_start || !report.window_end) return "—"
return `${report.window_start} 至 ${report.window_end}`
}
function progressPercentage(checked: number, target: number) {
if (target <= 0) return 0
return Math.min(100, Math.max(0, (checked / target) * 100))
}
function acceptedCheckAsReport(
accepted: IntegrityCheckAccepted,
): MarketIntegrityCheck {
return {
check_id: accepted.check_id,
status: "running",
window_start: accepted.window_start,
window_end: accepted.window_end,
target_count: 0,
checked_count: 0,
issue_count: 0,
error_type: null,
error_message: null,
created_at: null,
finished_at: null,
page: 1,
page_size: defaultMarketIntegrityQuery.pageSize,
issues_total: 0,
issues: [],
}
}
function getErrorStatus(error: unknown) {
if (error instanceof ApiError) return error.status
if (typeof error === "object" && error !== null && "status" in error) {
const status = error.status
return typeof status === "number" ? status : null
}
return null
}
function getQueryErrorDescription(error: unknown) {
const status = getErrorStatus(error)
if (status === 503) {
return "检查结果存储暂时不可用,未将这次请求解释为市场数据损坏。"
}
return "检查结果暂时无法获取,可能是网络或服务异常。请稍后重试。"
}
function getTriggerErrorDescription(error: unknown) {
const status = getErrorStatus(error)
if (status === 422) {
return "当前没有可供比较的已完成市场数据窗口,请先完成一次市场数据同步。"
}
if (status === 503) {
return "检查服务暂时不可用,未发起新的检查。请稍后重试。"
}
return "检查请求可能没有到达服务,请确认网络后重试。"
}
+13 -1
View File
@@ -8,6 +8,7 @@ import {
type SelectionCategoryFilter,
} from "@/features/selection/api/selection.types"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
import { SyncPage } from "@/features/sync/pages/sync-page"
const rootRoute = createRootRoute({
component: () => <Outlet />,
@@ -48,6 +49,12 @@ const selectionRoute = createRoute({
component: SelectionResultsPage,
})
const syncRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/sync",
component: SyncPage,
})
const componentsRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/components",
@@ -55,5 +62,10 @@ const componentsRoute = createRoute({
})
export const routeTree = rootRoute.addChildren([
workspaceRoute.addChildren([indexRoute, selectionRoute, componentsRoute]),
workspaceRoute.addChildren([
indexRoute,
selectionRoute,
syncRoute,
componentsRoute,
]),
])