feat(web): 选股列表改为滚动加载并精简操作区 #24
@@ -10,18 +10,20 @@
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和详情展开能力。列表容器应继续支持内部滚动和空结果提示,分页与筛选行为保持不变。
|
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和右侧详情面板。列表容器应继续支持内部滚动和空结果提示;筛选、排序保留,不再展示底部分页,改为滚动加载更多。
|
||||||
2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。
|
2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。
|
||||||
3. 现有筛选、排序、加载更多和选中股票详情的数据契约不变;仅调整展示层结构和样式。
|
3. 现有筛选、排序和选中股票详情的数据契约不变;仅调整展示层结构和样式。执行状态入口放到工具栏“重新执行”按钮右侧。
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。
|
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。卡片上不再出现“查看详情”。
|
||||||
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项;“查看详情/收起详情”可展开信号细节。
|
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项。
|
||||||
|
- [ ] 选股结果不再渲染底部分页控件;列表滚到底部会加载下一页,加载中和失败重试提示保持可见。
|
||||||
|
- [ ] 执行状态入口(如“部分成功”)出现在工具栏“重新执行/重试执行/执行策略”按钮右侧。
|
||||||
- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。
|
- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。
|
||||||
- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。
|
- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。
|
||||||
- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。
|
- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
不调整后端接口、指标计算、筛选/排序语义、分页参数、详情面板字段或全局主题 token。
|
不调整后端接口、指标计算、筛选/排序语义、详情面板字段或全局主题 token。
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { requestJson } from "@/shared/api/request-json"
|
import { requestJson } from "@/shared/api/request-json"
|
||||||
|
|
||||||
import type {
|
import {
|
||||||
SelectionChart,
|
selectionResultPageSize,
|
||||||
SelectionResults,
|
type SelectionChart,
|
||||||
SelectionResultsQuery,
|
type SelectionResults,
|
||||||
SelectionRunAccepted,
|
type SelectionResultsQuery,
|
||||||
SelectionRunRequest,
|
type SelectionRunAccepted,
|
||||||
SelectionStrategy,
|
type SelectionRunRequest,
|
||||||
|
type SelectionStrategy,
|
||||||
} from "./selection.types"
|
} from "./selection.types"
|
||||||
|
|
||||||
export function getSelectionChart(
|
export function getSelectionChart(
|
||||||
@@ -26,7 +27,7 @@ export function getSelectionChart(
|
|||||||
export function getSelectionResults(
|
export function getSelectionResults(
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
query: SelectionResultsQuery = { page: 1, pageSize: selectionResultPageSize },
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const params = buildSelectionQueryParams(query)
|
const params = buildSelectionQueryParams(query)
|
||||||
@@ -40,7 +41,7 @@ export function getSelectionResults(
|
|||||||
|
|
||||||
export function getSelectionRun(
|
export function getSelectionRun(
|
||||||
runId: string,
|
runId: string,
|
||||||
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
query: SelectionResultsQuery = { page: 1, pageSize: selectionResultPageSize },
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const params = buildSelectionQueryParams(query)
|
const params = buildSelectionQueryParams(query)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
const useQuery = vi.hoisted(() => vi.fn())
|
||||||
|
const useInfiniteQuery = vi.hoisted(() => vi.fn())
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getSelectionChart: vi.fn(),
|
||||||
|
getSelectionResults: vi.fn(),
|
||||||
|
getSelectionRun: vi.fn(),
|
||||||
|
triggerSelectionRun: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-query", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||||
|
"@tanstack/react-query",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useInfiniteQuery,
|
||||||
|
useQuery,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
vi.mock("./selection.api", () => api)
|
||||||
|
|
||||||
|
import {
|
||||||
|
flattenSelectionResults,
|
||||||
|
selectionResultsQueryKey,
|
||||||
|
useSelectionResults,
|
||||||
|
} from "./selection.query"
|
||||||
|
import type { SelectionResults } from "./selection.types"
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
pageSize: 20,
|
||||||
|
search: "平安",
|
||||||
|
category: "pullback" as const,
|
||||||
|
sort: "score_desc" as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageResult(overrides: {
|
||||||
|
page?: number
|
||||||
|
selected_stock_count?: number
|
||||||
|
status?: SelectionResults["status"]
|
||||||
|
stocks: ReadonlyArray<unknown>
|
||||||
|
stocks_total?: number
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
status: "success" as const,
|
||||||
|
stocks_total: 40,
|
||||||
|
selected_stock_count: 40,
|
||||||
|
...overrides,
|
||||||
|
} as SelectionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("selection query hooks", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useQuery.mockImplementation((options) => options)
|
||||||
|
useInfiniteQuery.mockImplementation((options) => options)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps filters in one results key without splitting cached pages", () => {
|
||||||
|
expect(selectionResultsQueryKey("zhixing_b1", "2026-08-08", query)).toEqual(
|
||||||
|
[
|
||||||
|
"selection",
|
||||||
|
"results",
|
||||||
|
"zhixing_b1",
|
||||||
|
"2026-08-08",
|
||||||
|
20,
|
||||||
|
"平安",
|
||||||
|
"pullback",
|
||||||
|
"score_desc",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
selectionResultsQueryKey("zhixing_b1", "2026-08-08", {
|
||||||
|
...query,
|
||||||
|
}),
|
||||||
|
).toEqual(selectionResultsQueryKey("zhixing_b1", "2026-08-08", query))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("forwards React Query cancellation signals to the results adapter", async () => {
|
||||||
|
useSelectionResults("zhixing_b1", "2026-08-08", query)
|
||||||
|
const options = useInfiniteQuery.mock.calls[0]?.[0] as {
|
||||||
|
queryFn: (context: { pageParam: number; signal: AbortSignal }) => unknown
|
||||||
|
}
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
|
await options.queryFn({ pageParam: 3, signal })
|
||||||
|
|
||||||
|
expect(api.getSelectionResults).toHaveBeenCalledWith(
|
||||||
|
"zhixing_b1",
|
||||||
|
"2026-08-08",
|
||||||
|
{ ...query, page: 3 },
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts from page one and stops after loaded stocks cover total", () => {
|
||||||
|
useSelectionResults("zhixing_b1", "2026-08-08", query)
|
||||||
|
const options = useInfiniteQuery.mock.calls[0]?.[0] as {
|
||||||
|
getNextPageParam: (
|
||||||
|
lastPage: ReturnType<typeof pageResult>,
|
||||||
|
pages: ReturnType<typeof pageResult>[],
|
||||||
|
lastPageParam: number,
|
||||||
|
) => number | undefined
|
||||||
|
initialPageParam: number
|
||||||
|
}
|
||||||
|
const firstPage = pageResult({
|
||||||
|
page: 1,
|
||||||
|
stocks: Array(20).fill(null),
|
||||||
|
stocks_total: 35,
|
||||||
|
})
|
||||||
|
const secondPage = pageResult({
|
||||||
|
page: 2,
|
||||||
|
stocks: Array(15).fill(null),
|
||||||
|
stocks_total: 35,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(options.initialPageParam).toBe(1)
|
||||||
|
expect(options.getNextPageParam(firstPage, [firstPage], 1)).toBe(2)
|
||||||
|
expect(
|
||||||
|
options.getNextPageParam(secondPage, [firstPage, secondPage], 2),
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("stops when a page is empty, running, or does not match its page param", () => {
|
||||||
|
useSelectionResults("zhixing_b1", "2026-08-08", query)
|
||||||
|
const options = useInfiniteQuery.mock.calls[0]?.[0] as {
|
||||||
|
getNextPageParam: (
|
||||||
|
lastPage: ReturnType<typeof pageResult>,
|
||||||
|
pages: ReturnType<typeof pageResult>[],
|
||||||
|
lastPageParam: number,
|
||||||
|
) => number | undefined
|
||||||
|
}
|
||||||
|
const firstPage = pageResult({
|
||||||
|
page: 1,
|
||||||
|
stocks: Array(20).fill(null),
|
||||||
|
stocks_total: 35,
|
||||||
|
})
|
||||||
|
const emptyPage = pageResult({
|
||||||
|
page: 2,
|
||||||
|
stocks: [],
|
||||||
|
stocks_total: 35,
|
||||||
|
})
|
||||||
|
const mismatchedPage = pageResult({
|
||||||
|
page: 1,
|
||||||
|
stocks: Array(15).fill(null),
|
||||||
|
stocks_total: 35,
|
||||||
|
})
|
||||||
|
const runningPage = pageResult({
|
||||||
|
page: 1,
|
||||||
|
status: "running",
|
||||||
|
stocks: [],
|
||||||
|
stocks_total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
options.getNextPageParam(emptyPage, [firstPage, emptyPage], 2),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
options.getNextPageParam(mismatchedPage, [firstPage, mismatchedPage], 2),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
options.getNextPageParam(runningPage, [runningPage], 1),
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("flattens loaded pages into one stock list", () => {
|
||||||
|
expect(flattenSelectionResults(undefined)).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
flattenSelectionResults({
|
||||||
|
pageParams: [1, 2],
|
||||||
|
pages: [
|
||||||
|
pageResult({
|
||||||
|
page: 1,
|
||||||
|
stocks: [{ ts_code: "000001.SZ" }],
|
||||||
|
}),
|
||||||
|
pageResult({
|
||||||
|
page: 2,
|
||||||
|
stocks: [{ ts_code: "600036.SH" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})?.stocks,
|
||||||
|
).toEqual([{ ts_code: "000001.SZ" }, { ts_code: "600036.SH" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
|
type InfiniteData,
|
||||||
type QueryClient,
|
type QueryClient,
|
||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
@@ -12,12 +14,20 @@ import {
|
|||||||
getSelectionRun,
|
getSelectionRun,
|
||||||
triggerSelectionRun,
|
triggerSelectionRun,
|
||||||
} from "./selection.api"
|
} from "./selection.api"
|
||||||
import type {
|
import {
|
||||||
SelectionResultsQuery,
|
selectionResultPageSize,
|
||||||
SelectionRunRequest,
|
type SelectionResults,
|
||||||
SelectionStrategy,
|
type SelectionResultsQuery,
|
||||||
|
type SelectionRunRequest,
|
||||||
|
type SelectionStrategy,
|
||||||
} from "./selection.types"
|
} from "./selection.types"
|
||||||
|
|
||||||
|
export type SelectionResultsListQuery = Omit<SelectionResultsQuery, "page">
|
||||||
|
|
||||||
|
const defaultListQuery: SelectionResultsListQuery = {
|
||||||
|
pageSize: selectionResultPageSize,
|
||||||
|
}
|
||||||
|
|
||||||
export const selectionChartQueryKey = (
|
export const selectionChartQueryKey = (
|
||||||
tsCode?: string,
|
tsCode?: string,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
@@ -27,14 +37,13 @@ export const selectionChartQueryKey = (
|
|||||||
export const selectionResultsQueryKey = (
|
export const selectionResultsQueryKey = (
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
query: SelectionResultsListQuery = defaultListQuery,
|
||||||
) =>
|
) =>
|
||||||
[
|
[
|
||||||
"selection",
|
"selection",
|
||||||
"results",
|
"results",
|
||||||
strategy,
|
strategy,
|
||||||
targetTradeDate,
|
targetTradeDate,
|
||||||
query.page,
|
|
||||||
query.pageSize,
|
query.pageSize,
|
||||||
query.search ?? "",
|
query.search ?? "",
|
||||||
query.category ?? "all",
|
query.category ?? "all",
|
||||||
@@ -43,27 +52,50 @@ export const selectionResultsQueryKey = (
|
|||||||
|
|
||||||
export const selectionRunQueryKey = (
|
export const selectionRunQueryKey = (
|
||||||
runId: string,
|
runId: string,
|
||||||
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
query: SelectionResultsListQuery = defaultListQuery,
|
||||||
) =>
|
) =>
|
||||||
[
|
[
|
||||||
"selection",
|
"selection",
|
||||||
"runs",
|
"runs",
|
||||||
runId,
|
runId,
|
||||||
query.page,
|
|
||||||
query.pageSize,
|
query.pageSize,
|
||||||
query.search ?? "",
|
query.search ?? "",
|
||||||
query.category ?? "all",
|
query.category ?? "all",
|
||||||
query.sort ?? "code",
|
query.sort ?? "code",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export function flattenSelectionResults(
|
||||||
|
data: InfiniteData<SelectionResults> | undefined,
|
||||||
|
): SelectionResults | undefined {
|
||||||
|
const firstPage = data?.pages[0]
|
||||||
|
if (!firstPage) return undefined
|
||||||
|
return {
|
||||||
|
...firstPage,
|
||||||
|
stocks: data.pages.flatMap((page) => page.stocks),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useSelectionResults(
|
export function useSelectionResults(
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
query: SelectionResultsListQuery = defaultListQuery,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useInfiniteQuery<
|
||||||
queryFn: ({ signal }) =>
|
SelectionResults,
|
||||||
getSelectionResults(strategy, targetTradeDate, query, signal),
|
Error,
|
||||||
|
InfiniteData<SelectionResults>,
|
||||||
|
ReturnType<typeof selectionResultsQueryKey>,
|
||||||
|
number
|
||||||
|
>({
|
||||||
|
getNextPageParam: nextSelectionPageParam,
|
||||||
|
initialPageParam: 1,
|
||||||
|
queryFn: ({ pageParam, signal }) =>
|
||||||
|
getSelectionResults(
|
||||||
|
strategy,
|
||||||
|
targetTradeDate,
|
||||||
|
{ ...query, page: pageParam },
|
||||||
|
signal,
|
||||||
|
),
|
||||||
queryKey: selectionResultsQueryKey(strategy, targetTradeDate, query),
|
queryKey: selectionResultsQueryKey(strategy, targetTradeDate, query),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -79,19 +111,28 @@ export function useSelectionChart(tsCode?: string, targetTradeDate?: string) {
|
|||||||
|
|
||||||
export function useSelectionRun(
|
export function useSelectionRun(
|
||||||
runId: string | null,
|
runId: string | null,
|
||||||
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
resultQuery: SelectionResultsListQuery = defaultListQuery,
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const query = useQuery({
|
const query = useInfiniteQuery<
|
||||||
|
SelectionResults,
|
||||||
|
Error,
|
||||||
|
InfiniteData<SelectionResults>,
|
||||||
|
ReturnType<typeof selectionRunQueryKey>,
|
||||||
|
number
|
||||||
|
>({
|
||||||
enabled: Boolean(runId),
|
enabled: Boolean(runId),
|
||||||
queryFn: ({ signal }) => getSelectionRun(runId ?? "", resultQuery, signal),
|
getNextPageParam: nextSelectionPageParam,
|
||||||
|
initialPageParam: 1,
|
||||||
|
queryFn: ({ pageParam, signal }) =>
|
||||||
|
getSelectionRun(runId ?? "", { ...resultQuery, page: pageParam }, signal),
|
||||||
queryKey: selectionRunQueryKey(runId ?? "none", resultQuery),
|
queryKey: selectionRunQueryKey(runId ?? "none", resultQuery),
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (currentQuery) =>
|
||||||
query.state.data?.status === "running" ? 1500 : false,
|
currentQuery.state.data?.pages[0]?.status === "running" ? 1500 : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const result = query.data
|
const result = query.data?.pages[0]
|
||||||
if (!runId || !result || result.status === "running") return
|
if (!runId || !result || result.status === "running") return
|
||||||
|
|
||||||
void invalidateSelectionResults(queryClient, result.strategy)
|
void invalidateSelectionResults(queryClient, result.strategy)
|
||||||
@@ -125,3 +166,26 @@ export function invalidateSelectionResults(
|
|||||||
queryKey: ["selection", "results", strategy],
|
queryKey: ["selection", "results", strategy],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextSelectionPageParam(
|
||||||
|
lastPage: SelectionResults,
|
||||||
|
pages: SelectionResults[],
|
||||||
|
lastPageParam: number,
|
||||||
|
) {
|
||||||
|
const loadedStockCount = pages.reduce(
|
||||||
|
(count, page) => count + page.stocks.length,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const total = lastPage.stocks_total ?? lastPage.selected_stock_count
|
||||||
|
if (
|
||||||
|
(lastPage.status !== "success" && lastPage.status !== "partial_success") ||
|
||||||
|
lastPage.stocks.length === 0 ||
|
||||||
|
lastPage.page !== lastPageParam ||
|
||||||
|
loadedStockCount >= total
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPageParam = lastPageParam + 1
|
||||||
|
return Number.isSafeInteger(nextPageParam) ? nextPageParam : undefined
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type SelectionSort = "code" | "score_desc" | "score_asc"
|
|||||||
|
|
||||||
export const selectionSorts = ["code", "score_desc", "score_asc"] as const
|
export const selectionSorts = ["code", "score_desc", "score_asc"] as const
|
||||||
|
|
||||||
|
export const selectionResultPageSize = 20
|
||||||
|
|
||||||
export interface SelectionResultsQuery {
|
export interface SelectionResultsQuery {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState, type RefObject } from "react"
|
import { useMemo, useRef, useState } from "react"
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { Input } from "@/shared/ui/input"
|
import { Input } from "@/shared/ui/input"
|
||||||
import { Pagination } from "@/shared/ui/pagination"
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -12,12 +11,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/ui/select"
|
} from "@/shared/ui/select"
|
||||||
|
|
||||||
import type {
|
import type { SelectionResults, SelectionSort } from "../api/selection.types"
|
||||||
SelectionResults,
|
|
||||||
SelectionSort,
|
|
||||||
SelectionStockResult,
|
|
||||||
} from "../api/selection.types"
|
|
||||||
import { ExecutionStatusTrigger } from "./execution-status-trigger"
|
|
||||||
import { SignalDetailPanel } from "./signal-detail-panel"
|
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||||
import { SignalRecordList } from "./signal-record-list"
|
import { SignalRecordList } from "./signal-record-list"
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +21,6 @@ import {
|
|||||||
type SignalCategoryFilter,
|
type SignalCategoryFilter,
|
||||||
} from "./selection-presentation"
|
} from "./selection-presentation"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
|
||||||
const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
||||||
label: string
|
label: string
|
||||||
value: SelectionSort
|
value: SelectionSort
|
||||||
@@ -38,17 +31,19 @@ const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
|||||||
]
|
]
|
||||||
|
|
||||||
interface SelectionResultsWorkbenchProps {
|
interface SelectionResultsWorkbenchProps {
|
||||||
drawerOpen: boolean
|
hasNextPage: boolean
|
||||||
onOpenExecutionStatus: () => void
|
isFetchNextPageError: boolean
|
||||||
|
isFetchingNextPage: boolean
|
||||||
|
onLoadMore: () => void | Promise<unknown>
|
||||||
result: SelectionResults
|
result: SelectionResults
|
||||||
triggerRef: RefObject<HTMLButtonElement | null>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectionResultsWorkbench({
|
export function SelectionResultsWorkbench({
|
||||||
drawerOpen,
|
hasNextPage,
|
||||||
onOpenExecutionStatus,
|
isFetchNextPageError,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onLoadMore,
|
||||||
result,
|
result,
|
||||||
triggerRef,
|
|
||||||
}: SelectionResultsWorkbenchProps) {
|
}: SelectionResultsWorkbenchProps) {
|
||||||
const search = useSearch({ from: "/_workspace/selection" })
|
const search = useSearch({ from: "/_workspace/selection" })
|
||||||
const navigate = useNavigate({ from: "/selection" })
|
const navigate = useNavigate({ from: "/selection" })
|
||||||
@@ -56,9 +51,7 @@ export function SelectionResultsWorkbench({
|
|||||||
const category = search.category ?? "all"
|
const category = search.category ?? "all"
|
||||||
const sort = search.sort ?? "code"
|
const sort = search.sort ?? "code"
|
||||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||||
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
|
const loadMoreRequestPending = useRef(false)
|
||||||
new Set(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const visibleStocks = useMemo(() => {
|
const visibleStocks = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase()
|
const normalized = query.trim().toLowerCase()
|
||||||
@@ -71,26 +64,12 @@ export function SelectionResultsWorkbench({
|
|||||||
})
|
})
|
||||||
}, [category, query, result.stocks])
|
}, [category, query, result.stocks])
|
||||||
|
|
||||||
const page = search.page
|
|
||||||
const pageSize = search.pageSize
|
|
||||||
const stocksTotal = result.stocks_total ?? result.selected_stock_count
|
const stocksTotal = result.stocks_total ?? result.selected_stock_count
|
||||||
const pageCount = Math.max(1, Math.ceil(stocksTotal / pageSize))
|
|
||||||
const currentPage = Math.min(page, pageCount)
|
|
||||||
const currentStocks = visibleStocks
|
|
||||||
const selectedStock =
|
const selectedStock =
|
||||||
currentStocks.find((stock) => getStockKey(stock) === selectedKey) ??
|
visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ??
|
||||||
currentStocks[0]
|
visibleStocks[0]
|
||||||
useEffect(() => {
|
|
||||||
if (page > pageCount) {
|
|
||||||
void navigate({
|
|
||||||
search: (previous) => ({ ...previous, page: pageCount }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [navigate, page, pageCount])
|
|
||||||
|
|
||||||
function updateSearch(next: {
|
function updateSearch(next: {
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
|
||||||
search?: string
|
search?: string
|
||||||
category?: SignalCategoryFilter
|
category?: SignalCategoryFilter
|
||||||
sort?: SelectionSort
|
sort?: SelectionSort
|
||||||
@@ -99,30 +78,35 @@ export function SelectionResultsWorkbench({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleQueryChange(value: string) {
|
function handleQueryChange(value: string) {
|
||||||
updateSearch({ page: 1, search: value })
|
updateSearch({ search: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCategoryChange(value: SignalCategoryFilter) {
|
function handleCategoryChange(value: SignalCategoryFilter) {
|
||||||
updateSearch({ category: value, page: 1 })
|
updateSearch({ category: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSortChange(value: SelectionSort) {
|
function handleSortChange(value: SelectionSort) {
|
||||||
updateSearch({ page: 1, sort: value })
|
updateSearch({ sort: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleExpanded(stock: SelectionStockResult) {
|
function requestLoadMore() {
|
||||||
const key = getStockKey(stock)
|
if (
|
||||||
setExpandedKeys((previous) => {
|
loadMoreRequestPending.current ||
|
||||||
const next = new Set(previous)
|
isFetchingNextPage ||
|
||||||
if (next.has(key)) next.delete(key)
|
!hasNextPage ||
|
||||||
else next.add(key)
|
isFetchNextPageError
|
||||||
return next
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadMoreRequestPending.current = true
|
||||||
|
void Promise.resolve(onLoadMore()).finally(() => {
|
||||||
|
loadMoreRequestPending.current = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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)_minmax(0,3fr)] md:overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]">
|
||||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border/80 bg-card">
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
||||||
<Input
|
<Input
|
||||||
aria-label="搜索命中股票"
|
aria-label="搜索命中股票"
|
||||||
@@ -185,34 +169,22 @@ export function SelectionResultsWorkbench({
|
|||||||
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
|
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
|
||||||
筛选结果 {stocksTotal} 只
|
筛选结果 {stocksTotal} 只
|
||||||
</span>
|
</span>
|
||||||
<ExecutionStatusTrigger
|
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
onOpen={onOpenExecutionStatus}
|
|
||||||
result={result}
|
|
||||||
triggerRef={triggerRef}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<SignalRecordList
|
<SignalRecordList
|
||||||
expandedKeys={expandedKeys}
|
isFetchNextPageError={isFetchNextPageError}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onLoadMore={requestLoadMore}
|
||||||
|
onRetryLoadMore={() => {
|
||||||
|
loadMoreRequestPending.current = false
|
||||||
|
onLoadMore()
|
||||||
|
}}
|
||||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||||
onToggleExpanded={handleToggleExpanded}
|
|
||||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||||
stocks={currentStocks}
|
stocks={visibleStocks}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
|
||||||
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
|
|
||||||
onPageSizeChange={(nextPageSize) =>
|
|
||||||
updateSearch({ page: 1, pageSize: nextPageSize })
|
|
||||||
}
|
|
||||||
page={currentPage}
|
|
||||||
pageSize={pageSize}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
total={stocksTotal}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<SignalDetailPanel stock={selectedStock} />
|
<SignalDetailPanel stock={selectedStock} />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
|
||||||
import type { KeyboardEvent } from "react"
|
import type { KeyboardEvent } from "react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/ui/badge"
|
import { Badge } from "@/shared/ui/badge"
|
||||||
@@ -6,29 +5,32 @@ import { Button } from "@/shared/ui/button"
|
|||||||
import { cn } from "@/shared/ui/utils"
|
import { cn } from "@/shared/ui/utils"
|
||||||
|
|
||||||
import type { SelectionStockResult } from "../api/selection.types"
|
import type { SelectionStockResult } from "../api/selection.types"
|
||||||
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
|
import { PatternScoreSummary } from "./pattern-score"
|
||||||
import {
|
import {
|
||||||
categoryToneClass,
|
categoryToneClass,
|
||||||
getCategoryLabel,
|
getCategoryLabel,
|
||||||
getStockJValue,
|
getStockJValue,
|
||||||
getStockKey,
|
getStockKey,
|
||||||
} from "./selection-presentation"
|
} from "./selection-presentation"
|
||||||
import { SignalDetails } from "./signal-details"
|
|
||||||
|
|
||||||
interface SignalRecordListProps {
|
interface SignalRecordListProps {
|
||||||
stocks: SelectionStockResult[]
|
stocks: SelectionStockResult[]
|
||||||
selectedKey: string | null
|
selectedKey: string | null
|
||||||
expandedKeys: ReadonlySet<string>
|
isFetchingNextPage: boolean
|
||||||
|
isFetchNextPageError: boolean
|
||||||
onSelect: (stock: SelectionStockResult) => void
|
onSelect: (stock: SelectionStockResult) => void
|
||||||
onToggleExpanded: (stock: SelectionStockResult) => void
|
onLoadMore: () => void
|
||||||
|
onRetryLoadMore: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignalRecordList({
|
export function SignalRecordList({
|
||||||
stocks,
|
stocks,
|
||||||
selectedKey,
|
selectedKey,
|
||||||
expandedKeys,
|
isFetchingNextPage,
|
||||||
|
isFetchNextPageError,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleExpanded,
|
onLoadMore,
|
||||||
|
onRetryLoadMore,
|
||||||
}: SignalRecordListProps) {
|
}: SignalRecordListProps) {
|
||||||
function handleSelectKeyDown(
|
function handleSelectKeyDown(
|
||||||
event: KeyboardEvent<HTMLButtonElement>,
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
@@ -48,99 +50,98 @@ export function SignalRecordList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<div
|
||||||
|
aria-label="选股结果滚动区域"
|
||||||
|
className="min-h-0 flex-1 overflow-auto"
|
||||||
|
onScroll={(event) => {
|
||||||
|
const container = event.currentTarget
|
||||||
|
if (
|
||||||
|
container.scrollHeight -
|
||||||
|
container.scrollTop -
|
||||||
|
container.clientHeight <=
|
||||||
|
160
|
||||||
|
) {
|
||||||
|
onLoadMore()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="region"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
<div className="divide-y divide-border/60">
|
<div className="divide-y divide-border/60">
|
||||||
{stocks.map((stock) => {
|
{stocks.map((stock) => {
|
||||||
const key = getStockKey(stock)
|
const key = getStockKey(stock)
|
||||||
const selected = selectedKey === key
|
const selected = selectedKey === key
|
||||||
const expanded = expandedKeys.has(key)
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={key}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-1 px-2.5 py-1.5 transition-colors hover:bg-muted/50",
|
"px-2.5 py-1.5 transition-colors hover:bg-muted/50",
|
||||||
selected && "bg-muted/70",
|
selected && "bg-muted/70",
|
||||||
)}
|
)}
|
||||||
data-selected={selected}
|
data-selected={selected}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<button
|
||||||
<button
|
className="min-h-11 w-full text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
className="min-h-11 min-w-0 flex-1 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
onClick={() => onSelect(stock)}
|
||||||
onClick={() => onSelect(stock)}
|
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
|
||||||
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
|
type="button"
|
||||||
type="button"
|
>
|
||||||
>
|
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
<span className="font-medium text-foreground">
|
||||||
<span className="font-medium text-foreground">
|
{stock.name}
|
||||||
{stock.name}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
|
||||||
{stock.ts_code}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
|
||||||
¥ {stock.close.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
<span className="flex flex-wrap gap-1">
|
{stock.ts_code}
|
||||||
{stock.signals.map((signal) => (
|
|
||||||
<Badge
|
|
||||||
key={signal.category}
|
|
||||||
className={categoryToneClass(signal.category)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{getCategoryLabel(signal.category)}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<PatternScoreSummary score={stock.score} />
|
|
||||||
<span className="tabular-nums text-muted-foreground">
|
|
||||||
J
|
|
||||||
<strong className="font-semibold text-foreground">
|
|
||||||
{getStockJValue(stock)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||||
<Button
|
¥ {stock.close.toFixed(2)}
|
||||||
aria-expanded={expanded}
|
</span>
|
||||||
className="min-h-11 shrink-0 gap-1 px-2 text-xs text-muted-foreground"
|
</span>
|
||||||
onClick={() => onToggleExpanded(stock)}
|
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||||
variant="ghost"
|
<span className="flex flex-wrap gap-1">
|
||||||
>
|
{stock.signals.map((signal) => (
|
||||||
{expanded ? "收起详情" : "查看详情"}
|
<Badge
|
||||||
{expanded ? (
|
key={signal.category}
|
||||||
<ChevronUp aria-hidden="true" />
|
className={categoryToneClass(signal.category)}
|
||||||
) : (
|
variant="outline"
|
||||||
<ChevronDown aria-hidden="true" />
|
>
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{expanded ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<section className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
|
||||||
图形相似度评分
|
|
||||||
</p>
|
|
||||||
<PatternScoreDetails score={stock.score} />
|
|
||||||
</section>
|
|
||||||
{stock.signals.map((signal) => (
|
|
||||||
<section key={signal.category} className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
|
||||||
{getCategoryLabel(signal.category)}
|
{getCategoryLabel(signal.category)}
|
||||||
</p>
|
</Badge>
|
||||||
<SignalDetails
|
))}
|
||||||
className="sm:grid-cols-3"
|
</span>
|
||||||
details={signal.details}
|
<PatternScoreSummary score={stock.score} />
|
||||||
/>
|
<span className="tabular-nums text-muted-foreground">
|
||||||
</section>
|
J
|
||||||
))}
|
<strong className="font-semibold text-foreground">
|
||||||
</div>
|
{getStockJValue(stock)}
|
||||||
) : null}
|
</strong>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
正在加载更多股票…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isFetchNextPageError ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span>加载更多失败,已加载结果仍保留。</span>
|
||||||
|
<Button onClick={onRetryLoadMore} size="sm" variant="outline">
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,20 @@ const useSelectionRun = vi.fn()
|
|||||||
const useTriggerSelectionRun = vi.fn()
|
const useTriggerSelectionRun = vi.fn()
|
||||||
const mutate = vi.fn()
|
const mutate = vi.fn()
|
||||||
const reset = vi.fn()
|
const reset = vi.fn()
|
||||||
|
const fetchNextPage = vi.fn()
|
||||||
|
|
||||||
vi.mock("@/features/selection/api/selection.query", () => ({
|
vi.mock("@/features/selection/api/selection.query", async () => {
|
||||||
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
const actual = await vi.importActual<
|
||||||
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
typeof import("@/features/selection/api/selection.query")
|
||||||
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
>("@/features/selection/api/selection.query")
|
||||||
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
return {
|
||||||
}))
|
...actual,
|
||||||
|
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
||||||
|
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
||||||
|
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
||||||
|
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock("@tanstack/react-router", () => ({
|
vi.mock("@tanstack/react-router", () => ({
|
||||||
useNavigate: () => routerNavigate,
|
useNavigate: () => routerNavigate,
|
||||||
@@ -110,6 +117,34 @@ const selectedResult: SelectionResults = {
|
|||||||
target_trade_date: "2026-08-08",
|
target_trade_date: "2026-08-08",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptySelectionQuery() {
|
||||||
|
return {
|
||||||
|
data: undefined,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage: false,
|
||||||
|
isError: false,
|
||||||
|
isFetchNextPageError: false,
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
isPending: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionQueryResult(
|
||||||
|
result: SelectionResults,
|
||||||
|
overrides: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: { pageParams: [1], pages: [result] },
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage: false,
|
||||||
|
isError: false,
|
||||||
|
isFetchNextPageError: false,
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
isPending: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("SelectionResultsPage", () => {
|
describe("SelectionResultsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSelectionChart.mockReturnValue({
|
useSelectionChart.mockReturnValue({
|
||||||
@@ -117,16 +152,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
isError: false,
|
isError: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
})
|
})
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
|
||||||
data: selectedResult,
|
useSelectionRun.mockReturnValue(emptySelectionQuery())
|
||||||
isError: false,
|
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
useSelectionRun.mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isError: false,
|
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
useTriggerSelectionRun.mockReturnValue({
|
useTriggerSelectionRun.mockReturnValue({
|
||||||
isError: false,
|
isError: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
@@ -136,6 +163,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
mutate.mockReset()
|
mutate.mockReset()
|
||||||
reset.mockReset()
|
reset.mockReset()
|
||||||
routerNavigate.mockReset()
|
routerNavigate.mockReset()
|
||||||
|
fetchNextPage.mockReset()
|
||||||
|
fetchNextPage.mockResolvedValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("renders persisted results and keeps multiple categories visible", () => {
|
it("renders persisted results and keeps multiple categories visible", () => {
|
||||||
@@ -155,6 +184,18 @@ describe("SelectionResultsPage", () => {
|
|||||||
expect(within(record).getByText("¥ 10.50")).toBeInTheDocument()
|
expect(within(record).getByText("¥ 10.50")).toBeInTheDocument()
|
||||||
expect(within(record).getByText("华纳药厂")).toBeInTheDocument()
|
expect(within(record).getByText("华纳药厂")).toBeInTheDocument()
|
||||||
expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument()
|
expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "查看详情" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/共 \d+ 条/)).not.toBeInTheDocument()
|
||||||
|
const rerunButton = screen.getByRole("button", { name: "重新执行" })
|
||||||
|
const statusTrigger = screen.getByRole("button", {
|
||||||
|
name: "查看执行状态:执行完成,未完成评估 0 条",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
rerunButton.compareDocumentPosition(statusTrigger) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
const detailPanel = screen.getByRole("complementary", {
|
const detailPanel = screen.getByRole("complementary", {
|
||||||
name: "当前股票详情",
|
name: "当前股票详情",
|
||||||
@@ -224,14 +265,12 @@ describe("SelectionResultsPage", () => {
|
|||||||
])(
|
])(
|
||||||
"keeps selection signals visible when score state changes",
|
"keeps selection signals visible when score state changes",
|
||||||
({ expected, score }) => {
|
({ expected, score }) => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
stocks: [{ ...selectedResult.stocks[0], score }],
|
stocks: [{ ...selectedResult.stocks[0], score }],
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
@@ -285,8 +324,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("opens execution status details in a drawer and restores trigger focus", async () => {
|
it("opens execution status details in a drawer and restores trigger focus", async () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
failed_count: 1,
|
failed_count: 1,
|
||||||
failures: [
|
failures: [
|
||||||
@@ -298,10 +337,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
status: "partial_success",
|
status: "partial_success",
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
@@ -376,16 +413,14 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("keeps execution status accessible when no signals are hit", async () => {
|
it("keeps execution status accessible when no signals are hit", async () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
signal_count: 0,
|
signal_count: 0,
|
||||||
stocks: [],
|
stocks: [],
|
||||||
stocks_total: 0,
|
stocks_total: 0,
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
@@ -401,8 +436,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("keeps execution status accessible for a failed result", async () => {
|
it("keeps execution status accessible for a failed result", async () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
failed_count: 2,
|
failed_count: 2,
|
||||||
failures: [
|
failures: [
|
||||||
@@ -422,10 +457,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
signal_count: 0,
|
signal_count: 0,
|
||||||
stocks: [],
|
stocks: [],
|
||||||
status: "failed",
|
status: "failed",
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
@@ -440,7 +473,7 @@ describe("SelectionResultsPage", () => {
|
|||||||
expect(within(dialog).getByText("执行失败")).toBeInTheDocument()
|
expect(within(dialog).getByText("执行失败")).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("uses shared selects and resets pagination when the category changes", async () => {
|
it("uses shared selects when the category changes", async () => {
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent(
|
expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent(
|
||||||
@@ -472,13 +505,13 @@ describe("SelectionResultsPage", () => {
|
|||||||
searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }),
|
searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
category: "oversold",
|
category: "oversold",
|
||||||
page: 1,
|
page: 4,
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
search: "",
|
search: "",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("requests database-backed score sorting and resets the page", async () => {
|
it("requests database-backed score sorting", async () => {
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
const sortTrigger = screen.getByRole("combobox", {
|
const sortTrigger = screen.getByRole("combobox", {
|
||||||
@@ -507,14 +540,14 @@ describe("SelectionResultsPage", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
category: "all",
|
category: "all",
|
||||||
page: 1,
|
page: 3,
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
search: "",
|
search: "",
|
||||||
sort: "score_desc",
|
sort: "score_desc",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
it("filters signals and updates the detail panel", () => {
|
||||||
const extraStock = {
|
const extraStock = {
|
||||||
...selectedResult.stocks[0],
|
...selectedResult.stocks[0],
|
||||||
name: "招商银行",
|
name: "招商银行",
|
||||||
@@ -529,16 +562,14 @@ describe("SelectionResultsPage", () => {
|
|||||||
],
|
],
|
||||||
ts_code: "600036.SH",
|
ts_code: "600036.SH",
|
||||||
}
|
}
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
signal_count: 3,
|
signal_count: 3,
|
||||||
stocks: [...selectedResult.stocks, extraStock],
|
stocks: [...selectedResult.stocks, extraStock],
|
||||||
stocks_total: 2,
|
stocks_total: 2,
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
@@ -555,24 +586,19 @@ describe("SelectionResultsPage", () => {
|
|||||||
const selectButton = within(extraRecord).getByRole("button", {
|
const selectButton = within(extraRecord).getByRole("button", {
|
||||||
name: /招商银行/,
|
name: /招商银行/,
|
||||||
})
|
})
|
||||||
fireEvent.keyDown(selectButton, { key: "Enter" })
|
|
||||||
expect(
|
|
||||||
within(
|
|
||||||
screen.getByRole("complementary", { name: "当前股票详情" }),
|
|
||||||
).getByText("招商银行"),
|
|
||||||
).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.keyDown(selectButton, { key: " " })
|
fireEvent.keyDown(selectButton, { key: " " })
|
||||||
const expandButton = within(extraRecord).getByRole("button", {
|
const detailPanel = screen.getByRole("complementary", {
|
||||||
name: "查看详情",
|
name: "当前股票详情",
|
||||||
})
|
})
|
||||||
fireEvent.click(expandButton)
|
expect(within(detailPanel).getByText("招商银行")).toBeInTheDocument()
|
||||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
expect(within(detailPanel).getByText("MACD")).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
expect(within(detailPanel).getByText("3.2")).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("3.2").length).toBeGreaterThan(1)
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "查看详情" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("navigates to another page when the result list is paginated", () => {
|
it("shows the full result list without pagination controls", () => {
|
||||||
const stocks = Array.from({ length: 6 }, (_, index) => ({
|
const stocks = Array.from({ length: 6 }, (_, index) => ({
|
||||||
...selectedResult.stocks[0],
|
...selectedResult.stocks[0],
|
||||||
name: `股票${index + 1}`,
|
name: `股票${index + 1}`,
|
||||||
@@ -585,23 +611,121 @@ describe("SelectionResultsPage", () => {
|
|||||||
],
|
],
|
||||||
ts_code: `00000${index + 1}.SZ`,
|
ts_code: `00000${index + 1}.SZ`,
|
||||||
}))
|
}))
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
selected_stock_count: stocks.length,
|
selected_stock_count: stocks.length,
|
||||||
signal_count: stocks.length,
|
signal_count: stocks.length,
|
||||||
stocks,
|
stocks,
|
||||||
stocks_total: stocks.length,
|
stocks_total: stocks.length,
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
expect(screen.getByText("第 1–5 条,共 6 条")).toBeInTheDocument()
|
expect(screen.getAllByText("股票1").length).toBeGreaterThan(0)
|
||||||
fireEvent.click(screen.getByRole("button", { name: "第 2 页" }))
|
expect(screen.getByText("股票6")).toBeInTheDocument()
|
||||||
expect(routerNavigate).toHaveBeenCalled()
|
expect(screen.queryByText("第 1–5 条,共 6 条")).not.toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "第 2 页" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("combobox", { name: "每页数量" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("loads the next page once when repeated scroll events reach the bottom", () => {
|
||||||
|
fetchNextPage.mockReturnValue(new Promise(() => undefined))
|
||||||
|
useSelectionResults.mockReturnValue(
|
||||||
|
selectionQueryResult(
|
||||||
|
{ ...selectedResult, stocks_total: 40 },
|
||||||
|
{ hasNextPage: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<SelectionResultsPage />)
|
||||||
|
const scrollRegion = screen.getByRole("region", {
|
||||||
|
name: "选股结果滚动区域",
|
||||||
|
})
|
||||||
|
Object.defineProperties(scrollRegion, {
|
||||||
|
clientHeight: { configurable: true, value: 300 },
|
||||||
|
scrollHeight: { configurable: true, value: 1_000 },
|
||||||
|
scrollTop: { configurable: true, value: 550 },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.scroll(scrollRegion)
|
||||||
|
fireEvent.scroll(scrollRegion)
|
||||||
|
|
||||||
|
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("announces that the next page is loading without hiding loaded rows", () => {
|
||||||
|
useSelectionResults.mockReturnValue(
|
||||||
|
selectionQueryResult(selectedResult, {
|
||||||
|
hasNextPage: true,
|
||||||
|
isFetchingNextPage: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多股票")
|
||||||
|
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps loaded rows and offers retry when loading more fails", () => {
|
||||||
|
useSelectionResults.mockReturnValue(
|
||||||
|
selectionQueryResult(selectedResult, {
|
||||||
|
hasNextPage: true,
|
||||||
|
isError: true,
|
||||||
|
isFetchNextPageError: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||||
|
"加载更多失败,已加载结果仍保留",
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
||||||
|
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("merges stocks from every loaded page", () => {
|
||||||
|
const nextStock = {
|
||||||
|
...selectedResult.stocks[0],
|
||||||
|
name: "招商银行",
|
||||||
|
ts_code: "600036.SH",
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
...selectedResult.stocks[0].signals[0],
|
||||||
|
name: "招商银行",
|
||||||
|
ts_code: "600036.SH",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
useSelectionResults.mockReturnValue(
|
||||||
|
selectionQueryResult(selectedResult, {
|
||||||
|
data: {
|
||||||
|
pageParams: [1, 2],
|
||||||
|
pages: [
|
||||||
|
{ ...selectedResult, stocks_total: 2 },
|
||||||
|
{
|
||||||
|
...selectedResult,
|
||||||
|
page: 2,
|
||||||
|
stocks: [nextStock],
|
||||||
|
stocks_total: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getAllByText("招商银行").length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("requires confirmation before a rerun and does not trigger on cancel", () => {
|
it("requires confirmation before a rerun and does not trigger on cancel", () => {
|
||||||
@@ -634,8 +758,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("uses the same confirmation flow for a failed retry", () => {
|
it("uses the same confirmation flow for a failed retry", () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
failed_count: 1,
|
failed_count: 1,
|
||||||
failures: [
|
failures: [
|
||||||
@@ -650,10 +774,8 @@ describe("SelectionResultsPage", () => {
|
|||||||
signal_count: 0,
|
signal_count: 0,
|
||||||
stocks: [],
|
stocks: [],
|
||||||
status: "failed",
|
status: "failed",
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
@@ -664,17 +786,15 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("renders a no-data state and triggers an initial run after date selection", async () => {
|
it("renders a no-data state and triggers an initial run after date selection", async () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: {
|
selectionQueryResult({
|
||||||
...selectedResult,
|
...selectedResult,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
stocks: [],
|
stocks: [],
|
||||||
status: "no_data",
|
status: "no_data",
|
||||||
target_trade_date: "2026-08-08",
|
target_trade_date: "2026-08-08",
|
||||||
},
|
}),
|
||||||
isError: false,
|
)
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
@@ -715,33 +835,35 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("continues polling a persisted running run after page reload", () => {
|
it("continues polling a persisted running run after page reload", () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue(
|
||||||
data: { ...selectedResult, stocks: [], status: "running" },
|
selectionQueryResult({
|
||||||
isError: false,
|
...selectedResult,
|
||||||
isPending: false,
|
stocks: [],
|
||||||
})
|
status: "running",
|
||||||
useSelectionRun.mockReturnValue({
|
}),
|
||||||
data: { ...selectedResult, stocks: [], status: "running" },
|
)
|
||||||
isError: false,
|
useSelectionRun.mockReturnValue(
|
||||||
isPending: false,
|
selectionQueryResult({
|
||||||
})
|
...selectedResult,
|
||||||
|
stocks: [],
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
expect(screen.getByText("策略执行中")).toBeInTheDocument()
|
expect(screen.getByText("策略执行中")).toBeInTheDocument()
|
||||||
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
|
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
|
||||||
expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
|
expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
|
||||||
page: 1,
|
pageSize: 20,
|
||||||
pageSize: 5,
|
|
||||||
sort: "code",
|
sort: "code",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("renders query failures explicitly", () => {
|
it("renders query failures explicitly", () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue({
|
||||||
data: undefined,
|
...emptySelectionQuery(),
|
||||||
isError: true,
|
isError: true,
|
||||||
isPending: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { useRef, useState, type RefObject } from "react"
|
|||||||
|
|
||||||
import { PageLayout } from "@/app/layout/page-layout"
|
import { PageLayout } from "@/app/layout/page-layout"
|
||||||
import {
|
import {
|
||||||
|
flattenSelectionResults,
|
||||||
useSelectionResults,
|
useSelectionResults,
|
||||||
useSelectionRun,
|
useSelectionRun,
|
||||||
useTriggerSelectionRun,
|
useTriggerSelectionRun,
|
||||||
} from "@/features/selection/api/selection.query"
|
} from "@/features/selection/api/selection.query"
|
||||||
import type {
|
import {
|
||||||
SelectionResults,
|
selectionResultPageSize,
|
||||||
SelectionResultsQuery,
|
type SelectionResults,
|
||||||
|
type SelectionResultsQuery,
|
||||||
} from "@/features/selection/api/selection.types"
|
} from "@/features/selection/api/selection.types"
|
||||||
import { Button } from "@/shared/ui/button"
|
import { Button } from "@/shared/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -56,9 +58,8 @@ export function SelectionResultsPage() {
|
|||||||
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
|
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
|
||||||
const executionStatusTriggerRef = useRef<HTMLButtonElement>(null)
|
const executionStatusTriggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const search = useSearch({ from: "/_workspace/selection" })
|
const search = useSearch({ from: "/_workspace/selection" })
|
||||||
const resultQuery: SelectionResultsQuery = {
|
const resultQuery: Omit<SelectionResultsQuery, "page"> = {
|
||||||
page: search.page,
|
pageSize: selectionResultPageSize,
|
||||||
pageSize: search.pageSize,
|
|
||||||
...(search.search ? { search: search.search } : {}),
|
...(search.search ? { search: search.search } : {}),
|
||||||
...(search.category !== "all" ? { category: search.category } : {}),
|
...(search.category !== "all" ? { category: search.category } : {}),
|
||||||
sort: search.sort,
|
sort: search.sort,
|
||||||
@@ -69,33 +70,38 @@ export function SelectionResultsPage() {
|
|||||||
targetTradeDate || undefined,
|
targetTradeDate || undefined,
|
||||||
resultQuery,
|
resultQuery,
|
||||||
)
|
)
|
||||||
|
const resultsSnapshot = flattenSelectionResults(results.data)
|
||||||
const persistedRunningRunId =
|
const persistedRunningRunId =
|
||||||
results.data?.status === "running" ? results.data.run_id : null
|
resultsSnapshot?.status === "running" ? resultsSnapshot.run_id : null
|
||||||
const polledRunId = activeRunId ?? persistedRunningRunId
|
const polledRunId = activeRunId ?? persistedRunningRunId
|
||||||
const activeRun = useSelectionRun(polledRunId, resultQuery)
|
const activeRun = useSelectionRun(polledRunId, resultQuery)
|
||||||
const trigger = useTriggerSelectionRun()
|
const trigger = useTriggerSelectionRun()
|
||||||
|
const runSnapshot = flattenSelectionResults(activeRun.data)
|
||||||
|
|
||||||
const selectedTargetTradeDate =
|
const selectedTargetTradeDate =
|
||||||
targetTradeDate === undefined
|
targetTradeDate === undefined
|
||||||
? (results.data?.target_trade_date ?? "")
|
? (resultsSnapshot?.target_trade_date ?? "")
|
||||||
: (targetTradeDate ?? "")
|
: (targetTradeDate ?? "")
|
||||||
|
|
||||||
const selectedDateResult =
|
const selectedDateResult =
|
||||||
targetTradeDate &&
|
targetTradeDate &&
|
||||||
results.data?.target_trade_date !== selectedTargetTradeDate
|
resultsSnapshot?.target_trade_date !== selectedTargetTradeDate
|
||||||
? undefined
|
? undefined
|
||||||
: results.data
|
: resultsSnapshot
|
||||||
const displayedResult = activeRun.data ?? selectedDateResult
|
const displayedResult = runSnapshot ?? selectedDateResult
|
||||||
|
const listQuery = activeRun.data ? activeRun : results
|
||||||
const isRunning =
|
const isRunning =
|
||||||
trigger.isPending ||
|
trigger.isPending ||
|
||||||
results.data?.status === "running" ||
|
resultsSnapshot?.status === "running" ||
|
||||||
activeRun.data?.status === "running" ||
|
runSnapshot?.status === "running" ||
|
||||||
(polledRunId !== null && activeRun.isPending)
|
(polledRunId !== null && activeRun.isPending)
|
||||||
const hasExistingResult = Boolean(
|
const hasExistingResult = Boolean(
|
||||||
displayedResult?.run_id && displayedResult.status !== "no_data",
|
displayedResult?.run_id && displayedResult.status !== "no_data",
|
||||||
)
|
)
|
||||||
const hasQueryError =
|
const hasQueryError =
|
||||||
activeRunId !== null ? activeRun.isError : results.isError
|
activeRunId !== null
|
||||||
|
? Boolean(activeRun.isError) && !activeRun.isFetchNextPageError
|
||||||
|
: Boolean(results.isError) && !results.isFetchNextPageError
|
||||||
|
|
||||||
function handleExecute() {
|
function handleExecute() {
|
||||||
if (!selectedTargetTradeDate || isRunning) return
|
if (!selectedTargetTradeDate || isRunning) return
|
||||||
@@ -135,18 +141,22 @@ export function SelectionResultsPage() {
|
|||||||
<PageLayout
|
<PageLayout
|
||||||
actions={
|
actions={
|
||||||
<ExecutionToolbar
|
<ExecutionToolbar
|
||||||
|
drawerOpen={executionStatusDrawerOpen}
|
||||||
hasExistingResult={hasExistingResult}
|
hasExistingResult={hasExistingResult}
|
||||||
isFailedResult={displayedResult?.status === "failed"}
|
isFailedResult={displayedResult?.status === "failed"}
|
||||||
isRunning={isRunning}
|
isRunning={isRunning}
|
||||||
onDateChange={handleDateChange}
|
onDateChange={handleDateChange}
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
|
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||||
|
result={displayedResult}
|
||||||
selectedTargetTradeDate={selectedTargetTradeDate}
|
selectedTargetTradeDate={selectedTargetTradeDate}
|
||||||
triggerError={trigger.isError}
|
triggerError={trigger.isError}
|
||||||
|
triggerRef={executionStatusTriggerRef}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
mode="bounded-workspace"
|
mode="bounded-workspace"
|
||||||
>
|
>
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
{isRunning ? <RunningState /> : null}
|
{isRunning ? <RunningState /> : null}
|
||||||
{!isRunning &&
|
{!isRunning &&
|
||||||
!hasQueryError &&
|
!hasQueryError &&
|
||||||
@@ -163,12 +173,7 @@ export function SelectionResultsPage() {
|
|||||||
{!isRunning &&
|
{!isRunning &&
|
||||||
!hasQueryError &&
|
!hasQueryError &&
|
||||||
displayedResult?.status === "failed" ? (
|
displayedResult?.status === "failed" ? (
|
||||||
<FailureState
|
<FailureState result={displayedResult} />
|
||||||
drawerOpen={executionStatusDrawerOpen}
|
|
||||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
|
||||||
result={displayedResult}
|
|
||||||
triggerRef={executionStatusTriggerRef}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
{!isRunning &&
|
{!isRunning &&
|
||||||
!hasQueryError &&
|
!hasQueryError &&
|
||||||
@@ -176,10 +181,11 @@ export function SelectionResultsPage() {
|
|||||||
displayedResult.status !== "no_data" &&
|
displayedResult.status !== "no_data" &&
|
||||||
displayedResult.status !== "failed" ? (
|
displayedResult.status !== "failed" ? (
|
||||||
<ResultState
|
<ResultState
|
||||||
drawerOpen={executionStatusDrawerOpen}
|
hasNextPage={Boolean(listQuery.hasNextPage)}
|
||||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
isFetchNextPageError={Boolean(listQuery.isFetchNextPageError)}
|
||||||
|
isFetchingNextPage={Boolean(listQuery.isFetchingNextPage)}
|
||||||
|
onLoadMore={() => listQuery.fetchNextPage()}
|
||||||
result={displayedResult}
|
result={displayedResult}
|
||||||
triggerRef={executionStatusTriggerRef}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,23 +234,31 @@ export function SelectionResultsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ExecutionToolbarProps {
|
interface ExecutionToolbarProps {
|
||||||
|
drawerOpen: boolean
|
||||||
hasExistingResult: boolean
|
hasExistingResult: boolean
|
||||||
isFailedResult: boolean
|
isFailedResult: boolean
|
||||||
isRunning: boolean
|
isRunning: boolean
|
||||||
onDateChange: (value: Date | undefined) => void
|
onDateChange: (value: Date | undefined) => void
|
||||||
onExecute: () => void
|
onExecute: () => void
|
||||||
|
onOpenExecutionStatus: () => void
|
||||||
|
result: SelectionResults | undefined
|
||||||
selectedTargetTradeDate: string
|
selectedTargetTradeDate: string
|
||||||
triggerError: boolean
|
triggerError: boolean
|
||||||
|
triggerRef: RefObject<HTMLButtonElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExecutionToolbar({
|
function ExecutionToolbar({
|
||||||
|
drawerOpen,
|
||||||
hasExistingResult,
|
hasExistingResult,
|
||||||
isFailedResult,
|
isFailedResult,
|
||||||
isRunning,
|
isRunning,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
onExecute,
|
onExecute,
|
||||||
|
onOpenExecutionStatus,
|
||||||
|
result,
|
||||||
selectedTargetTradeDate,
|
selectedTargetTradeDate,
|
||||||
triggerError,
|
triggerError,
|
||||||
|
triggerRef,
|
||||||
}: ExecutionToolbarProps) {
|
}: ExecutionToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-1.5">
|
<div className="w-full space-y-1.5">
|
||||||
@@ -278,26 +292,36 @@ function ExecutionToolbar({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex flex-wrap items-center gap-1.5 sm:items-end">
|
||||||
className="h-11 w-full sm:w-auto md:h-8"
|
<Button
|
||||||
disabled={!selectedTargetTradeDate || isRunning}
|
className="h-11 shrink-0 md:h-8"
|
||||||
onClick={onExecute}
|
disabled={!selectedTargetTradeDate || isRunning}
|
||||||
>
|
onClick={onExecute}
|
||||||
{isRunning ? (
|
>
|
||||||
<RefreshCw aria-hidden="true" className="animate-spin" />
|
{isRunning ? (
|
||||||
) : hasExistingResult ? (
|
<RefreshCw aria-hidden="true" className="animate-spin" />
|
||||||
<RefreshCw aria-hidden="true" />
|
) : hasExistingResult ? (
|
||||||
) : (
|
<RefreshCw aria-hidden="true" />
|
||||||
<Play aria-hidden="true" />
|
) : (
|
||||||
)}
|
<Play aria-hidden="true" />
|
||||||
{isRunning
|
)}
|
||||||
? "执行中"
|
{isRunning
|
||||||
: hasExistingResult
|
? "执行中"
|
||||||
? isFailedResult
|
: hasExistingResult
|
||||||
? "重试执行"
|
? isFailedResult
|
||||||
: "重新执行"
|
? "重试执行"
|
||||||
: "执行策略"}
|
: "重新执行"
|
||||||
</Button>
|
: "执行策略"}
|
||||||
|
</Button>
|
||||||
|
{result ? (
|
||||||
|
<ExecutionStatusTrigger
|
||||||
|
drawerOpen={drawerOpen}
|
||||||
|
onOpen={onOpenExecutionStatus}
|
||||||
|
result={result}
|
||||||
|
triggerRef={triggerRef}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{triggerError ? (
|
{triggerError ? (
|
||||||
<p className="text-sm text-destructive" role="alert">
|
<p className="text-sm text-destructive" role="alert">
|
||||||
@@ -309,10 +333,11 @@ function ExecutionToolbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ResultStateProps {
|
interface ResultStateProps {
|
||||||
drawerOpen: boolean
|
hasNextPage: boolean
|
||||||
onOpenExecutionStatus: () => void
|
isFetchNextPageError: boolean
|
||||||
|
isFetchingNextPage: boolean
|
||||||
|
onLoadMore: () => void | Promise<unknown>
|
||||||
result: SelectionResults
|
result: SelectionResults
|
||||||
triggerRef: RefObject<HTMLButtonElement | null>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLocalTradeDate(value: string): Date | undefined {
|
function parseLocalTradeDate(value: string): Date | undefined {
|
||||||
@@ -409,12 +434,7 @@ function NoDataState() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FailureState({
|
function FailureState({ result }: { result: SelectionResults }) {
|
||||||
drawerOpen,
|
|
||||||
onOpenExecutionStatus,
|
|
||||||
result,
|
|
||||||
triggerRef,
|
|
||||||
}: ResultStateProps) {
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="gap-1.5 p-4">
|
<CardHeader className="gap-1.5 p-4">
|
||||||
@@ -431,75 +451,49 @@ function FailureState({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-0">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
目标交易日:
|
||||||
目标交易日:
|
<span className="font-medium text-foreground">
|
||||||
<span className="font-medium text-foreground">
|
{result.target_trade_date}
|
||||||
{result.target_trade_date}
|
</span>
|
||||||
</span>
|
,失败股票:
|
||||||
,失败股票:
|
<span className="font-medium text-foreground">
|
||||||
<span className="font-medium text-foreground">
|
{result.failed_count}
|
||||||
{result.failed_count}
|
</span>
|
||||||
</span>
|
</p>
|
||||||
</p>
|
|
||||||
<ExecutionStatusTrigger
|
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
onOpen={onOpenExecutionStatus}
|
|
||||||
result={result}
|
|
||||||
triggerRef={triggerRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultState({
|
function ResultState({
|
||||||
drawerOpen,
|
hasNextPage,
|
||||||
onOpenExecutionStatus,
|
isFetchNextPageError,
|
||||||
|
isFetchingNextPage,
|
||||||
|
onLoadMore,
|
||||||
result,
|
result,
|
||||||
triggerRef,
|
|
||||||
}: ResultStateProps) {
|
}: ResultStateProps) {
|
||||||
return result.signal_count === 0 ? (
|
return result.signal_count === 0 ? (
|
||||||
<NoSignalState
|
<NoSignalState />
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
onOpenExecutionStatus={onOpenExecutionStatus}
|
|
||||||
result={result}
|
|
||||||
triggerRef={triggerRef}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<SelectionResultsWorkbench
|
<SelectionResultsWorkbench
|
||||||
drawerOpen={drawerOpen}
|
hasNextPage={hasNextPage}
|
||||||
onOpenExecutionStatus={onOpenExecutionStatus}
|
isFetchNextPageError={isFetchNextPageError}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
result={result}
|
result={result}
|
||||||
triggerRef={triggerRef}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoSignalState({
|
function NoSignalState() {
|
||||||
drawerOpen,
|
|
||||||
onOpenExecutionStatus,
|
|
||||||
result,
|
|
||||||
triggerRef,
|
|
||||||
}: ResultStateProps) {
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="gap-1.5 p-4">
|
<CardHeader className="gap-1.5 p-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||||
<div className="space-y-1.5">
|
<CardDescription>
|
||||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
目标交易日数据完整,但没有符合公式条件的股票。
|
||||||
<CardDescription>
|
</CardDescription>
|
||||||
目标交易日数据完整,但没有符合公式条件的股票。
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<ExecutionStatusTrigger
|
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
onOpen={onOpenExecutionStatus}
|
|
||||||
result={result}
|
|
||||||
triggerRef={triggerRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page"
|
import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page"
|
||||||
import {
|
import {
|
||||||
selectionCategoryFilters,
|
selectionCategoryFilters,
|
||||||
|
selectionResultPageSize,
|
||||||
selectionSorts,
|
selectionSorts,
|
||||||
type SelectionCategoryFilter,
|
type SelectionCategoryFilter,
|
||||||
type SelectionSort,
|
type SelectionSort,
|
||||||
@@ -104,7 +105,10 @@ const selectionRoute = createRoute({
|
|||||||
const rawPageSize = Number(search.pageSize)
|
const rawPageSize = Number(search.pageSize)
|
||||||
const page =
|
const page =
|
||||||
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
|
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
|
||||||
const pageSize = [5, 10, 15].includes(rawPageSize) ? rawPageSize : 10
|
const pageSize =
|
||||||
|
Number.isFinite(rawPageSize) && rawPageSize >= 1 && rawPageSize <= 100
|
||||||
|
? Math.floor(rawPageSize)
|
||||||
|
: selectionResultPageSize
|
||||||
const searchValue =
|
const searchValue =
|
||||||
typeof search.search === "string" ? search.search.slice(0, 100) : ""
|
typeof search.search === "string" ? search.search.slice(0, 100) : ""
|
||||||
const rawCategory =
|
const rawCategory =
|
||||||
|
|||||||
Reference in New Issue
Block a user