feat: unify backtest table controls and add refresh action
Deploy production / deploy (push) Successful in 53s

This commit is contained in:
yuxuanhui
2026-09-12 13:29:00 +08:00
parent ed349693db
commit 8206b36cd2
8 changed files with 453 additions and 142 deletions
+265 -132
View File
@@ -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<string[]>([]);
const [page, setPage] = useState(1);
const [runs, setRuns] = useState<Page<Run>>({
@@ -139,30 +171,56 @@ export function BacktestPage({
const [itemDetail, setItemDetail] = useState<Item | null>(null);
const refresh = useCallback(async () => {
const [r, d, c, kinds] = await Promise.all([
api<Page<Run>>(
`/backtests/runs?offset=${(page - 1) * 25}&source=${encodeURIComponent(sourceFilter)}`,
),
api<Page<DraftSummary>>(`/backtests/drafts?offset=${(page - 1) * 25}`),
api<Scheduler>("/backtests/config"),
api<string[]>("/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<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
api<Scheduler>("/backtests/config"),
api<string[]>("/backtests/sources"),
]);
if (sequence !== requestSequence.current) return;
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);
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<Run>[] = [
{
title: "运行",
dataIndex: "name",
width: 240,
render: (value, r) => (
<Button
theme="borderless"
className="backtest-name-button"
title={String(value)}
onClick={() => openRun(r!.backtest_run_id)}
>
{String(value)}
</Button>
),
},
{
title: "来源",
width: 120,
render: (_, r) => sourceLabel(r!.source.kind),
},
{
title: "状态",
width: 160,
render: (_, r) => <BacktestStatusTag status={r!.status} />,
},
{
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<DraftSummary>[] = [
{
title: "候选草稿",
dataIndex: "name",
width: 260,
render: (v, d) => (
<Button
theme="borderless"
className="backtest-name-button"
title={String(v)}
onClick={() => void perform(() => loadDraft(d!.id))}
>
{String(v)}
</Button>
),
},
{ 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 (
<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" && (
<CatalogTableToolbar
label="回测研究"
active={active && !suspended && !editor && !runId && !settingsDraft}
scope={
<Select
aria-label="回测研究来源筛选"
value={sourceFilter || undefined}
placeholder="全部研究来源"
showClear
optionList={sourceKinds.map((kind) => ({
value: kind,
label: sourceLabel(kind),
}))}
onChange={(value) => {
setSourceFilter(String(value ?? ""));
aria-label="回测视图"
value={view}
onChange={(v) => {
setView(String(v));
setPage(1);
setQ("");
setSourceFilter("");
setFilterDraft({ q: "", source: "" });
}}
optionList={[
{ value: "runs", label: "运行记录" },
{ value: "drafts", label: "候选草稿" },
]}
/>
)}
</div>
<div className="backtest-toolbar">
<Button
type="tertiary"
onClick={() => {
setSettingsDraft(config);
setRunId("");
setEditor(false);
}}
>
调度设置
</Button>
<Button theme="solid" onClick={newDraft}>
新建回测
</Button>
</div>
}
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>
)}
</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} />
)}
@@ -401,7 +587,8 @@ export function BacktestPage({
<Banner type="warning" description={config.blocked_reason} />
)}
{view === "runs" ? (
<Table<Run>
<WorkspaceTable<Run>
fill
className="alpha-table"
size="small"
rowKey="backtest_run_id"
@@ -409,52 +596,13 @@ export function BacktestPage({
scroll={{ x: 900, y: "100%" }}
dataSource={runs.items}
empty="尚无回测运行,准备候选并预览后启动。"
columns={[
{
title: "运行",
dataIndex: "name",
width: 240,
render: (value, r) => (
<Button
theme="borderless"
className="backtest-name-button"
title={String(value)}
onClick={() => openRun(r!.backtest_run_id)}
>
{String(value)}
</Button>
),
},
{
title: "来源",
width: 120,
render: (_, r) => sourceLabel(r!.source.kind),
},
{
title: "状态",
width: 160,
render: (_, r) => <BacktestStatusTag status={r!.status} />,
},
{
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),
},
]}
columns={runTableColumns.filter((_, i) =>
runColumns.visible.includes(runColumns.choices[i].key),
)}
/>
) : (
<Table<DraftSummary>
<WorkspaceTable<DraftSummary>
fill
className="alpha-table"
size="small"
rowKey="id"
@@ -462,39 +610,24 @@ export function BacktestPage({
scroll={{ x: 700, y: "100%" }}
dataSource={drafts.items}
empty="尚无已保存草稿。"
columns={[
{
title: "候选草稿",
dataIndex: "name",
width: 260,
render: (v, d) => (
<Button
theme="borderless"
className="backtest-name-button"
title={String(v)}
onClick={() => void perform(() => loadDraft(d!.id))}
>
{String(v)}
</Button>
),
},
{ title: "候选数", dataIndex: "total", width: 100 },
{ title: "版本", dataIndex: "version", width: 80 },
{
title: "更新时间",
width: 180,
render: (_, d) => formatTime(d!.updated_at, timezone),
},
]}
columns={draftTableColumns.filter((_, i) =>
draftColumns.visible.includes(draftColumns.choices[i].key),
)}
/>
)}
<footer className="table-pagination">
<footer className="table-pagination workspace-table-footer">
<span className="muted">
共 {view === "runs" ? runs.total : drafts.total} 条
</span>
<Pagination
currentPage={page}
pageSize={25}
pageSize={pageSize}
showSizeChanger
pageSizeOpts={[25, 50, 100]}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
total={view === "runs" ? runs.total : drafts.total}
onPageChange={setPage}
/>
+1 -1
View File
@@ -4,7 +4,7 @@
height: 100%;
min-height: 0;
min-width: 0;
gap: 12px;
gap: 0;
}
.backtest-toolbar {
flex-shrink: 0;
+101
View File
@@ -188,9 +188,110 @@ test("MCP direct batch appears through its run link and source filter", async ({
.getByRole("heading")
.getByRole("button")
.click();
await page.getByRole("button", { name: "筛选回测研究", exact: true }).click();
await page.getByText("全部研究来源", { exact: true }).click();
await page.getByRole("option", { name: /MCP 研究/ }).click();
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
).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`,
});
}
});