Compare commits

...

2 Commits

Author SHA1 Message Date
yuxuanhui 20f6d0fc51 feat(research): separate template editing and backtest preparation tabs
Deploy production / deploy (push) Successful in 36s
2026-09-13 15:09:56 +08:00
yuxuanhui db449f0915 fix: keep template table cells compact with Semi tooltips 2026-09-13 14:31:03 +08:00
4 changed files with 412 additions and 264 deletions
+132 -13
View File
@@ -13,9 +13,12 @@ import {
Input, Input,
InputNumber, InputNumber,
Table, Table,
Tabs,
TextArea, TextArea,
Toast, Toast,
Typography,
} from "@douyinfe/semi-ui-19"; } from "@douyinfe/semi-ui-19";
import { WorkspaceTable } from "../components/WorkspaceTable";
import { ResearchInput } from "../ai/ResearchInput"; import { ResearchInput } from "../ai/ResearchInput";
import { IconAIEditLevel3 } from "@douyinfe/semi-icons"; import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
import { api, post, formatTime } from "../api"; import { api, post, formatTime } from "../api";
@@ -54,6 +57,7 @@ export function ResearchWorkspace({
onAction: (action: UIAction) => void; onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void; onContext: (context: PageContext) => void;
}) { }) {
const [detailTab, setDetailTab] = useState("editor");
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState<"editor" | "experiment">( const [detailMode, setDetailMode] = useState<"editor" | "experiment">(
"editor", "editor",
@@ -150,6 +154,7 @@ export function ResearchWorkspace({
["open_experiment", "open_template", "open_variant"].includes(action.type) ["open_experiment", "open_template", "open_variant"].includes(action.type)
) { ) {
setDetailOpen(true); setDetailOpen(true);
setDetailTab("editor");
setDetailMode( setDetailMode(
action.type === "open_experiment" ? "experiment" : "editor", action.type === "open_experiment" ? "experiment" : "editor",
); );
@@ -261,6 +266,7 @@ export function ResearchWorkspace({
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [], parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
}); });
setExperiment(next); setExperiment(next);
setDetailTab("prepare");
await refresh(); await refresh();
} }
const combination = Object.values(template.variables) const combination = Object.values(template.variables)
@@ -308,6 +314,7 @@ export function ResearchWorkspace({
setAsset(null); setAsset(null);
setTemplate(blankTemplate()); setTemplate(blankTemplate());
setExperiment(null); setExperiment(null);
setDetailTab("editor");
setDetailMode("editor"); setDetailMode("editor");
setDetailOpen(true); setDetailOpen(true);
}} }}
@@ -354,20 +361,20 @@ export function ResearchWorkspace({
} }
> >
{listMode === "assets" ? ( {listMode === "assets" ? (
<> <div className="research-template-table-container">
<Table<Asset> <WorkspaceTable<Asset>
className="research-table" key={JSON.stringify([search, assetPage])}
fill
dataSource={assets} dataSource={assets}
rowKey="id" rowKey="id"
pagination={false} scroll={{ x: 800, y: "100%" }}
size="small"
sticky
style={{ minWidth: 720 }}
empty="还没有模板。新建或导入一个模板开始研究。" empty="还没有模板。新建或导入一个模板开始研究。"
columns={[ columns={[
{ {
title: "模板名称", title: "模板名称",
key: "name", key: "name",
width: 280,
ellipsis: { showTitle: false },
render: (_, item) => ( render: (_, item) => (
<Button <Button
theme="borderless" theme="borderless"
@@ -379,12 +386,19 @@ export function ResearchWorkspace({
setAsset(next); setAsset(next);
setTemplate(next.content); setTemplate(next.content);
setExperiment(null); setExperiment(null);
setDetailTab("editor");
setDetailMode("editor"); setDetailMode("editor");
setDetailOpen(true); setDetailOpen(true);
}) })
} }
>
<Typography.Text
size="inherit"
style={{ width: "100%", color: "inherit" }}
ellipsis={{ showTooltip: true }}
> >
{item.name} {item.name}
</Typography.Text>
</Button> </Button>
), ),
}, },
@@ -397,8 +411,17 @@ export function ResearchWorkspace({
{ {
title: "表达式", title: "表达式",
key: "expression", key: "expression",
width: 260, width: undefined,
render: (_, item) => <code>{item.content.expression}</code>, ellipsis: { showTitle: false },
render: (_, item) => (
<Typography.Text
className="research-template-expression"
style={{ width: "100%" }}
ellipsis={{ showTooltip: true }}
>
{item.content.expression}
</Typography.Text>
),
}, },
{ {
title: "分类", title: "分类",
@@ -412,6 +435,7 @@ export function ResearchWorkspace({
key: "actions", key: "actions",
width: 80, width: 80,
render: (_, item) => ( render: (_, item) => (
<div className="workspace-cell-actions">
<DeleteResearchButton <DeleteResearchButton
name={item.name} name={item.name}
label="模板" label="模板"
@@ -420,11 +444,12 @@ export function ResearchWorkspace({
task("删除模板", () => removeAsset(item)) task("删除模板", () => removeAsset(item))
} }
/> />
</div>
), ),
}, },
]} ]}
/> />
</> </div>
) : ( ) : (
<> <>
<Table<(typeof history)[number]> <Table<(typeof history)[number]>
@@ -522,6 +547,19 @@ export function ResearchWorkspace({
description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。" description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。"
/> />
)} )}
{page === "templates" && (
<Tabs
type="card"
activeKey={detailTab}
onChange={setDetailTab}
tabPaneMotion={false}
tabList={[
{ itemKey: "editor", tab: "模板信息编辑" },
{ itemKey: "prepare", tab: "回测准备" },
]}
/>
)}
<div hidden={page === "templates" && detailTab !== "editor"}>
<section className="research-card"> <section className="research-card">
<h3>研究输入与假设</h3> <h3>研究输入与假设</h3>
{page === "variants" && ( {page === "variants" && (
@@ -639,11 +677,14 @@ export function ResearchWorkspace({
</div> </div>
)} )}
</section> </section>
</div>
{page !== "variants" || method === "structure" ? ( {page !== "variants" || method === "structure" ? (
<>
<div hidden={page === "templates" && detailTab !== "editor"}>
<section className="research-card"> <section className="research-card">
<div className="research-section-heading"> <div className="research-section-heading">
<h3> <h3>
编辑与展开{asset && ` · v${asset.version}`} 模板信息{asset && ` · v${asset.version}`}
{dirty && " · 未保存"} {dirty && " · 未保存"}
</h3> </h3>
</div> </div>
@@ -708,6 +749,68 @@ export function ResearchWorkspace({
)} )}
<span>组合规模:{combination}</span> <span>组合规模:{combination}</span>
</div> </div>
{page === "templates" && (
<Button
theme="solid"
disabled={!!busy || dirty}
onClick={() => setDetailTab("prepare")}
>
下一步:回测准备
</Button>
)}
</section>
</div>
<div hidden={page === "templates" && detailTab !== "prepare"}>
<section className="research-card">
<h3>回测准备</h3>
<p className="research-hint">
配置展开方式与模拟设置 → 生成候选研究集合 →
生成回测确认预览 → 确认后开始回测。
</p>
{page === "templates" && (
<>
<label>
固定研究输入
<ResearchDataInput
label="选择回测数据准备"
ids={inputIds}
inputs={inputs}
onChange={(ids, rows) => {
setInputs(rows);
setInputIds(ids);
const first = rows.find((r) => r.id === ids[0]);
if (first)
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}}
/>
</label>
<label>
本次研究假设
<TextArea
aria-label="回测研究假设"
value={hypothesis}
onChange={setHypothesis}
autosize={{ minRows: 2, maxRows: 5 }}
/>
</label>
<p>
模板:{template.name || "未命名"} ·{" "}
{asset ? `v${asset.version}` : "未保存"} · 组合规模:
{combination}
</p>
{dirty && (
<Banner
type="warning"
description="请返回模板信息编辑,保存模板版本后再生成候选。"
/>
)}
</>
)}
<div className="research-form-grid"> <div className="research-form-grid">
<label> <label>
展开方式 展开方式
@@ -760,9 +863,23 @@ export function ResearchWorkspace({
} }
onClick={() => void task("生成候选", expand)} onClick={() => void task("生成候选", expand)}
> >
保存候选研究记录 生成候选研究集合
</Button> </Button>
{(!inputIds.length || !hypothesis || !settingsValid) && (
<p className="research-hint">
待完成:
{[
!inputIds.length && "选择固定研究输入",
!hypothesis && "填写研究假设",
!settingsValid && "检查模拟设置",
]
.filter(Boolean)
.join("、")}
</p>
)}
</section> </section>
</div>
</>
) : ( ) : (
<section className="research-card"> <section className="research-card">
<h3>目标范围检查</h3> <h3>目标范围检查</h3>
@@ -789,6 +906,7 @@ export function ResearchWorkspace({
</Button> </Button>
</section> </section>
)} )}
<div hidden={page === "templates" && detailTab !== "prepare"}>
{experiment && ( {experiment && (
<ExperimentView <ExperimentView
key={experiment.id} key={experiment.id}
@@ -800,7 +918,8 @@ export function ResearchWorkspace({
} }
/> />
)} )}
{page === "templates" && ( </div>
{page === "templates" && detailTab === "editor" && (
<details className="research-card"> <details className="research-card">
<summary>导入旧模板</summary> <summary>导入旧模板</summary>
<p> <p>
+12
View File
@@ -104,6 +104,18 @@
.research-table-scroll { .research-table-scroll {
overflow-x: auto; overflow-x: auto;
} }
.research-template-table-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.research-template-expression {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.research-table .semi-table-row-cell { .research-table .semi-table-row-cell {
vertical-align: top; vertical-align: top;
overflow-wrap: anywhere; overflow-wrap: anywhere;
+1
View File
@@ -86,6 +86,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
await expect( await expect(
page.getByRole("textbox", { name: "模板名称", exact: true }), page.getByRole("textbox", { name: "模板名称", exact: true }),
).toHaveValue("特征转换模板"); ).toHaveValue("特征转换模板");
await page.getByRole("tab", { name: "回测准备", exact: true }).click();
const settingsEditor = page.getByRole("region", { const settingsEditor = page.getByRole("region", {
name: "回测参数", name: "回测参数",
exact: true, exact: true,
+17 -1
View File
@@ -79,7 +79,7 @@ for (const mode of ["templates", "variants", "features"] as const) {
const hypothesis = "检验 <收益> 与规模\n第二轮保留原始假设"; const hypothesis = "检验 <收益> 与规模\n第二轮保留原始假设";
await input.fill(hypothesis); await input.fill(hypothesis);
await expect( await expect(
sheet.locator("p").filter({ hasText: /Semi 准备集合 ·/ }), sheet.locator("p:visible").filter({ hasText: /Semi 准备集合 ·/ }),
).toBeVisible(); ).toBeVisible();
let release!: () => void; let release!: () => void;
const pending = new Promise<void>((resolve) => { const pending = new Promise<void>((resolve) => {
@@ -148,6 +148,22 @@ for (const mode of ["templates", "variants", "features"] as const) {
sheet.getByRole("textbox", { name: "模板名称", exact: true }), sheet.getByRole("textbox", { name: "模板名称", exact: true }),
).toHaveValue("AI 生成验收"); ).toHaveValue("AI 生成验收");
await expect(generate).toBeEnabled(); await expect(generate).toBeEnabled();
if (mode === "templates") {
await sheet.getByRole("button", { name: "下一步:回测准备" }).click();
await expect(
sheet.getByRole("textbox", { name: "回测研究假设", exact: true }),
).toHaveValue(hypothesis);
await expect(
sheet.getByRole("button", { name: "生成候选研究集合", exact: true }),
).toBeVisible();
await expect(generate).toBeHidden();
await sheet
.getByRole("tab", { name: "模板信息编辑", exact: true })
.click();
await expect(
sheet.getByRole("textbox", { name: "模板名称", exact: true }),
).toHaveValue("AI 生成验收");
}
await expect(input).toContainText("检验 <收益> 与规模"); await expect(input).toContainText("检验 <收益> 与规模");
await page.screenshot({ path: `../output/playwright/semi-ai-${mode}.png` }); await page.screenshot({ path: `../output/playwright/semi-ai-${mode}.png` });
await page.setViewportSize({ width: 390, height: 900 }); await page.setViewportSize({ width: 390, height: 900 });