feat(web): 选股列表改为滚动加载并精简操作区

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuxuanhui
2026-09-01 19:27:41 +08:00
parent c944b219d9
commit af1e1eaa66
10 changed files with 732 additions and 384 deletions
@@ -21,13 +21,20 @@ const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn()
const reset = vi.fn()
const fetchNextPage = vi.fn()
vi.mock("@/features/selection/api/selection.query", () => ({
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}))
vi.mock("@/features/selection/api/selection.query", async () => {
const actual = await vi.importActual<
typeof import("@/features/selection/api/selection.query")
>("@/features/selection/api/selection.query")
return {
...actual,
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}
})
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate,
@@ -110,6 +117,34 @@ const selectedResult: SelectionResults = {
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", () => {
beforeEach(() => {
useSelectionChart.mockReturnValue({
@@ -117,16 +152,8 @@ describe("SelectionResultsPage", () => {
isError: false,
isPending: false,
})
useSelectionResults.mockReturnValue({
data: selectedResult,
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
useSelectionRun.mockReturnValue(emptySelectionQuery())
useTriggerSelectionRun.mockReturnValue({
isError: false,
isPending: false,
@@ -136,6 +163,8 @@ describe("SelectionResultsPage", () => {
mutate.mockReset()
reset.mockReset()
routerNavigate.mockReset()
fetchNextPage.mockReset()
fetchNextPage.mockResolvedValue(undefined)
})
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("华纳药厂")).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", {
name: "当前股票详情",
@@ -224,14 +265,12 @@ describe("SelectionResultsPage", () => {
])(
"keeps selection signals visible when score state changes",
({ expected, score }) => {
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
stocks: [{ ...selectedResult.stocks[0], score }],
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
@@ -285,8 +324,8 @@ describe("SelectionResultsPage", () => {
})
it("opens execution status details in a drawer and restores trigger focus", async () => {
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
failed_count: 1,
failures: [
@@ -298,10 +337,8 @@ describe("SelectionResultsPage", () => {
},
],
status: "partial_success",
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
@@ -376,16 +413,14 @@ describe("SelectionResultsPage", () => {
})
it("keeps execution status accessible when no signals are hit", async () => {
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
signal_count: 0,
stocks: [],
stocks_total: 0,
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
@@ -401,8 +436,8 @@ describe("SelectionResultsPage", () => {
})
it("keeps execution status accessible for a failed result", async () => {
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
failed_count: 2,
failures: [
@@ -422,10 +457,8 @@ describe("SelectionResultsPage", () => {
signal_count: 0,
stocks: [],
status: "failed",
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
@@ -440,7 +473,7 @@ describe("SelectionResultsPage", () => {
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 />)
expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent(
@@ -472,13 +505,13 @@ describe("SelectionResultsPage", () => {
searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }),
).toEqual({
category: "oversold",
page: 1,
page: 4,
pageSize: 5,
search: "",
})
})
it("requests database-backed score sorting and resets the page", async () => {
it("requests database-backed score sorting", async () => {
render(<SelectionResultsPage />)
const sortTrigger = screen.getByRole("combobox", {
@@ -507,14 +540,14 @@ describe("SelectionResultsPage", () => {
}),
).toEqual({
category: "all",
page: 1,
page: 3,
pageSize: 5,
search: "",
sort: "score_desc",
})
})
it("filters signals, updates the detail panel, and expands mobile details", () => {
it("filters signals and updates the detail panel", () => {
const extraStock = {
...selectedResult.stocks[0],
name: "招商银行",
@@ -529,16 +562,14 @@ describe("SelectionResultsPage", () => {
],
ts_code: "600036.SH",
}
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
signal_count: 3,
stocks: [...selectedResult.stocks, extraStock],
stocks_total: 2,
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
@@ -555,24 +586,19 @@ describe("SelectionResultsPage", () => {
const selectButton = within(extraRecord).getByRole("button", {
name: /招商银行/,
})
fireEvent.keyDown(selectButton, { key: "Enter" })
expect(
within(
screen.getByRole("complementary", { name: "当前股票详情" }),
).getByText("招商银行"),
).toBeInTheDocument()
fireEvent.keyDown(selectButton, { key: " " })
const expandButton = within(extraRecord).getByRole("button", {
name: "查看详情",
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
fireEvent.click(expandButton)
expect(expandButton).toHaveAttribute("aria-expanded", "true")
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
expect(screen.getAllByText("3.2").length).toBeGreaterThan(1)
expect(within(detailPanel).getByText("招商银行")).toBeInTheDocument()
expect(within(detailPanel).getByText("MACD")).toBeInTheDocument()
expect(within(detailPanel).getByText("3.2")).toBeInTheDocument()
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) => ({
...selectedResult.stocks[0],
name: `股票${index + 1}`,
@@ -585,23 +611,121 @@ describe("SelectionResultsPage", () => {
],
ts_code: `00000${index + 1}.SZ`,
}))
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
selected_stock_count: stocks.length,
signal_count: stocks.length,
stocks,
stocks_total: stocks.length,
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
expect(screen.getByText("第 1–5 条,共 6 条")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "第 2 页" }))
expect(routerNavigate).toHaveBeenCalled()
expect(screen.getAllByText("股票1").length).toBeGreaterThan(0)
expect(screen.getByText("股票6")).toBeInTheDocument()
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", () => {
@@ -634,8 +758,8 @@ describe("SelectionResultsPage", () => {
})
it("uses the same confirmation flow for a failed retry", () => {
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
failed_count: 1,
failures: [
@@ -650,10 +774,8 @@ describe("SelectionResultsPage", () => {
signal_count: 0,
stocks: [],
status: "failed",
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
@@ -664,17 +786,15 @@ describe("SelectionResultsPage", () => {
})
it("renders a no-data state and triggers an initial run after date selection", async () => {
useSelectionResults.mockReturnValue({
data: {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
run_id: null,
stocks: [],
status: "no_data",
target_trade_date: "2026-08-08",
},
isError: false,
isPending: false,
})
}),
)
render(<SelectionResultsPage />)
@@ -715,33 +835,35 @@ describe("SelectionResultsPage", () => {
})
it("continues polling a persisted running run after page reload", () => {
useSelectionResults.mockReturnValue({
data: { ...selectedResult, stocks: [], status: "running" },
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: { ...selectedResult, stocks: [], status: "running" },
isError: false,
isPending: false,
})
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
stocks: [],
status: "running",
}),
)
useSelectionRun.mockReturnValue(
selectionQueryResult({
...selectedResult,
stocks: [],
status: "running",
}),
)
render(<SelectionResultsPage />)
expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
page: 1,
pageSize: 5,
pageSize: 20,
sort: "code",
})
})
it("renders query failures explicitly", () => {
useSelectionResults.mockReturnValue({
data: undefined,
...emptySelectionQuery(),
isError: true,
isPending: false,
})
render(<SelectionResultsPage />)