feat: unify backtest table controls and add refresh action
Deploy production / deploy (push) Successful in 53s
Deploy production / deploy (push) Successful in 53s
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# 实现与验证
|
||||||
|
Type: task
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
- [x] 共用表格、工具栏、图标刷新
|
||||||
|
- [x] 服务端名称筛选与排序
|
||||||
|
- [x] 后端与浏览器回归、构建
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
用户授权本地实现,未要求本次提交或推送。
|
||||||
|
|
||||||
|
本地实现完成。后端 20 项测试及 ruff 检查通过,前端构建通过;4 项浏览器用例通过,轮询慢请求保护修改后工具栏用例再次通过。验证了名称/来源筛选、排序回到第一页、手动刷新、列显隐、40px 行高、桌面与窄屏固定分页,以及原有草稿/预览/运行和 MCP 来源流程。使用模拟平台,无真实回测请求。构建保留已有依赖 eval 与包体积警告。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 回测研究表格交互
|
||||||
|
运行记录与候选草稿复用目录工具栏和 WorkspaceTable:筛选浮层、列显隐持久化、服务端名称/时间排序、图标刷新与调度设置、固定分页及 25/50/100 条容量。保留自动刷新和现有候选/运行详情操作;手动刷新当前视图,避免旧请求覆盖新筛选。
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Authenticated adapters; every mutation is committed before the execution lane wakes."""
|
"""Authenticated adapters; every mutation is committed before the execution lane wakes."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
from ..business import Business
|
from ..business import Business
|
||||||
@@ -48,9 +50,16 @@ async def configure(body: SchedulerInput, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/drafts")
|
@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:
|
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)
|
@router.post("/drafts", status_code=201)
|
||||||
@@ -108,9 +117,14 @@ async def runs(
|
|||||||
source: str | None = Query(None, max_length=100),
|
source: str | None = Query(None, max_length=100),
|
||||||
reference: str | None = Query(None, max_length=200),
|
reference: str | None = Query(None, max_length=200),
|
||||||
research_id: 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:
|
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])
|
@router.get("/sources", response_model=list[str])
|
||||||
|
|||||||
@@ -151,11 +151,16 @@ class Backtests:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return await self.draft(draft_id)
|
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 = (
|
rows = (
|
||||||
await self.db.scalars(
|
await self.db.scalars(
|
||||||
select(BacktestDraft)
|
query
|
||||||
.order_by(BacktestDraft.updated_at.desc(), BacktestDraft.id)
|
.order_by(order, BacktestDraft.id)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
@@ -173,7 +178,7 @@ class Backtests:
|
|||||||
)
|
)
|
||||||
for r in rows
|
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,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
@@ -355,15 +360,19 @@ class Backtests:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return await self.run(run.id)
|
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)
|
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)):
|
for key, value in (("kind", source), ("reference", reference), ("research_id", research_id)):
|
||||||
if value:
|
if value:
|
||||||
query = query.where(BacktestRun.source[key].as_string() == value)
|
query = query.where(BacktestRun.source[key].as_string() == value)
|
||||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
rows = (
|
rows = (
|
||||||
await self.db.scalars(
|
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()
|
).all()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
|
||||||
import { useSettingsOptions } from "../settings/useSettingsOptions";
|
import { useSettingsOptions } from "../settings/useSettingsOptions";
|
||||||
import { settingsErrors } from "../settings/model";
|
import { settingsErrors } from "../settings/model";
|
||||||
@@ -6,7 +14,7 @@ import {
|
|||||||
researchSelection,
|
researchSelection,
|
||||||
} from "../preparations/ResearchDataInput";
|
} from "../preparations/ResearchDataInput";
|
||||||
import type { InputSnapshot } from "../research/workspaceTypes";
|
import type { InputSnapshot } from "../research/workspaceTypes";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
@@ -19,7 +27,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
TextArea,
|
TextArea,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} 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 type { PageContext, UIAction } from "../ai/types";
|
||||||
import { controlLabels, initialSettings, labels } from "./types";
|
import { controlLabels, initialSettings, labels } from "./types";
|
||||||
import type {
|
import type {
|
||||||
@@ -82,6 +90,30 @@ export function BacktestPage({
|
|||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState("runs");
|
const [view, setView] = useState("runs");
|
||||||
const [sourceFilter, setSourceFilter] = useState("");
|
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<string[]>([]);
|
const [sourceKinds, setSourceKinds] = useState<string[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [runs, setRuns] = useState<Page<Run>>({
|
const [runs, setRuns] = useState<Page<Run>>({
|
||||||
@@ -139,30 +171,56 @@ export function BacktestPage({
|
|||||||
const [itemDetail, setItemDetail] = useState<Item | null>(null);
|
const [itemDetail, setItemDetail] = useState<Item | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const [r, d, c, kinds] = await Promise.all([
|
const sequence = ++requestSequence.current;
|
||||||
api<Page<Run>>(
|
setLoading(true);
|
||||||
`/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`,
|
try {
|
||||||
),
|
const params = queryString({
|
||||||
api<Page<DraftSummary>>(`/backtests/drafts?offset=${(page - 1) * 25}`),
|
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<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
|
||||||
api<Scheduler>("/backtests/config"),
|
api<Scheduler>("/backtests/config"),
|
||||||
api<string[]>("/backtests/sources"),
|
api<string[]>("/backtests/sources"),
|
||||||
]);
|
]);
|
||||||
setRuns(r);
|
if (sequence !== requestSequence.current) return;
|
||||||
setDrafts(d);
|
if (view === "runs") setRuns(data as Page<Run>);
|
||||||
|
else setDrafts(data as Page<DraftSummary>);
|
||||||
|
setPage((p) =>
|
||||||
|
Math.min(p, Math.max(1, Math.ceil(data.total / pageSize))),
|
||||||
|
);
|
||||||
setConfig(c);
|
setConfig(c);
|
||||||
setSourceKinds(kinds);
|
setSourceKinds(kinds);
|
||||||
}, [page, sourceFilter]);
|
setError("");
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, sourceFilter, q, view, sort.value, sort.direction]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () =>
|
let pending = false;
|
||||||
refresh().catch((e) => {
|
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);
|
if (alive) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending = false;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
void load();
|
void load();
|
||||||
const timer = window.setInterval(load, 3000);
|
const timer = window.setInterval(load, 3000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
requestSequence.current++;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [active, refresh, version]);
|
}, [active, refresh, version]);
|
||||||
@@ -345,71 +403,7 @@ export function BacktestPage({
|
|||||||
setRun(r);
|
setRun(r);
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
const drawerVisible = active && !suspended;
|
const runTableColumns: ColumnProps<Run>[] = [
|
||||||
return (
|
|
||||||
<section className="backtest-page">
|
|
||||||
<div className="backtest-toolbar">
|
|
||||||
<Select
|
|
||||||
aria-label="回测视图"
|
|
||||||
value={view}
|
|
||||||
onChange={(v) => {
|
|
||||||
setView(String(v));
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
optionList={[
|
|
||||||
{ value: "runs", label: "运行记录" },
|
|
||||||
{ value: "drafts", label: "候选草稿" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<span className="muted">REGULAR · FASTEXPR</span>
|
|
||||||
{view === "runs" && (
|
|
||||||
<Select
|
|
||||||
aria-label="回测研究来源筛选"
|
|
||||||
value={sourceFilter || undefined}
|
|
||||||
placeholder="全部研究来源"
|
|
||||||
showClear
|
|
||||||
optionList={sourceKinds.map((kind) => ({
|
|
||||||
value: kind,
|
|
||||||
label: sourceLabel(kind),
|
|
||||||
}))}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSourceFilter(String(value ?? ""));
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="backtest-toolbar">
|
|
||||||
<Button
|
|
||||||
type="tertiary"
|
|
||||||
onClick={() => {
|
|
||||||
setSettingsDraft(config);
|
|
||||||
setRunId("");
|
|
||||||
setEditor(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
调度设置
|
|
||||||
</Button>
|
|
||||||
<Button theme="solid" onClick={newDraft}>
|
|
||||||
新建回测
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error && !editor && !runId && !settingsDraft && (
|
|
||||||
<Banner type="danger" description={error} />
|
|
||||||
)}
|
|
||||||
{config?.blocked_reason && (
|
|
||||||
<Banner type="warning" description={config.blocked_reason} />
|
|
||||||
)}
|
|
||||||
{view === "runs" ? (
|
|
||||||
<Table<Run>
|
|
||||||
className="alpha-table"
|
|
||||||
size="small"
|
|
||||||
rowKey="backtest_run_id"
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ x: 900, y: "100%" }}
|
|
||||||
dataSource={runs.items}
|
|
||||||
empty="尚无回测运行,准备候选并预览后启动。"
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
title: "运行",
|
title: "运行",
|
||||||
dataIndex: "name",
|
dataIndex: "name",
|
||||||
@@ -438,8 +432,7 @@ export function BacktestPage({
|
|||||||
{
|
{
|
||||||
title: "已保存 / 总数",
|
title: "已保存 / 总数",
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, r) =>
|
render: (_, r) => `${r!.counts.persistence.saved || 0} / ${r!.total}`,
|
||||||
`${r!.counts.persistence.saved || 0} / ${r!.total}`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "失败",
|
title: "失败",
|
||||||
@@ -451,18 +444,8 @@ export function BacktestPage({
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (_, r) => formatTime(r!.created_at, timezone),
|
render: (_, r) => formatTime(r!.created_at, timezone),
|
||||||
},
|
},
|
||||||
]}
|
];
|
||||||
/>
|
const draftTableColumns: ColumnProps<DraftSummary>[] = [
|
||||||
) : (
|
|
||||||
<Table<DraftSummary>
|
|
||||||
className="alpha-table"
|
|
||||||
size="small"
|
|
||||||
rowKey="id"
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ x: 700, y: "100%" }}
|
|
||||||
dataSource={drafts.items}
|
|
||||||
empty="尚无已保存草稿。"
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
title: "候选草稿",
|
title: "候选草稿",
|
||||||
dataIndex: "name",
|
dataIndex: "name",
|
||||||
@@ -485,16 +468,166 @@ export function BacktestPage({
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (_, d) => formatTime(d!.updated_at, timezone),
|
render: (_, d) => formatTime(d!.updated_at, timezone),
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
const drawerVisible = active && !suspended;
|
||||||
|
return (
|
||||||
|
<section className="backtest-page">
|
||||||
|
<CatalogTableToolbar
|
||||||
|
label="回测研究"
|
||||||
|
active={active && !suspended && !editor && !runId && !settingsDraft}
|
||||||
|
scope={
|
||||||
|
<Select
|
||||||
|
aria-label="回测视图"
|
||||||
|
value={view}
|
||||||
|
onChange={(v) => {
|
||||||
|
setView(String(v));
|
||||||
|
setPage(1);
|
||||||
|
setQ("");
|
||||||
|
setSourceFilter("");
|
||||||
|
setFilterDraft({ q: "", source: "" });
|
||||||
|
}}
|
||||||
|
optionList={[
|
||||||
|
{ value: "runs", label: "运行记录" },
|
||||||
|
{ value: "drafts", label: "候选草稿" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
columns={view === "runs" ? runColumns : draftColumns}
|
||||||
|
filterCount={
|
||||||
|
Number(Boolean(q)) + Number(view === "runs" && Boolean(sourceFilter))
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<div className="catalog-filter-grid">
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<Input
|
||||||
|
aria-label="回测名称筛选"
|
||||||
|
value={filterDraft.q}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(q) => setFilterDraft({ ...filterDraft, q })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{view === "runs" && (
|
||||||
|
<label>
|
||||||
|
研究来源
|
||||||
|
<Select
|
||||||
|
aria-label="回测研究来源筛选"
|
||||||
|
value={filterDraft.source || undefined}
|
||||||
|
placeholder="全部研究来源"
|
||||||
|
showClear
|
||||||
|
optionList={sourceKinds.map((kind) => ({
|
||||||
|
value: kind,
|
||||||
|
label: sourceLabel(kind),
|
||||||
|
}))}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFilterDraft({ ...filterDraft, source: String(v ?? "") })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
)}
|
)}
|
||||||
<footer className="table-pagination">
|
</div>
|
||||||
|
}
|
||||||
|
onApply={() => {
|
||||||
|
setQ(filterDraft.q);
|
||||||
|
setSourceFilter(filterDraft.source);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onReset={() => {
|
||||||
|
setQ("");
|
||||||
|
setSourceFilter("");
|
||||||
|
setFilterDraft({ q: "", source: "" });
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sort={{
|
||||||
|
...sort,
|
||||||
|
options: [
|
||||||
|
{ value: "name", label: "名称" },
|
||||||
|
{
|
||||||
|
value: view === "runs" ? "created_at" : "updated_at",
|
||||||
|
label: view === "runs" ? "创建时间" : "更新时间",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChange: (value, direction) => {
|
||||||
|
setSorting((old) => ({ ...old, [view]: { value, direction } }));
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="刷新回测"
|
||||||
|
icon={<IconRefresh aria-hidden="true" />}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => void perform(refresh)}
|
||||||
|
/>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="调度设置"
|
||||||
|
icon={<IconSetting aria-hidden="true" />}
|
||||||
|
disabled={!config}
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsDraft(config);
|
||||||
|
setRunId("");
|
||||||
|
setEditor(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconPlus aria-hidden="true" />}
|
||||||
|
onClick={newDraft}
|
||||||
|
>
|
||||||
|
新建回测
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error && !editor && !runId && !settingsDraft && (
|
||||||
|
<Banner type="danger" description={error} />
|
||||||
|
)}
|
||||||
|
{config?.blocked_reason && (
|
||||||
|
<Banner type="warning" description={config.blocked_reason} />
|
||||||
|
)}
|
||||||
|
{view === "runs" ? (
|
||||||
|
<WorkspaceTable<Run>
|
||||||
|
fill
|
||||||
|
className="alpha-table"
|
||||||
|
size="small"
|
||||||
|
rowKey="backtest_run_id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 900, y: "100%" }}
|
||||||
|
dataSource={runs.items}
|
||||||
|
empty="尚无回测运行,准备候选并预览后启动。"
|
||||||
|
columns={runTableColumns.filter((_, i) =>
|
||||||
|
runColumns.visible.includes(runColumns.choices[i].key),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WorkspaceTable<DraftSummary>
|
||||||
|
fill
|
||||||
|
className="alpha-table"
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 700, y: "100%" }}
|
||||||
|
dataSource={drafts.items}
|
||||||
|
empty="尚无已保存草稿。"
|
||||||
|
columns={draftTableColumns.filter((_, i) =>
|
||||||
|
draftColumns.visible.includes(draftColumns.choices[i].key),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<footer className="table-pagination workspace-table-footer">
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
共 {view === "runs" ? runs.total : drafts.total} 条
|
共 {view === "runs" ? runs.total : drafts.total} 条
|
||||||
</span>
|
</span>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
pageSize={25}
|
pageSize={pageSize}
|
||||||
|
showSizeChanger
|
||||||
|
pageSizeOpts={[25, 50, 100]}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
total={view === "runs" ? runs.total : drafts.total}
|
total={view === "runs" ? runs.total : drafts.total}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 12px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
.backtest-toolbar {
|
.backtest-toolbar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -188,9 +188,110 @@ test("MCP direct batch appears through its run link and source filter", async ({
|
|||||||
.getByRole("heading")
|
.getByRole("heading")
|
||||||
.getByRole("button")
|
.getByRole("button")
|
||||||
.click();
|
.click();
|
||||||
|
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
|
||||||
await page.getByText("全部研究来源", { exact: true }).click();
|
await page.getByText("全部研究来源", { exact: true }).click();
|
||||||
await page.getByRole("option", { name: /MCP 研究/ }).click();
|
await page.getByRole("option", { name: /MCP 研究/ }).click();
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
|
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("backtest toolbar filters, sorts, refreshes and preserves compact layout", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
let revision = 1;
|
||||||
|
const requests: URL[] = [];
|
||||||
|
await page.route("**/api/v1/backtests/runs?*", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
requests.push(url);
|
||||||
|
const offset = Number(url.searchParams.get("offset") || 0);
|
||||||
|
const limit = Number(url.searchParams.get("limit") || 25);
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
total: 60,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
items: Array.from({ length: Math.min(limit, 60 - offset) }, (_, i) => ({
|
||||||
|
backtest_run_id: `run-${offset + i}`,
|
||||||
|
name: `工具栏验收-${revision}-${offset + i} ` + "长名称".repeat(20),
|
||||||
|
source: { kind: "manual" },
|
||||||
|
status: "running",
|
||||||
|
total: 8,
|
||||||
|
counts: { persistence: { saved: 2 }, platform: { failed: 0 } },
|
||||||
|
created_at: "2026-09-12T10:00:00Z",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
const table = page.locator(".backtest-page .workspace-table");
|
||||||
|
await expect(
|
||||||
|
table.getByRole("row").filter({ hasText: "工具栏验收-1-0" }),
|
||||||
|
).toBeVisible();
|
||||||
|
revision = 2;
|
||||||
|
await page.getByRole("button", { name: "刷新回测", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
table.getByRole("row").filter({ hasText: "工具栏验收-2-0" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
|
||||||
|
await page.getByLabel("回测名称筛选").fill("test-filter");
|
||||||
|
expect(
|
||||||
|
requests.some((url) => url.searchParams.get("q") === "test-filter"),
|
||||||
|
).toBe(false);
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some((url) => url.searchParams.get("q") === "test-filter"),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.getByRole("button", { name: "显示列设置", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.locator(".catalog-column-panel .semi-checkbox")
|
||||||
|
.filter({ hasText: "失败" })
|
||||||
|
.click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(
|
||||||
|
table.getByRole("columnheader", { name: "失败", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.locator(".backtest-page .semi-page-item")
|
||||||
|
.filter({ hasText: /^2$/ })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => requests.some((url) => url.searchParams.get("offset") === "25"))
|
||||||
|
.toBe(true);
|
||||||
|
await page.getByRole("button", { name: "排序设置", exact: true }).click();
|
||||||
|
await page.getByRole("combobox", { name: "排序字段", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("option")
|
||||||
|
.filter({ hasText: /^名称$/ })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some(
|
||||||
|
(url) =>
|
||||||
|
url.searchParams.get("sort") === "name" &&
|
||||||
|
url.searchParams.get("offset") === "0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
for (const width of [1440, 850]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const row = table.locator("tbody tr.semi-table-row").first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
expect(await row.evaluate((el) => el.getBoundingClientRect().height)).toBe(
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
const footer = await page
|
||||||
|
.locator(".backtest-page .workspace-table-footer")
|
||||||
|
.boundingBox();
|
||||||
|
expect(footer!.y + footer!.height).toBeLessThanOrEqual(900);
|
||||||
|
expect(footer!.y + footer!.height).toBeGreaterThan(860);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `../output/playwright/backtest-toolbar-${width}.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user