diff --git a/.scratch/backtest-table-interactions/issues/01-implementation.md b/.scratch/backtest-table-interactions/issues/01-implementation.md new file mode 100644 index 0000000..48a18e6 --- /dev/null +++ b/.scratch/backtest-table-interactions/issues/01-implementation.md @@ -0,0 +1,12 @@ +# 实现与验证 +Type: task +Status: ready-for-agent + +- [x] 共用表格、工具栏、图标刷新 +- [x] 服务端名称筛选与排序 +- [x] 后端与浏览器回归、构建 + +## Comments +用户授权本地实现,未要求本次提交或推送。 + +本地实现完成。后端 20 项测试及 ruff 检查通过,前端构建通过;4 项浏览器用例通过,轮询慢请求保护修改后工具栏用例再次通过。验证了名称/来源筛选、排序回到第一页、手动刷新、列显隐、40px 行高、桌面与窄屏固定分页,以及原有草稿/预览/运行和 MCP 来源流程。使用模拟平台,无真实回测请求。构建保留已有依赖 eval 与包体积警告。 diff --git a/.scratch/backtest-table-interactions/spec.md b/.scratch/backtest-table-interactions/spec.md new file mode 100644 index 0000000..d28b0ce --- /dev/null +++ b/.scratch/backtest-table-interactions/spec.md @@ -0,0 +1,2 @@ +# 回测研究表格交互 +运行记录与候选草稿复用目录工具栏和 WorkspaceTable:筛选浮层、列显隐持久化、服务端名称/时间排序、图标刷新与调度设置、固定分页及 25/50/100 条容量。保留自动刷新和现有候选/运行详情操作;手动刷新当前视图,避免旧请求覆盖新筛选。 diff --git a/backend/app/backtests/routes.py b/backend/app/backtests/routes.py index ca4fbac..2ca53a5 100644 --- a/backend/app/backtests/routes.py +++ b/backend/app/backtests/routes.py @@ -1,5 +1,7 @@ """Authenticated adapters; every mutation is committed before the execution lane wakes.""" +from typing import Literal + from fastapi import APIRouter, Depends, Query, Request from ..business import Business @@ -48,9 +50,16 @@ async def configure(body: SchedulerInput, request: Request): @router.get("/drafts") -async def drafts(request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)): +async def drafts( + request: Request, + limit: int = Query(25, ge=1, le=100), + offset: int = Query(0, ge=0), + q: str = Query("", max_length=200), + sort: Literal["name", "updated_at"] = "updated_at", + direction: Literal["asc", "desc"] = "desc", +): async with request.app.state.sessions() as db: - return await Business(db).backtests.drafts(limit, offset) + return await Business(db).backtests.drafts(limit, offset, q, sort, direction) @router.post("/drafts", status_code=201) @@ -108,9 +117,14 @@ async def runs( source: str | None = Query(None, max_length=100), reference: str | None = Query(None, max_length=200), research_id: str | None = Query(None, max_length=200), + q: str = Query("", max_length=200), + sort: Literal["name", "created_at"] = "created_at", + direction: Literal["asc", "desc"] = "desc", ): async with request.app.state.sessions() as db: - return await Business(db).backtests.runs(limit, offset, source, reference, research_id) + return await Business(db).backtests.runs( + limit, offset, source, reference, research_id, q, sort, direction + ) @router.get("/sources", response_model=list[str]) diff --git a/backend/app/backtests/service.py b/backend/app/backtests/service.py index 78bce67..59056ef 100644 --- a/backend/app/backtests/service.py +++ b/backend/app/backtests/service.py @@ -151,11 +151,16 @@ class Backtests: await self.db.flush() return await self.draft(draft_id) - async def drafts(self, limit=25, offset=0): + async def drafts(self, limit=25, offset=0, q="", sort="updated_at", direction="desc"): + query = select(BacktestDraft) + if q: + query = query.where(BacktestDraft.name.contains(q, autoescape=True)) + column = {"name": BacktestDraft.name, "updated_at": BacktestDraft.updated_at}[sort] + order = column.asc() if direction == "asc" else column.desc() rows = ( await self.db.scalars( - select(BacktestDraft) - .order_by(BacktestDraft.updated_at.desc(), BacktestDraft.id) + query + .order_by(order, BacktestDraft.id) .limit(limit) .offset(offset) ) @@ -173,7 +178,7 @@ class Backtests: ) for r in rows ], - "total": await self.db.scalar(select(func.count()).select_from(BacktestDraft)), + "total": await self.db.scalar(select(func.count()).select_from(query.subquery())), "limit": limit, "offset": offset, } @@ -355,15 +360,19 @@ class Backtests: await self.db.flush() return await self.run(run.id) - async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None): + async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None, q="", sort="created_at", direction="desc"): query = select(BacktestRun) + if q: + query = query.where(BacktestRun.name.contains(q, autoescape=True)) + column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort] + order = column.asc() if direction == "asc" else column.desc() for key, value in (("kind", source), ("reference", reference), ("research_id", research_id)): if value: query = query.where(BacktestRun.source[key].as_string() == value) total = await self.db.scalar(select(func.count()).select_from(query.subquery())) rows = ( await self.db.scalars( - query.order_by(BacktestRun.created_at.desc(), BacktestRun.id).limit(limit).offset(offset) + query.order_by(order, BacktestRun.id).limit(limit).offset(offset) ) ).all() return { diff --git a/backend/tests/test_backtest_list_sort.py b/backend/tests/test_backtest_list_sort.py new file mode 100644 index 0000000..2250f05 --- /dev/null +++ b/backend/tests/test_backtest_list_sort.py @@ -0,0 +1,40 @@ +"""List filtering and ordering happen before pagination, without platform execution.""" + +from tests.test_backtests import PREFIX, candidate, setup, start + + +async def test_list_sorting(app, logged_in): + await setup(app) + for name in ["sort-C", "sort-A", "sort-B", "unrelated"]: + body = {"name": name, "source": {"kind": "test"}, "candidates": [candidate()]} + assert (await logged_in.post(f"{PREFIX}/drafts", json=body)).status_code == 201 + preview = await logged_in.post(f"{PREFIX}/previews", json={"inline": body}) + assert preview.status_code == 201 + await start(logged_in, preview.json(), key=name) + for path in ["runs", "drafts"]: + for direction, expected in [ + ("asc", ["sort-A", "sort-B", "sort-C"]), + ("desc", ["sort-C", "sort-B", "sort-A"]), + ]: + names = [] + for offset in range(3): + r = await logged_in.get( + f"{PREFIX}/{path}", + params={ + "q": "sort-", + "sort": "name", + "direction": direction, + "limit": 1, + "offset": offset, + }, + ) + assert r.status_code == 200 + assert r.json()["total"] == 3 + names.append(r.json()["items"][0]["name"]) + assert names == expected + for params in [{"sort": "unknown"}, {"direction": "unknown"}]: + assert (await logged_in.get(f"{PREFIX}/{path}", params=params)).status_code == 422 + assert (await logged_in.get(f"{PREFIX}/{path}", params={"q": "%"})).json()["total"] == 0 + assert (await logged_in.get(f"{PREFIX}/runs", params={"q": "sort-", "source": "missing"})).json()[ + "total" + ] == 0 diff --git a/frontend/src/backtests/BacktestPage.tsx b/frontend/src/backtests/BacktestPage.tsx index e1cb5c0..4a312fe 100644 --- a/frontend/src/backtests/BacktestPage.tsx +++ b/frontend/src/backtests/BacktestPage.tsx @@ -1,3 +1,11 @@ +import { + CatalogTableToolbar, + CatalogIconAction, + useCatalogColumns, +} from "../components/CatalogTableToolbar"; +import { WorkspaceTable } from "../components/WorkspaceTable"; +import { IconRefresh, IconSetting, IconPlus } from "@douyinfe/semi-icons"; +import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface"; import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor"; import { useSettingsOptions } from "../settings/useSettingsOptions"; import { settingsErrors } from "../settings/model"; @@ -6,7 +14,7 @@ import { researchSelection, } from "../preparations/ResearchDataInput"; import type { InputSnapshot } from "../research/workspaceTypes"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Banner, Button, @@ -19,7 +27,7 @@ import { Tag, TextArea, } from "@douyinfe/semi-ui-19"; -import { api, formatNumber, formatTime, post } from "../api"; +import { api, formatNumber, formatTime, post, queryString } from "../api"; import type { PageContext, UIAction } from "../ai/types"; import { controlLabels, initialSettings, labels } from "./types"; import type { @@ -82,6 +90,30 @@ export function BacktestPage({ }) { const [view, setView] = useState("runs"); const [sourceFilter, setSourceFilter] = useState(""); + const [q, setQ] = useState(""); + const [filterDraft, setFilterDraft] = useState({ q: "", source: "" }); + const [pageSize, setPageSize] = useState(25); + const [loading, setLoading] = useState(false); + const requestSequence = useRef(0); + const [sorting, setSorting] = useState({ + runs: { value: "created_at", direction: "desc" }, + drafts: { value: "updated_at", direction: "desc" }, + }); + const sort = sorting[view === "runs" ? "runs" : "drafts"]; + const runColumns = useCatalogColumns("backtest-run-columns", [ + { key: "name", label: "运行", required: true }, + { key: "source", label: "来源" }, + { key: "status", label: "状态" }, + { key: "saved", label: "已保存 / 总数" }, + { key: "failed", label: "失败" }, + { key: "created_at", label: "创建时间" }, + ]); + const draftColumns = useCatalogColumns("backtest-draft-columns", [ + { key: "name", label: "候选草稿", required: true }, + { key: "total", label: "候选数" }, + { key: "version", label: "版本" }, + { key: "updated_at", label: "更新时间" }, + ]); const [sourceKinds, setSourceKinds] = useState([]); const [page, setPage] = useState(1); const [runs, setRuns] = useState>({ @@ -139,30 +171,56 @@ export function BacktestPage({ const [itemDetail, setItemDetail] = useState(null); const refresh = useCallback(async () => { - const [r, d, c, kinds] = await Promise.all([ - api>( - `/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`, - ), - api>(`/backtests/drafts?offset=${(page - 1) * 25}`), - api("/backtests/config"), - api("/backtests/sources"), - ]); - setRuns(r); - setDrafts(d); - setConfig(c); - setSourceKinds(kinds); - }, [page, sourceFilter]); + const sequence = ++requestSequence.current; + setLoading(true); + try { + const params = queryString({ + q, + limit: pageSize, + offset: (page - 1) * pageSize, + sort: sort.value, + direction: sort.direction, + ...(view === "runs" ? { source: sourceFilter } : {}), + }); + const [data, c, kinds] = await Promise.all([ + api | Page>(`/backtests/${view}?${params}`), + api("/backtests/config"), + api("/backtests/sources"), + ]); + if (sequence !== requestSequence.current) return; + if (view === "runs") setRuns(data as Page); + else setDrafts(data as Page); + setPage((p) => + Math.min(p, Math.max(1, Math.ceil(data.total / pageSize))), + ); + setConfig(c); + setSourceKinds(kinds); + setError(""); + } finally { + if (sequence === requestSequence.current) setLoading(false); + } + }, [page, pageSize, sourceFilter, q, view, sort.value, sort.direction]); useEffect(() => { if (!active) return; let alive = true; - const load = () => - refresh().catch((e) => { - if (alive) setError(e.message); - }); + let pending = false; + const load = () => { + // Slow responses must finish before the next polling request supersedes them. + if (pending) return; + pending = true; + void refresh() + .catch((e) => { + if (alive) setError(e.message); + }) + .finally(() => { + pending = false; + }); + }; void load(); const timer = window.setInterval(load, 3000); return () => { alive = false; + requestSequence.current++; clearInterval(timer); }; }, [active, refresh, version]); @@ -345,55 +403,183 @@ export function BacktestPage({ setRun(r); await refresh(); } + const runTableColumns: ColumnProps[] = [ + { + title: "运行", + dataIndex: "name", + width: 240, + render: (value, r) => ( + + ), + }, + { + title: "来源", + width: 120, + render: (_, r) => sourceLabel(r!.source.kind), + }, + { + title: "状态", + width: 160, + render: (_, r) => , + }, + { + title: "已保存 / 总数", + width: 120, + render: (_, r) => `${r!.counts.persistence.saved || 0} / ${r!.total}`, + }, + { + title: "失败", + width: 80, + render: (_, r) => r!.counts.platform.failed || 0, + }, + { + title: "创建时间", + width: 180, + render: (_, r) => formatTime(r!.created_at, timezone), + }, + ]; + const draftTableColumns: ColumnProps[] = [ + { + title: "候选草稿", + dataIndex: "name", + width: 260, + render: (v, d) => ( + + ), + }, + { title: "候选数", dataIndex: "total", width: 100 }, + { title: "版本", dataIndex: "version", width: 80 }, + { + title: "更新时间", + width: 180, + render: (_, d) => formatTime(d!.updated_at, timezone), + }, + ]; const drawerVisible = active && !suspended; return (
-
- setFilterDraft({ ...filterDraft, q })} + /> + + {view === "runs" && ( +