Compare commits

...

2 Commits

Author SHA1 Message Date
yuxuanhui 8206b36cd2 feat: unify backtest table controls and add refresh action
Deploy production / deploy (push) Successful in 53s
2026-09-12 13:29:00 +08:00
yuxuanhui ed349693db fix: align scope picker with catalog toolbar 2026-09-12 13:21:08 +08:00
14 changed files with 531 additions and 159 deletions
@@ -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 条容量。保留自动刷新和现有候选/运行详情操作;手动刷新当前视图,避免旧请求覆盖新筛选。
+17 -3
View File
@@ -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])
+15 -6
View File
@@ -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 {
+40
View File
@@ -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
+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 { 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,
api<Scheduler>("/backtests/config"), limit: pageSize,
api<string[]>("/backtests/sources"), offset: (page - 1) * pageSize,
]); sort: sort.value,
setRuns(r); direction: sort.direction,
setDrafts(d); ...(view === "runs" ? { source: sourceFilter } : {}),
setConfig(c); });
setSourceKinds(kinds); const [data, c, kinds] = await Promise.all([
}, [page, sourceFilter]); 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(() => { useEffect(() => {
if (!active) return; if (!active) return;
let alive = true; let alive = true;
const load = () => let pending = false;
refresh().catch((e) => { const load = () => {
if (alive) setError(e.message); // 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(); 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,55 +403,183 @@ export function BacktestPage({
setRun(r); setRun(r);
await refresh(); 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; const drawerVisible = active && !suspended;
return ( return (
<section className="backtest-page"> <section className="backtest-page">
<div className="backtest-toolbar"> <CatalogTableToolbar
<Select label="回测研究"
aria-label="回测视图" active={active && !suspended && !editor && !runId && !settingsDraft}
value={view} scope={
onChange={(v) => {
setView(String(v));
setPage(1);
}}
optionList={[
{ value: "runs", label: "运行记录" },
{ value: "drafts", label: "候选草稿" },
]}
/>
<span className="muted">REGULAR · FASTEXPR</span>
{view === "runs" && (
<Select <Select
aria-label="回测研究来源筛选" aria-label="回测视图"
value={sourceFilter || undefined} value={view}
placeholder="全部研究来源" onChange={(v) => {
showClear setView(String(v));
optionList={sourceKinds.map((kind) => ({
value: kind,
label: sourceLabel(kind),
}))}
onChange={(value) => {
setSourceFilter(String(value ?? ""));
setPage(1); setPage(1);
setQ("");
setSourceFilter("");
setFilterDraft({ q: "", source: "" });
}} }}
optionList={[
{ value: "runs", label: "运行记录" },
{ value: "drafts", label: "候选草稿" },
]}
/> />
)} }
</div> columns={view === "runs" ? runColumns : draftColumns}
<div className="backtest-toolbar"> filterCount={
<Button Number(Boolean(q)) + Number(view === "runs" && Boolean(sourceFilter))
type="tertiary" }
onClick={() => { filters={
setSettingsDraft(config); <div className="catalog-filter-grid">
setRunId(""); <label>
setEditor(false); 名称
}} <Input
> aria-label="回测名称筛选"
调度设置 value={filterDraft.q}
</Button> maxLength={200}
<Button theme="solid" onClick={newDraft}> onChange={(q) => setFilterDraft({ ...filterDraft, q })}
新建回测 />
</Button> </label>
</div> {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 && ( {error && !editor && !runId && !settingsDraft && (
<Banner type="danger" description={error} /> <Banner type="danger" description={error} />
)} )}
@@ -401,7 +587,8 @@ export function BacktestPage({
<Banner type="warning" description={config.blocked_reason} /> <Banner type="warning" description={config.blocked_reason} />
)} )}
{view === "runs" ? ( {view === "runs" ? (
<Table<Run> <WorkspaceTable<Run>
fill
className="alpha-table" className="alpha-table"
size="small" size="small"
rowKey="backtest_run_id" rowKey="backtest_run_id"
@@ -409,52 +596,13 @@ export function BacktestPage({
scroll={{ x: 900, y: "100%" }} scroll={{ x: 900, y: "100%" }}
dataSource={runs.items} dataSource={runs.items}
empty="尚无回测运行,准备候选并预览后启动。" empty="尚无回测运行,准备候选并预览后启动。"
columns={[ columns={runTableColumns.filter((_, i) =>
{ runColumns.visible.includes(runColumns.choices[i].key),
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),
},
]}
/> />
) : ( ) : (
<Table<DraftSummary> <WorkspaceTable<DraftSummary>
fill
className="alpha-table" className="alpha-table"
size="small" size="small"
rowKey="id" rowKey="id"
@@ -462,39 +610,24 @@ export function BacktestPage({
scroll={{ x: 700, y: "100%" }} scroll={{ x: 700, y: "100%" }}
dataSource={drafts.items} dataSource={drafts.items}
empty="尚无已保存草稿。" empty="尚无已保存草稿。"
columns={[ columns={draftTableColumns.filter((_, i) =>
{ draftColumns.visible.includes(draftColumns.choices[i].key),
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),
},
]}
/> />
)} )}
<footer className="table-pagination"> <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}
/> />
+1 -1
View File
@@ -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;
@@ -70,6 +70,7 @@ export function CatalogTableToolbar({
label, label,
active = true, active = true,
filterCount = 0, filterCount = 0,
scope,
filters, filters,
onApply, onApply,
onReset, onReset,
@@ -82,6 +83,7 @@ export function CatalogTableToolbar({
label: string; label: string;
active?: boolean; active?: boolean;
filterCount?: number; filterCount?: number;
scope?: ReactNode;
filters: ReactNode; filters: ReactNode;
onApply: () => void; onApply: () => void;
onReset: () => void; onReset: () => void;
@@ -149,6 +151,7 @@ export function CatalogTableToolbar({
role="toolbar" role="toolbar"
aria-label={`${label}表格工具栏`} aria-label={`${label}表格工具栏`}
> >
{scope}
<div className="catalog-toolbar-tools"> <div className="catalog-toolbar-tools">
{popup( {popup(
"filter", "filter",
+9 -6
View File
@@ -310,12 +310,6 @@ export function DatasetPage({
); );
return ( return (
<section className="preparation-page"> <section className="preparation-page">
<ScopeControls
value={scope}
onChange={setScope}
active={active}
revision={version}
/>
{error && <Banner type="danger" description={error} />} {error && <Banner type="danger" description={error} />}
{copied && ( {copied && (
<Banner <Banner
@@ -329,6 +323,15 @@ export function DatasetPage({
/> />
)} )}
<CatalogTableToolbar <CatalogTableToolbar
scope={
<ScopeControls
inline
value={scope}
onChange={setScope}
active={active}
revision={version}
/>
}
label="数据目录" label="数据目录"
active={active && !suspended && !detail} active={active && !suspended && !detail}
filterCount={[q, category, subcategory].filter(Boolean).length} filterCount={[q, category, subcategory].filter(Boolean).length}
+12 -9
View File
@@ -209,16 +209,19 @@ export function FieldBrowser({
setDraft((old) => ({ ...old, [k]: v })); setDraft((old) => ({ ...old, [k]: v }));
return ( return (
<div className="preparation-fields"> <div className="preparation-fields">
{!fixedScope && (
<ScopeControls
value={scope}
onChange={setScope}
local={source === "local"}
active={active}
revision={revision}
/>
)}
<CatalogTableToolbar <CatalogTableToolbar
scope={
!fixedScope ? (
<ScopeControls
inline
value={scope}
onChange={setScope}
local={source === "local"}
active={active}
revision={revision}
/>
) : undefined
}
label="字段目录" label="字段目录"
active={active && !adding} active={active && !adding}
columns={columnSettings} columns={columnSettings}
+6 -1
View File
@@ -18,6 +18,7 @@ export function ScopePicker({
value, value,
onChange, onChange,
local = false, local = false,
inline = false,
active = true, active = true,
revision = "", revision = "",
options, options,
@@ -28,6 +29,7 @@ export function ScopePicker({
value: Scope; value: Scope;
onChange: (scope: Scope) => void; onChange: (scope: Scope) => void;
local?: boolean; local?: boolean;
inline?: boolean;
active?: boolean; active?: boolean;
revision?: string; revision?: string;
options?: ScopeOption[]; options?: ScopeOption[];
@@ -86,7 +88,10 @@ export function ScopePicker({
value.universe && value.universe &&
Number.isFinite(value.delay); Number.isFinite(value.delay);
return ( return (
<section className="scope-picker" aria-label="组合选择"> <section
className={`scope-picker${inline ? " scope-picker-inline" : ""}`}
aria-label="组合选择"
>
<div className="scope-picker-summary"> <div className="scope-picker-summary">
<div> <div>
<span className="settings-label">组合</span> <span className="settings-label">组合</span>
+28 -1
View File
@@ -5,7 +5,7 @@
.scope-picker-summary { .scope-picker-summary {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 16px; gap: 16px;
padding: 12px; padding: 12px;
border: 1px solid var(--semi-color-border); border: 1px solid var(--semi-color-border);
@@ -83,3 +83,30 @@
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
} }
/* The toolbar variant keeps the summary compact; editing does not move the table. */
.scope-picker-inline {
position: relative;
margin: 0;
max-width: 100%;
}
.scope-picker-inline .scope-picker-summary {
padding: 0;
border: 0;
gap: 8px;
flex-wrap: wrap;
}
.scope-picker-inline .scope-picker-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 10;
box-sizing: border-box;
width: min(560px, calc(100vw - 48px));
max-height: min(600px, 65dvh);
overflow-y: auto;
background: var(--semi-color-bg-2);
border: 1px solid var(--semi-color-border);
border-radius: 6px;
box-shadow: var(--semi-shadow-elevated);
}
+101
View File
@@ -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`,
});
}
});
+20
View File
@@ -219,6 +219,26 @@ test("目录表格保持紧凑行高、截断和固定分页", async ({ page })
await expect( await expect(
table.locator(".semi-tag").filter({ hasText: "已同步字段" }), table.locator(".semi-tag").filter({ hasText: "已同步字段" }),
).toBeVisible(); ).toBeVisible();
const summary = page
.getByRole("toolbar", { name: "数据目录表格工具栏" })
.locator(".scope-picker-summary");
const summaryBox = await summary.boundingBox();
const filterBox = await page
.getByRole("button", { name: "筛选数据目录", exact: true })
.boundingBox();
expect(
Math.abs(
summaryBox!.y +
summaryBox!.height / 2 -
filterBox!.y -
filterBox!.height / 2,
),
).toBeLessThan(3);
const textBox = await summary.locator(":scope > div").boundingBox();
const changeBox = await summary
.getByRole("button", { name: "更改组合" })
.boundingBox();
expect(changeBox!.x - textBox!.x - textBox!.width).toBeLessThanOrEqual(16);
await page.screenshot({ path: "test-results/catalog-table-layout.png" }); await page.screenshot({ path: "test-results/catalog-table-layout.png" });
await page await page
.getByRole("navigation", { name: "主导航" }) .getByRole("navigation", { name: "主导航" })