feat: 统一研究模块表格布局与详情交互

This commit is contained in:
yuxuanhui
2026-09-09 09:29:30 +08:00
parent f6c2dcead8
commit 20645d6d17
19 changed files with 1693 additions and 1243 deletions
@@ -0,0 +1,19 @@
# 统一列表与详情交互
Type: task
Status: ready-for-agent
## Scope
六个研究模块使用 table + detail,除 QuantFlow 独立详情页外均为 60% 右侧抽屉。所有列表页连续 Flex 高度链,移除页面顶部标题描述,按钮区位于筛选下方。
## Selected learning
采用 20260724-bounded-list-flex-height-chain:当前应用外壳已有有界 Flex,研究页面仍整页滚动。改为固定控制区和分页、表格数据区独立滚动。Semi UI 2.103.0 本机类型与当前官方文档均支持 SideSheet 字符串 width。
## Validation
完成:`pnpm --dir frontend typecheck`、`pnpm --dir frontend build`、`git diff --check` 通过。
相关 Playwright 回归 11/11 通过(Alpha 管理、回测、数据目录、QuantFlow、研究流水线、跨页研究导航、特征转模板)。
隔离测试服务与浏览器数据验证:1440px/390px 视口抽屉宽度分别 864px/234px;长列表只滚动数据区,分页 y=834 不随内容滚动;body 高度不超过视口。QuantFlow 独立详情支持前进、后退、刷新。
独立只读核验 `/root/route_review` complete:未发现路由或草稿恢复的严重阻断。
## Outcome
Completion: complete
应用选定 Flex 高度链经验,未修改经验库。共享 ResearchList 管理固定控制区、分页及数据视口;ResearchDetail 使用 60% 右侧抽屉。QuantFlow 使用独立 hash 详情路由。保留工作区同时出现的部署相关改动,本次未提交或部署。
+2 -1
View File
@@ -24,7 +24,8 @@ export function contextLabel(context: PageContext): string {
} }
export function pageFromHash(hash: string): WorkspacePage { export function pageFromHash(hash: string): WorkspacePage {
const page = hash.replace(/^#/, ""); const value = hash.replace(/^#/, "");
const page = value.startsWith("quantflow/") ? "quantflow" : value;
return Object.hasOwn(contextLabels, page) return Object.hasOwn(contextLabels, page)
? (page as WorkspacePage) ? (page as WorkspacePage)
: "alphas"; : "alphas";
+4 -3
View File
@@ -318,7 +318,8 @@ export function BacktestPage({
}} }}
/> />
)} )}
<div className="backtest-spacer" /> </div>
<div className="backtest-toolbar">
<Button <Button
type="tertiary" type="tertiary"
onClick={() => { onClick={() => {
@@ -438,7 +439,7 @@ export function BacktestPage({
<SideSheet <SideSheet
title={preview ? "确认回测输入" : "准备候选"} title={preview ? "确认回测输入" : "准备候选"}
visible={editor && drawerVisible} visible={editor && drawerVisible}
width={Math.min(880, window.innerWidth - chatOffset)} width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
onCancel={() => setEditor(false)} onCancel={() => setEditor(false)}
@@ -730,7 +731,7 @@ export function BacktestPage({
<SideSheet <SideSheet
title={run?.name || "回测详情"} title={run?.name || "回测详情"}
visible={!!runId && drawerVisible} visible={!!runId && drawerVisible}
width={Math.min(1120, window.innerWidth - chatOffset)} width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
onCancel={() => { onCancel={() => {
+6 -1
View File
@@ -7,6 +7,7 @@
gap: 12px; gap: 12px;
} }
.backtest-toolbar { .backtest-toolbar {
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -65,8 +66,12 @@
margin: 0; margin: 0;
} }
.backtest-page-view { .backtest-page-view {
height: 100%; display: flex;
flex-direction: column;
flex: 1;
min-height: 0; min-height: 0;
min-width: 0;
overflow: hidden;
} }
.backtest-tool-summary { .backtest-tool-summary {
display: flex; display: flex;
+1 -1
View File
@@ -162,7 +162,7 @@ export function AlphaDetail({
closeOnEsc={!taskPanelOpen} closeOnEsc={!taskPanelOpen}
className="alpha-detail" className="alpha-detail"
visible={Boolean(id) && !suspended} visible={Boolean(id) && !suspended}
width={Math.min(800, window.innerWidth - chatOffset)} width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
title={ title={
+48 -45
View File
@@ -514,45 +514,6 @@ export function AlphaPage({
</span> </span>
</span> </span>
</div> </div>
<SavedViews
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={visibleColumns}
onRestore={(view) => {
const {
submission: savedSubmission,
sort: savedSort,
direction: savedDirection,
limit,
offset: _offset,
...rest
} = view.filters;
const values = Object.fromEntries(
Object.entries(rest)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [k, String(v)]),
);
setFilters(values);
setDraft(values);
setPage(1);
setSelected([]);
if (
savedSubmission === "SUBMITTED" ||
savedSubmission === "UNSUBMITTED"
)
setSubmission(savedSubmission);
setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit);
const next = [
...new Set([
"name",
...view.columns.filter((c) => c in columnLabels),
]),
];
setVisibleColumns(next);
localStorage.setItem("alpha-columns", JSON.stringify(next));
}}
/>
<form <form
className="filter-panel" className="filter-panel"
onSubmit={(e) => { onSubmit={(e) => {
@@ -592,12 +553,6 @@ export function AlphaPage({
optionList={stateOptions} optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)} onChange={(v) => updateDraft("research_state", v)}
/> />
<Button htmlType="submit" theme="solid">
查询
</Button>
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
重置
</Button>
</div> </div>
<details className="advanced-filters"> <details className="advanced-filters">
<summary> <summary>
@@ -724,7 +679,55 @@ export function AlphaPage({
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。 数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
</p> </p>
</details> </details>
<div className="inline-actions filter-actions">
<Button htmlType="submit" theme="solid">
查询
</Button>
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
重置
</Button>
</div>
</form> </form>
<SavedViews
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={visibleColumns}
onRestore={(view) => {
const {
submission: savedSubmission,
sort: savedSort,
direction: savedDirection,
limit,
offset: _offset,
...rest
} = view.filters;
const values = Object.fromEntries(
Object.entries(rest)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [k, String(v)]),
);
setFilters(values);
setDraft(values);
setPage(1);
setSelected([]);
if (
savedSubmission === "SUBMITTED" ||
savedSubmission === "UNSUBMITTED"
)
setSubmission(savedSubmission);
setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit);
const next = [
...new Set([
"name",
...view.columns.filter((c) => c in columnLabels),
]),
];
setVisibleColumns(next);
localStorage.setItem("alpha-columns", JSON.stringify(next));
}}
/>
<div className="table-toolbar"> <div className="table-toolbar">
<div> <div>
<strong> <strong>
+36 -40
View File
@@ -981,24 +981,6 @@ export function DatasetPage({
) )
} }
/> />
<Button
loading={busy}
disabled={!scopeValid}
onClick={() => void sync()}
>
同步目录
</Button>
<Button
onClick={() => {
remember();
setHistoryOpen(true);
}}
>
已保存输入 ({drafts.length})
</Button>
<span className="muted">
{formatTime(data.synced_at, account?.timezone)}
</span>
</div> </div>
{scopeError && ( {scopeError && (
<Banner <Banner
@@ -1041,9 +1023,27 @@ export function DatasetPage({
optionList={options(data.categories[browse.category] ?? [])} optionList={options(data.categories[browse.category] ?? [])}
onChange={(v) => editBrowse({ subcategory: String(v ?? "") })} onChange={(v) => editBrowse({ subcategory: String(v ?? "") })}
/> />
<Button onClick={() => setBrowse(initialBrowse)}>重置筛选</Button>
</div> </div>
<div className="catalog-tools"> <div className="catalog-tools">
<Button
loading={busy}
disabled={!scopeValid}
onClick={() => void sync()}
>
同步目录
</Button>
<Button
onClick={() => {
remember();
setHistoryOpen(true);
}}
>
已保存输入 ({drafts.length})
</Button>
<span className="muted">
{formatTime(data.synced_at, account?.timezone)}
</span>
<Button onClick={() => setBrowse(initialBrowse)}>重置筛选</Button>
<span aria-live="polite">{summary}</span> <span aria-live="polite">{summary}</span>
{restoreAction} {restoreAction}
{inputAction} {inputAction}
@@ -1075,24 +1075,11 @@ export function DatasetPage({
{...panelProps} {...panelProps}
className="catalog-sheet catalog-fields" className="catalog-sheet catalog-fields"
visible={fieldsOpen && !suspended && active} visible={fieldsOpen && !suspended && active}
width={viewport < 900 ? viewport : viewport * 0.75} width="60%"
mask={top === "fields"} mask={top === "fields"}
> >
<div className="catalog-layer" ref={fieldsRef}> <div className="catalog-layer" ref={fieldsRef}>
{fieldError && <Banner type="danger" description={fieldError} />} {fieldError && <Banner type="danger" description={fieldError} />}
<div className="catalog-tools">
<Button onClick={onChat}>AI 助手</Button>
<Button aria-label="关闭字段列表" onClick={close}>
关闭
</Button>
<span>{summary}</span>
{restoreAction}
{inputAction}
<Button loading={busy} onClick={() => void sync(selected?.id)}>
刷新字段
</Button>
{jobStatus}
</div>
<div className="catalog-tools"> <div className="catalog-tools">
<span> <span>
{scope.region} · {scope.universe} · Delay {scope.delay} {scope.region} · {scope.universe} · Delay {scope.delay}
@@ -1130,9 +1117,22 @@ export function DatasetPage({
editBrowse({ coverage_min: String(v ?? "") }, true) editBrowse({ coverage_min: String(v ?? "") }, true)
} }
/> />
</div>
<div className="catalog-tools">
<Button onClick={() => setFieldBrowse(initialBrowse)}> <Button onClick={() => setFieldBrowse(initialBrowse)}>
重置筛选 重置筛选
</Button> </Button>
<Button onClick={onChat}>AI 助手</Button>
<Button aria-label="关闭字段列表" onClick={close}>
关闭
</Button>
<span>{summary}</span>
{restoreAction}
{inputAction}
<Button loading={busy} onClick={() => void sync(selected?.id)}>
刷新字段
</Button>
{jobStatus}
</div> </div>
<Table<Entry> <Table<Entry>
className="alpha-table catalog-table" className="alpha-table catalog-table"
@@ -1161,11 +1161,7 @@ export function DatasetPage({
zIndex={1101} zIndex={1101}
className="catalog-sheet catalog-detail" className="catalog-sheet catalog-detail"
visible={!!detail && !suspended && active} visible={!!detail && !suspended && active}
width={ width="60%"
detail?.field && viewport >= 900
? viewport * 0.3
: Math.min(560, viewport)
}
> >
<div className="catalog-layer" ref={detailRef}> <div className="catalog-layer" ref={detailRef}>
<div className="catalog-tools"> <div className="catalog-tools">
@@ -1288,7 +1284,7 @@ export function DatasetPage({
zIndex={1102} zIndex={1102}
className="catalog-sheet" className="catalog-sheet"
visible={inputOpen && !suspended && active} visible={inputOpen && !suspended && active}
width={Math.min(560, viewport)} width="60%"
> >
<div className="catalog-layer" ref={inputRef}> <div className="catalog-layer" ref={inputRef}>
<div className="catalog-tools"> <div className="catalog-tools">
@@ -1349,7 +1345,7 @@ export function DatasetPage({
zIndex={1103} zIndex={1103}
className="catalog-sheet" className="catalog-sheet"
visible={historyOpen && !suspended && active} visible={historyOpen && !suspended && active}
width={Math.min(560, viewport)} width="60%"
> >
<div className="catalog-layer" ref={historyRef}> <div className="catalog-layer" ref={historyRef}>
<div className="catalog-tools"> <div className="catalog-tools">
+70 -30
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Banner, Banner,
@@ -41,6 +42,7 @@ export function FeaturesPage({
onAction: (action: UIAction) => void; onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void; onContext: (context: PageContext) => void;
}) { }) {
const [detailOpen, setDetailOpen] = useState(false);
const [draft, setDraft] = useState<Feature>(blank); const [draft, setDraft] = useState<Feature>(blank);
const [asset, setAsset] = useState<FeatureAsset | null>(null); const [asset, setAsset] = useState<FeatureAsset | null>(null);
const [items, setItems] = useState<FeatureAsset[]>([]); const [items, setItems] = useState<FeatureAsset[]>([]);
@@ -95,6 +97,7 @@ export function FeaturesPage({
); );
}, [active, action]); }, [active, action]);
async function load(item: FeatureAsset) { async function load(item: FeatureAsset) {
setDetailOpen(true);
setAsset(item); setAsset(item);
setDraft(item.content); setDraft(item.content);
setVersions(await api(`/research/assets/${item.id}/versions`)); setVersions(await api(`/research/assets/${item.id}/versions`));
@@ -127,27 +130,12 @@ export function FeaturesPage({
return saved; return saved;
} }
return ( return (
<section className="research-workspace"> <section className="research-workspace research-list-page">
<div className="section-toolbar"> {error && !detailOpen && <Banner type="danger" description={error} />}
<div> <ResearchList
<h2>特征工程</h2> resetKey={JSON.stringify([q, page])}
<p className="muted"> label="特征方案"
围绕固定字段记录处理步骤与经济假设,再进入模板、候选和回测闭环。 filters={
</p>
</div>
<Button
onClick={() => {
setAsset(null);
setDraft(blank());
setVersions([]);
}}
>
新建特征方案
</Button>
</div>
{error && <Banner type="danger" description={error} />}
<div className="research-layout">
<aside className="research-library">
<Input <Input
aria-label="搜索特征方案" aria-label="搜索特征方案"
placeholder="搜索方案" placeholder="搜索方案"
@@ -157,16 +145,24 @@ export function FeaturesPage({
setPage(1); setPage(1);
}} }}
/> />
{items.map((item) => ( }
actions={
<Button <Button
key={item.id} theme="solid"
block onClick={() => {
theme={asset?.id === item.id ? "light" : "borderless"} setAsset(null);
onClick={() => void run("load", () => load(item))} setDraft(blank());
setVersions([]);
setDetailOpen(true);
}}
> >
{item.name} · v{item.version} 新建特征方案
</Button> </Button>
))} }
pagination={
<>
<span>共 {total} 个方案</span>
{total > 0 && (
<Pagination <Pagination
size="small" size="small"
currentPage={page} currentPage={page}
@@ -174,7 +170,51 @@ export function FeaturesPage({
total={total} total={total}
onPageChange={setPage} onPageChange={setPage}
/> />
</aside> )}
</>
}
>
<table className="research-table">
<thead>
<tr>
<th>方案名称</th>
<th>版本</th>
<th>经济假设</th>
<th>固定输入</th>
<th>处理步骤</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<Button
theme="borderless"
onClick={() => void run("load", () => load(item))}
>
{item.name}
</Button>
</td>
<td>v{item.version}</td>
<td>{item.content.hypothesis || "—"}</td>
<td>{item.content.input_ids.length}</td>
<td>{item.content.steps.length}</td>
</tr>
))}
</tbody>
</table>
{!items.length && (
<p className="research-list-empty">
暂无特征方案,请新建方案或调整筛选条件。
</p>
)}
</ResearchList>
<ResearchDetail
title={asset ? asset.name : "新建特征方案"}
visible={active && detailOpen}
onClose={() => setDetailOpen(false)}
>
{error && <Banner type="danger" description={error} />}
<div className="research-main"> <div className="research-main">
<div className="inline-actions"> <div className="inline-actions">
{asset && ( {asset && (
@@ -380,7 +420,7 @@ export function FeaturesPage({
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。 保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
</p> </p>
</div> </div>
</div> </ResearchDetail>
</section> </section>
); );
} }
+63 -55
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { ResearchSelect } from "./ResearchSelect"; import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -61,29 +62,12 @@ export function OperatorsPage({ active }: { active: boolean }) {
if (active) void task(load); if (active) void task(load);
}, [active, query, category, favorite, offset]); }, [active, query, category, favorite, offset]);
return ( return (
<div className="research-workspace"> <div className="research-workspace research-list-page">
<header className="research-page-heading"> {error && !selected && <Banner type="danger" description={error} />}
<div> <ResearchList
<h2>算子库</h2> resetKey={JSON.stringify([query, category, favorite, offset])}
<p> label="算子库"
平台定义只读,本地收藏和研究备注独立保存。同步时间: filters={
{formatTime(result.fetched_at)}
</p>
</div>
<Button
theme="solid"
loading={busy}
onClick={() =>
void task(async () => {
await post("/catalog/operators/refresh");
await load();
})
}
>
同步算子目录
</Button>
</header>
{error && <Banner type="danger" description={error} />}
<div className="research-toolbar"> <div className="research-toolbar">
<Input <Input
aria-label="搜索算子" aria-label="搜索算子"
@@ -116,39 +100,27 @@ export function OperatorsPage({ active }: { active: boolean }) {
只看收藏 只看收藏
</Checkbox> </Checkbox>
</div> </div>
<div className="research-operator-layout"> }
<section className="research-card"> actions={
<table className="research-table"> <>
<thead>
<tr>
<th>算子</th>
<th>分类</th>
<th>定义</th>
</tr>
</thead>
<tbody>
{result.items.map((item) => (
<tr key={item.name}>
<td>
<Button <Button
theme="borderless" theme="solid"
onClick={() => setSelected(item)} loading={busy}
onClick={() =>
void task(async () => {
await post("/catalog/operators/refresh");
await load();
})
}
> >
{item.local.favorite ? "已收藏 · " : ""} 同步算子目录
{item.name}
</Button> </Button>
</td> <span className="muted">
<td>{item.category}</td> 同步时间:{formatTime(result.fetched_at)}
<td> </span>
<code>{item.definition}</code> </>
</td> }
</tr> pagination={
))}
</tbody>
</table>
{!result.items.length && (
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
)}
<div className="research-toolbar"> <div className="research-toolbar">
<span>共 {result.total} 个</span> <span>共 {result.total} 个</span>
<Button <Button
@@ -164,7 +136,43 @@ export function OperatorsPage({ active }: { active: boolean }) {
下一页 下一页
</Button> </Button>
</div> </div>
</section> }
>
<table className="research-table">
<thead>
<tr>
<th>算子</th>
<th>分类</th>
<th>定义</th>
</tr>
</thead>
<tbody>
{result.items.map((item) => (
<tr key={item.name}>
<td>
<Button theme="borderless" onClick={() => setSelected(item)}>
{item.local.favorite ? "已收藏 · " : ""}
{item.name}
</Button>
</td>
<td>{item.category}</td>
<td>
<code>{item.definition}</code>
</td>
</tr>
))}
</tbody>
</table>
{!result.items.length && (
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
)}
</ResearchList>
<ResearchDetail
title={selected?.name || "算子详情"}
visible={active && !!selected}
onClose={() => setSelected(null)}
>
{error && <Banner type="danger" description={error} />}
{selected && ( {selected && (
<section className="research-card"> <section className="research-card">
<h3>{selected.name}</h3> <h3>{selected.name}</h3>
@@ -225,7 +233,7 @@ export function OperatorsPage({ active }: { active: boolean }) {
</div> </div>
</section> </section>
)} )}
</div> </ResearchDetail>
</div> </div>
); );
} }
+63 -29
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19"; import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api"; import { api, formatTime } from "../api";
@@ -23,7 +24,8 @@ export function PipelinePage({
localStorage.getItem("research-selected-flow"), localStorage.getItem("research-selected-flow"),
); );
const [run, setRun] = useState<FlowRun | null>(null); const [run, setRun] = useState<FlowRun | null>(null);
const [creating, setCreating] = useState(!selected); const [creating, setCreating] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [revision, setRevision] = useState(0); const [revision, setRevision] = useState(0);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
@@ -59,38 +61,33 @@ export function PipelinePage({
}; };
}, [active, page, selected, revision]); }, [active, page, selected, revision]);
function select(id: string) { function select(id: string) {
setRun(null);
setDetailOpen(true);
setSelected(id); setSelected(id);
localStorage.setItem("research-selected-flow", id); localStorage.setItem("research-selected-flow", id);
setCreating(false); setCreating(false);
} }
return ( return (
<section className="research-workspace"> <section className="research-workspace research-list-page">
<div className="section-toolbar"> {error && !detailOpen && <Banner type="danger" description={error} />}
<div> <ResearchList
<h2>研究流水线</h2> resetKey={JSON.stringify([page])}
<p className="muted"> label="研究流水线"
生成 → 校验与设参 → 回测 → 评估决策 → 增强 → 重新展开 actions={
</p> <Button
</div> theme="solid"
<Button onClick={() => setCreating(true)}>新建自动研究</Button> onClick={() => {
</div> setCreating(true);
{error && <Banner type="danger" description={error} />} setDetailOpen(true);
<div className="research-layout"> }}
<aside className="research-library">
<h3>研究运行</h3>
{items.map((item) => (
<button
className={`research-library-item ${selected === item.id ? "selected" : ""}`}
key={item.id}
onClick={() => select(item.id)}
> >
<strong>{item.name}</strong> 新建自动研究
<span> </Button>
{flowStatus[item.status]} · 第 {item.round} 轮 }
</span> pagination={
<span>{formatTime(item.created_at)}</span> <>
</button> <span>共 {total} 次运行</span>
))} {total > 0 && (
<Pagination <Pagination
size="small" size="small"
total={total} total={total}
@@ -98,7 +95,44 @@ export function PipelinePage({
pageSize={25} pageSize={25}
onPageChange={setPage} onPageChange={setPage}
/> />
</aside> )}
</>
}
>
<table className="research-table">
<thead>
<tr>
<th>研究名称</th>
<th>状态</th>
<th>轮次</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<Button theme="borderless" onClick={() => select(item.id)}>
{item.name}
</Button>
</td>
<td>{flowStatus[item.status]}</td>
<td>{item.round}</td>
<td>{formatTime(item.created_at)}</td>
</tr>
))}
</tbody>
</table>
{!items.length && (
<p className="research-list-empty">暂无研究运行,请新建自动研究。</p>
)}
</ResearchList>
<ResearchDetail
title={creating ? "新建自动研究" : "研究运行详情"}
visible={active && detailOpen}
onClose={() => setDetailOpen(false)}
>
{error && <Banner type="danger" description={error} />}
<div className="research-main"> <div className="research-main">
{creating ? ( {creating ? (
<FlowLaunchForm <FlowLaunchForm
@@ -119,7 +153,7 @@ export function PipelinePage({
) )
)} )}
</div> </div>
</div> </ResearchDetail>
</section> </section>
); );
} }
+220 -65
View File
@@ -1,3 +1,4 @@
import { ResearchList } from "./ResearchList";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Banner, Banner,
@@ -8,7 +9,7 @@ import {
TextArea, TextArea,
Toast, Toast,
} from "@douyinfe/semi-ui-19"; } from "@douyinfe/semi-ui-19";
import { api, post } from "../api"; import { api, post, formatTime } from "../api";
import type { PageContext, UIAction } from "../ai/types"; import type { PageContext, UIAction } from "../ai/types";
import { FlowCanvas, type NodeType } from "./FlowCanvas"; import { FlowCanvas, type NodeType } from "./FlowCanvas";
import { FlowLaunchForm } from "./FlowLaunchForm"; import { FlowLaunchForm } from "./FlowLaunchForm";
@@ -50,6 +51,18 @@ export function QuantFlowPage({
onContext: (c: PageContext) => void; onContext: (c: PageContext) => void;
onAction: (a: UIAction) => void; onAction: (a: UIAction) => void;
}) { }) {
const [route, setRoute] = useState(() => location.hash);
const [query, setQuery] = useState("");
const detail = /^#quantflow\/(new|workflows\/[^/]+|runs\/[^/]+)$/.test(route);
useEffect(() => {
const changed = () => setRoute(location.hash);
window.addEventListener("hashchange", changed);
return () => window.removeEventListener("hashchange", changed);
}, []);
function navigate(path: string) {
location.hash = `quantflow${path}`;
setRoute(`#quantflow${path}`);
}
const [assets, setAssets] = useState<WorkflowAsset[]>([]), const [assets, setAssets] = useState<WorkflowAsset[]>([]),
[total, setTotal] = useState(0), [total, setTotal] = useState(0),
[page, setPage] = useState(1); [page, setPage] = useState(1);
@@ -106,7 +119,7 @@ export function QuantFlowPage({
const c = new AbortController(); const c = new AbortController();
Promise.all([ Promise.all([
api<{ items: WorkflowAsset[]; total: number }>( api<{ items: WorkflowAsset[]; total: number }>(
`/research/assets?kind=workflow&offset=${(page - 1) * 25}`, `/research/assets?kind=workflow&offset=${(page - 1) * 25}&q=${encodeURIComponent(query)}`,
{ signal: c.signal }, { signal: c.signal },
), ),
api<{ items: NodeType[] }>("/research/flows/nodes", { signal: c.signal }), api<{ items: NodeType[] }>("/research/flows/nodes", { signal: c.signal }),
@@ -127,7 +140,7 @@ export function QuantFlowPage({
if (!c.signal.aborted) setError(e.message); if (!c.signal.aborted) setError(e.message);
}); });
return () => c.abort(); return () => c.abort();
}, [active, page, revision]); }, [active, page, revision, query]);
useEffect(() => { useEffect(() => {
if (!active || mode !== "runs") return; if (!active || mode !== "runs") return;
const c = new AbortController(); const c = new AbortController();
@@ -156,6 +169,41 @@ export function QuantFlowPage({
clearTimeout(timer); clearTimeout(timer);
}; };
}, [active, mode, runId, runPage, revision]); }, [active, mode, runId, runPage, revision]);
useEffect(() => {
if (!active) return;
const runMatch = route.match(/^#quantflow\/runs\/([^/]+)$/);
const workflowMatch = route.match(/^#quantflow\/workflows\/([^/]+)$/);
if (runMatch) {
setMode("runs");
if (runId !== runMatch[1]) {
setRun(null);
setRunId(runMatch[1]);
}
} else if (route === "#quantflow/runs") {
setMode("runs");
} else {
setMode("edit");
if (route === "#quantflow/new" && asset) {
setAsset(null);
setVersions([]);
change(readDraft("new", empty));
}
if (workflowMatch && asset?.id !== workflowMatch[1]) {
const controller = new AbortController();
void api<WorkflowAsset>(
`/research/assets/${encodeURIComponent(workflowMatch[1])}`,
{ signal: controller.signal },
)
.then((item) => {
if (!controller.signal.aborted) void open(item);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
});
return () => controller.abort();
}
}
}, [active, route]);
function change(value: Workflow) { function change(value: Workflow) {
setGraph(value); setGraph(value);
setValid(false); setValid(false);
@@ -179,6 +227,7 @@ export function QuantFlowPage({
/* Keep the server version when the local draft is unavailable. */ /* Keep the server version when the local draft is unavailable. */
} }
} }
navigate(`/workflows/${item.id}`);
setAsset(baseline); setAsset(baseline);
change(version ? loaded.content : readDraft(loaded.id, loaded.content)); change(version ? loaded.content : readDraft(loaded.id, loaded.content));
setSelected(loaded.content.nodes[0]?.id || null); setSelected(loaded.content.nodes[0]?.id || null);
@@ -208,6 +257,8 @@ export function QuantFlowPage({
}), }),
}, },
); );
history.replaceState(null, "", `#quantflow/workflows/${result.id}`);
setRoute(`#quantflow/workflows/${result.id}`);
setAsset(result); setAsset(result);
setGraph(result.content); setGraph(result.content);
setValid(true); setValid(true);
@@ -288,21 +339,48 @@ export function QuantFlowPage({
change({ ...graph, edges: [...graph.edges, edge] }); change({ ...graph, edges: [...graph.edges, edge] });
} }
return ( return (
<section className="research-workspace quantflow-workspace"> <section className="research-workspace research-list-page quantflow-workspace">
<div className="section-toolbar"> {error && <Banner type="danger" description={error} />}
<div> {!detail ? (
<h2>QuantFlow</h2> <ResearchList
<p className="muted">组合原生研究节点,复用回测、评估与有限预算。</p> resetKey={JSON.stringify([mode, query, page, runPage])}
</div> label="QuantFlow"
<div className="inline-actions"> filters={
<Button onClick={() => setMode("edit")}>编辑画布</Button> <>
<Button onClick={() => setMode("runs")}>运行记录</Button> <select
aria-label="QuantFlow 列表"
value={mode === "runs" ? "runs" : "workflows"}
onChange={(e) => {
setMode(e.target.value === "runs" ? "runs" : "edit");
navigate(e.target.value === "runs" ? "/runs" : "");
}}
>
<option value="workflows">流程库</option>
<option value="runs">运行记录</option>
</select>
{mode !== "runs" && (
<Input
aria-label="搜索流程"
placeholder="搜索流程名称"
value={query}
onChange={(value) => {
setQuery(value);
setPage(1);
}}
/>
)}
</>
}
actions={
<>
<Button <Button
theme="solid"
onClick={() => { onClick={() => {
setAsset(null); setAsset(null);
setVersions([]); setVersions([]);
change(readDraft("new", empty)); change(readDraft("new", empty));
setMode("edit"); setMode("edit");
navigate("/new");
}} }}
> >
新建流程 新建流程
@@ -310,11 +388,14 @@ export function QuantFlowPage({
<Button <Button
onClick={async () => { onClick={async () => {
try { try {
const recipe = await api<Workflow>("/research/flows/recipe"); const recipe = await api<Workflow>(
"/research/flows/recipe",
);
setAsset(null); setAsset(null);
setVersions([]); setVersions([]);
change({ ...recipe, name: "固定研究配方副本" }); change({ ...recipe, name: "固定研究配方副本" });
setMode("edit"); setMode("edit");
navigate("/new");
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} }
@@ -322,62 +403,106 @@ export function QuantFlowPage({
> >
复制固定配方 复制固定配方
</Button> </Button>
</div> </>
</div> }
{error && <Banner type="danger" description={error} />} pagination={
<div className="research-layout"> <>
<aside className="research-library"> <span>共 {mode === "runs" ? runTotal : total} 条</span>
<h3>{mode === "runs" ? "画布运行" : "流程库"}</h3> {(mode === "runs" ? runTotal : total) > 0 && (
<Pagination
size="small"
total={mode === "runs" ? runTotal : total}
currentPage={mode === "runs" ? runPage : page}
pageSize={25}
onPageChange={mode === "runs" ? setRunPage : setPage}
/>
)}
</>
}
>
{mode === "runs" ? ( {mode === "runs" ? (
<> <>
<table className="research-table">
<thead>
<tr>
<th>运行名称</th>
<th>状态</th>
<th>轮次</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{runs.map((r) => ( {runs.map((r) => (
<button <tr key={r.id}>
className={`research-library-item ${runId === r.id ? "selected" : ""}`} <td>
key={r.id} <Button
theme="borderless"
onClick={() => { onClick={() => {
setRunId(r.id); setRunId(r.id);
setRun(null); setRun(null);
localStorage.setItem("quantflow-selected-run", r.id); localStorage.setItem(
"quantflow-selected-run",
r.id,
);
navigate(`/runs/${r.id}`);
}} }}
> >
<strong>{r.name}</strong> {r.name}
<span> </Button>
{flowStatus[r.status]} · 第 {r.round} 轮 </td>
</span> <td>{flowStatus[r.status]}</td>
</button> <td>{r.round}</td>
<td>{formatTime(r.created_at)}</td>
</tr>
))} ))}
<Pagination </tbody>
size="small" </table>
total={runTotal} {!runs.length && (
currentPage={runPage} <p className="research-list-empty">暂无流程运行记录。</p>
pageSize={25} )}
onPageChange={setRunPage}
/>
</> </>
) : ( ) : (
<> <>
<table className="research-table">
<thead>
<tr>
<th>流程名称</th>
<th>版本</th>
<th>节点数量</th>
<th>连接数量</th>
</tr>
</thead>
<tbody>
{assets.map((a) => ( {assets.map((a) => (
<button <tr key={a.id}>
className={`research-library-item ${asset?.id === a.id ? "selected" : ""}`} <td>
key={a.id} <Button theme="borderless" onClick={() => void open(a)}>
onClick={() => void open(a)} {a.name}
> </Button>
<strong>{a.name}</strong> </td>
<span> <td>v{a.version}</td>
v{a.version} · {a.content.nodes.length} 个节点 <td>{a.content.nodes.length}</td>
</span> <td>{a.content.edges.length}</td>
</button> </tr>
))} ))}
<Pagination </tbody>
size="small" </table>
total={total} {!assets.length && (
currentPage={page} <p className="research-list-empty">
pageSize={25} 暂无流程,请新建流程或复制固定配方。
onPageChange={setPage} </p>
/> )}
</> </>
)} )}
</aside> </ResearchList>
) : (
<>
<div className="inline-actions quantflow-detail-actions">
<Button onClick={() => navigate(mode === "runs" ? "/runs" : "")}>
返回列表
</Button>
</div>
<div className="quantflow-detail-body">
<div className="research-main"> <div className="research-main">
{mode === "runs" ? ( {mode === "runs" ? (
run ? ( run ? (
@@ -413,7 +538,9 @@ export function QuantFlowPage({
/> />
</> </>
) : ( ) : (
<p className="research-card">选择一条运行查看节点状态和产物。</p> <p className="research-card">
选择一条运行查看节点状态和产物。
</p>
) )
) : mode === "launch" && asset ? ( ) : mode === "launch" && asset ? (
<> <>
@@ -425,6 +552,7 @@ export function QuantFlowPage({
setRunId(r.id); setRunId(r.id);
localStorage.setItem("quantflow-selected-run", r.id); localStorage.setItem("quantflow-selected-run", r.id);
setMode("runs"); setMode("runs");
navigate(`/runs/${r.id}`);
}} }}
/> />
</> </>
@@ -468,7 +596,10 @@ export function QuantFlowPage({
保存流程版本 保存流程版本
</Button> </Button>
{asset && ( {asset && (
<Button disabled={busy} onClick={() => void save(true)}> <Button
disabled={busy}
onClick={() => void save(true)}
>
另存流程 另存流程
</Button> </Button>
)} )}
@@ -548,9 +679,15 @@ export function QuantFlowPage({
{graph.edges.map((e, i) => ( {graph.edges.map((e, i) => (
<div key={i}> <div key={i}>
<span> <span>
{graph.nodes.find((n) => n.id === e.source)?.label}{" "} {
graph.nodes.find((n) => n.id === e.source)
?.label
}{" "}
→{" "} →{" "}
{graph.nodes.find((n) => n.id === e.target)?.label}{" "} {
graph.nodes.find((n) => n.id === e.target)
?.label
}{" "}
{e.branch && `· ${e.branch}`} {e.branch && `· ${e.branch}`}
</span> </span>
<Button <Button
@@ -559,7 +696,9 @@ export function QuantFlowPage({
onClick={() => onClick={() =>
change({ change({
...graph, ...graph,
edges: graph.edges.filter((_, j) => j !== i), edges: graph.edges.filter(
(_, j) => j !== i,
),
}) })
} }
> >
@@ -585,7 +724,10 @@ export function QuantFlowPage({
</option> </option>
))} ))}
</select> </select>
<Button disabled={graph.nodes.length >= 50} onClick={addNode}> <Button
disabled={graph.nodes.length >= 50}
onClick={addNode}
>
添加节点 添加节点
</Button> </Button>
{node && ( {node && (
@@ -607,7 +749,9 @@ export function QuantFlowPage({
aria-label="节点研究说明" aria-label="节点研究说明"
value={String(node.config.prompt || "")} value={String(node.config.prompt || "")}
onChange={(prompt) => onChange={(prompt) =>
updateNode({ config: { ...node.config, prompt } }) updateNode({
config: { ...node.config, prompt },
})
} }
/> />
</label> </label>
@@ -683,12 +827,18 @@ export function QuantFlowPage({
生成方法 生成方法
<select <select
aria-label="变体方法" aria-label="变体方法"
value={String(node.config.method || "structure")} value={String(
node.config.method || "structure",
)}
onChange={(e) => onChange={(e) =>
updateNode({ config: { method: e.target.value } }) updateNode({
config: { method: e.target.value },
})
} }
> >
<option value="structure">结构与字段变体</option> <option value="structure">
结构与字段变体
</option>
<option value="settings">市场与设置变体</option> <option value="settings">市场与设置变体</option>
</select> </select>
</label> </label>
@@ -701,7 +851,9 @@ export function QuantFlowPage({
<input <input
type="checkbox" type="checkbox"
checked={( checked={(
(node.config.verdicts as string[]) || ["pass"] (node.config.verdicts as string[]) || [
"pass",
]
).includes(v)} ).includes(v)}
onChange={(e) => { onChange={(e) => {
const values = (node.config const values = (node.config
@@ -753,7 +905,8 @@ export function QuantFlowPage({
), ),
edges: graph.edges.filter( edges: graph.edges.filter(
(e) => (e) =>
e.source !== node.id && e.target !== node.id, e.source !== node.id &&
e.target !== node.id,
), ),
}); });
setSelected("input"); setSelected("input");
@@ -770,6 +923,8 @@ export function QuantFlowPage({
)} )}
</div> </div>
</div> </div>
</>
)}
</section> </section>
); );
} }
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useRef, type ReactNode } from "react";
import { SideSheet } from "@douyinfe/semi-ui-19";
/** Parent supplies a bounded flex area; only the data viewport owns scrolling. */
export function ResearchList({
label,
resetKey,
filters,
actions,
pagination,
children,
}: {
label: string;
resetKey: string;
filters?: ReactNode;
actions: ReactNode;
pagination: ReactNode;
children: ReactNode;
}) {
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
viewport.current?.scrollTo({ top: 0, left: 0 });
}, [resetKey]);
return (
<section className="research-list" aria-label={label}>
{filters && <div className="research-list-filters">{filters}</div>}
<div className="research-list-actions">{actions}</div>
<div
ref={viewport}
className="research-list-scroll"
tabIndex={0}
aria-label={`${label}数据区`}
>
{children}
</div>
<div className="table-pagination">{pagination}</div>
</section>
);
}
/** Keep drafts mounted while closed; inactive workspace pages must hide portals. */
export function ResearchDetail({
title,
visible,
onClose,
children,
}: {
title: string;
visible: boolean;
onClose: () => void;
children: ReactNode;
}) {
return (
<SideSheet
title={title}
className="research-detail"
placement="right"
width="60%"
visible={visible}
onCancel={onClose}
closeOnEsc
keepDOM
motion={false}
>
<div className="research-detail-content">{children}</div>
</SideSheet>
);
}
+186 -84
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { ResearchSelect } from "./ResearchSelect"; import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
@@ -43,6 +44,11 @@ export function ResearchWorkspace({
onAction: (action: UIAction) => void; onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void; onContext: (context: PageContext) => void;
}) { }) {
const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState<"editor" | "experiment">(
"editor",
);
const [listMode, setListMode] = useState("assets");
const processedAction = useRef<number | null>(null); const processedAction = useRef<number | null>(null);
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [inputs, setInputs] = useState<InputSnapshot[]>([]); const [inputs, setInputs] = useState<InputSnapshot[]>([]);
@@ -119,6 +125,14 @@ export function ResearchWorkspace({
useEffect(() => { useEffect(() => {
if (!active || !action || processedAction.current === action.nonce) return; if (!active || !action || processedAction.current === action.nonce) return;
processedAction.current = action.nonce; processedAction.current = action.nonce;
if (
["open_experiment", "open_template", "open_variant"].includes(action.type)
) {
setDetailOpen(true);
setDetailMode(
action.type === "open_experiment" ? "experiment" : "editor",
);
}
if (action.type === "open_experiment") if (action.type === "open_experiment")
void task("读取实验", async () => void task("读取实验", async () =>
setExperiment( setExperiment(
@@ -244,35 +258,23 @@ export function ResearchWorkspace({
) )
.toString(); .toString();
return ( return (
<div className="research-workspace"> <div className="research-workspace research-list-page">
<header className="research-page-heading"> {error && !detailOpen && <Banner type="danger" description={error} />}
<div> <ResearchList
<h2>{page === "templates" ? "模板工坊" : "Alpha 变体"}</h2> resetKey={JSON.stringify([listMode, search, assetPage, historyPage])}
<p> label={page === "templates" ? "模板工坊" : "Alpha 变体"}
{page === "templates" filters={
? "从固定输入生成候选,保存每一次模板版本与研究依据。" <>
: "围绕已有 Alpha 研究结构、字段与市场设置的变化。"} <ResearchSelect
</p> label="研究列表"
</div> value={listMode}
<Button loading={!!busy} onClick={() => void task("刷新", refresh)}> optionList={[
刷新素材 { value: "assets", label: "模板库" },
</Button> { value: "history", label: "生成记录" },
</header> ]}
{error && <Banner type="danger" description={error} />} onChange={(value) => setListMode(String(value))}
<div className="research-layout"> />
<aside className="research-library"> {listMode === "assets" && (
<div className="research-section-heading">
<h3>模板库</h3>
<Button
size="small"
onClick={() => {
setAsset(null);
setTemplate(blankTemplate());
}}
>
新建
</Button>
</div>
<Input <Input
aria-label="搜索模板" aria-label="搜索模板"
placeholder="搜索名称" placeholder="搜索名称"
@@ -282,75 +284,174 @@ export function ResearchWorkspace({
setAssetPage(0); setAssetPage(0);
}} }}
/> />
)}
</>
}
actions={
<>
<Button
theme="solid"
onClick={() => {
setAsset(null);
setTemplate(blankTemplate());
setExperiment(null);
setDetailMode("editor");
setDetailOpen(true);
}}
>
新建
</Button>
<Button loading={!!busy} onClick={() => void task("刷新", refresh)}>
刷新素材
</Button>
</>
}
pagination={
<>
<span>
共 {listMode === "assets" ? assetTotal : historyTotal} 条
</span>
<div className="inline-actions">
<Button
disabled={!(listMode === "assets" ? assetPage : historyPage)}
onClick={() =>
listMode === "assets"
? setAssetPage(assetPage - 1)
: setHistoryPage(historyPage - 1)
}
>
上一页
</Button>
<Button
disabled={
listMode === "assets"
? (assetPage + 1) * 25 >= assetTotal
: (historyPage + 1) * 25 >= historyTotal
}
onClick={() =>
listMode === "assets"
? setAssetPage(assetPage + 1)
: setHistoryPage(historyPage + 1)
}
>
下一页
</Button>
</div>
</>
}
>
{listMode === "assets" ? (
<>
<table className="research-table">
<thead>
<tr>
<th>模板名称</th>
<th>版本</th>
<th>表达式</th>
<th>分类</th>
</tr>
</thead>
<tbody>
{assets.map((item) => ( {assets.map((item) => (
<button <tr key={item.id}>
className={`research-library-item ${asset?.id === item.id ? "selected" : ""}`} <td>
key={item.id} <Button
theme="borderless"
onClick={() => onClick={() =>
void task("读取模板", async () => { void task("读取模板", async () => {
const next = await api<Asset>(`/research/assets/${item.id}`); const next = await api<Asset>(
`/research/assets/${item.id}`,
);
setAsset(next); setAsset(next);
setTemplate(next.content); setTemplate(next.content);
setExperiment(null);
setDetailMode("editor");
setDetailOpen(true);
}) })
} }
> >
<strong>{item.name}</strong> {item.name}
<span>v{item.version}</span> </Button>
</button> </td>
<td>v{item.version}</td>
<td>
<code>{item.content.expression}</code>
</td>
<td>
{item.content.category === "fragment" ? "片段" : "模板"}
</td>
</tr>
))} ))}
{!assets.length && <p>还没有模板。新建或导入一个模板开始研究。</p>} </tbody>
<div className="research-toolbar"> </table>
<small>共 {assetTotal} 个模板</small> {!assets.length && (
<Button <p className="research-list-empty">
size="small" 还没有模板。新建或导入一个模板开始研究。
disabled={!assetPage} </p>
onClick={() => setAssetPage(assetPage - 1)} )}
> </>
上一页模板 ) : (
</Button> <>
<Button <table className="research-table">
size="small" <thead>
disabled={(assetPage + 1) * 25 >= assetTotal} <tr>
onClick={() => setAssetPage(assetPage + 1)} <th>研究名称</th>
> <th>候选数量</th>
下一页模板 <th>创建时间</th>
</Button> </tr>
</div> </thead>
<h3>生成记录</h3> <tbody>
{history.map((item) => ( {history.map((item) => (
<button <tr key={item.id}>
className="research-library-item" <td>
key={item.id} <Button
theme="borderless"
onClick={() => onClick={() =>
void task("读取实验", async () => void task("读取实验", async () => {
setExperiment(await api(`/research/experiments/${item.id}`)), setExperiment(
) await api(`/research/experiments/${item.id}`),
);
setDetailMode("experiment");
setDetailOpen(true);
})
} }
> >
<strong>{item.name}</strong> {item.name}
<span> </Button>
{item.total} 个候选 · {formatTime(item.created_at)} </td>
</span> <td>{item.total}</td>
</button> <td>{formatTime(item.created_at)}</td>
</tr>
))} ))}
<div className="research-toolbar"> </tbody>
<small>共 {historyTotal} 次生成</small> </table>
<Button {!history.length && (
size="small" <p className="research-list-empty">暂无生成记录。</p>
disabled={!historyPage} )}
onClick={() => setHistoryPage(historyPage - 1)} </>
)}
</ResearchList>
<ResearchDetail
title={
detailMode === "experiment"
? experiment?.name || "生成记录"
: page === "variants"
? "Alpha 变体详情"
: asset?.name || "新建模板"
}
visible={active && detailOpen}
onClose={() => setDetailOpen(false)}
> >
上一页记录 {error && <Banner type="danger" description={error} />}
</Button> {detailMode === "experiment" ? (
<Button experiment && (
size="small" <ExperimentView
disabled={(historyPage + 1) * 25 >= historyTotal} key={experiment.id}
onClick={() => setHistoryPage(historyPage + 1)} experiment={experiment}
> onAction={onAction}
下一页记录 />
</Button> )
</div> ) : (
</aside>
<div className="research-main"> <div className="research-main">
<section className="research-card"> <section className="research-card">
<h3>研究输入与假设</h3> <h3>研究输入与假设</h3>
@@ -694,7 +795,8 @@ export function ResearchWorkspace({
<ComparisonPanel key={parent} baseline={parent} /> <ComparisonPanel key={parent} baseline={parent} />
)} )}
</div> </div>
</div> )}
</ResearchDetail>
</div> </div>
); );
} }
+92 -84
View File
@@ -1,73 +1,25 @@
.research-workspace { .research-workspace {
height: 100%; display: flex;
overflow: auto; flex-direction: column;
padding: 24px 28px; flex: 1;
color: var(--semi-color-text-0); min-height: 0;
min-width: 0; min-width: 0;
overflow: hidden;
color: var(--semi-color-text-0);
} }
.research-page-heading,
.research-section-heading { .research-section-heading {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
} }
.research-page-heading {
margin-bottom: 24px;
}
.research-page-heading h2 {
font-size: 22px;
margin: 0 0 8px;
}
.research-workspace p, .research-workspace p,
.research-detail-content p,
.research-hint { .research-hint {
font-size: 13px; font-size: 13px;
color: var(--semi-color-text-2); color: var(--semi-color-text-2);
line-height: 1.6; line-height: 1.6;
} }
.research-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.research-library {
position: sticky;
top: 0;
max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 12px;
}
.research-library h3,
.research-card h3 {
font-size: 16px;
margin: 0 0 12px;
}
.research-library > h3 {
margin-top: 28px;
}
.research-library-item {
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
border: 0;
border-radius: 6px;
padding: 12px;
text-align: left;
width: 100%;
color: inherit;
cursor: pointer;
margin: 4px 0;
}
.research-library-item.selected,
.research-library-item:hover {
background: var(--semi-color-fill-0);
}
.research-library-item span {
font-size: 12px;
color: var(--semi-color-text-2);
}
.research-main { .research-main {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -80,6 +32,10 @@
background: var(--semi-color-bg-1); background: var(--semi-color-bg-1);
min-width: 0; min-width: 0;
} }
.research-card h3 {
font-size: 16px;
margin: 0 0 12px;
}
.research-card label, .research-card label,
.research-editor label { .research-editor label {
display: flex; display: flex;
@@ -127,13 +83,15 @@
justify-self: start; justify-self: start;
} }
.research-code-editor textarea, .research-code-editor textarea,
.research-workspace code { .research-workspace code,
.research-detail-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 12px;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.research-workspace pre { .research-workspace pre,
.research-detail-content pre {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-size: 12px; font-size: 12px;
@@ -170,19 +128,16 @@
.research-table details { .research-table details {
margin-top: 8px; margin-top: 8px;
} }
.research-workspace summary { .research-workspace summary,
.research-detail-content summary {
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.research-comparison-grid, .research-comparison-grid {
.research-operator-layout {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px; gap: 20px;
} }
.research-operator-layout {
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
}
.research-comparison-grid article { .research-comparison-grid article {
padding-top: 12px; padding-top: 12px;
} }
@@ -192,31 +147,10 @@
padding: 18px 12px 8px; padding: 18px 12px 8px;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.research-layout {
grid-template-columns: 170px minmax(0, 1fr);
gap: 16px;
}
.research-workspace {
padding: 20px;
}
.research-operator-layout,
.research-comparison-grid { .research-comparison-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 760px) {
.research-layout {
display: block;
}
.research-library {
position: static;
max-height: 200px;
margin-bottom: 16px;
}
.research-page-heading {
align-items: start;
}
}
.research-sr-only { .research-sr-only {
position: absolute; position: absolute;
@@ -381,3 +315,77 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
/* Lists inherit the viewport boundary; controls and pagination never join data scrolling. */
.research-list {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.research-list-filters,
.research-list-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 0 0 auto;
gap: 12px;
padding: 12px;
}
.research-list-filters {
border-bottom: 1px solid var(--line);
}
.research-list-filters > .semi-input-wrapper {
flex: 1;
min-width: 160px;
}
.research-list-filters > .research-toolbar {
width: 100%;
}
.research-list .research-toolbar {
margin: 0;
}
.research-list-scroll {
flex: 1;
min-height: 0;
min-width: 0;
overflow: auto;
overscroll-behavior: contain;
}
.research-list-scroll .research-table th {
position: sticky;
top: 0;
z-index: 1;
background: var(--canvas);
}
.research-list-empty {
padding: 40px 16px;
text-align: center;
}
.research-detail .semi-sidesheet-body {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.research-detail-content {
min-width: 0;
color: var(--ink);
}
.research-detail-content .research-main {
display: flex;
flex-direction: column;
gap: 16px;
}
.quantflow-detail-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.quantflow-detail-actions {
flex-shrink: 0;
padding: 8px 0 12px;
}
+3
View File
@@ -240,6 +240,9 @@ label:not(.semi-checkbox):not(.semi-radio) {
.primary-filters > .semi-select { .primary-filters > .semi-select {
width: 136px; width: 136px;
} }
.filter-actions {
margin-top: var(--space-2);
}
.advanced-filters { .advanced-filters {
margin-top: var(--space-2); margin-top: var(--space-2);
} }
+3 -3
View File
@@ -89,7 +89,7 @@ test("目录筛选、双层详情、完整输入、排除、备注与刷新", as
const fields = page.getByRole("dialog", { name: "数据字段", exact: true }); const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
await expect(fields).toBeVisible(); await expect(fields).toBeVisible();
await expect(fields).toContainText("全部 123 个字段"); await expect(fields).toContainText("全部 123 个字段");
expect((await fields.boundingBox())!.width).toBeCloseTo(1080, 0); expect((await fields.boundingBox())!.width).toBeCloseTo(864, 0);
await page.getByLabel("搜索字段").fill("字段 12"); await page.getByLabel("搜索字段").fill("字段 12");
await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount( await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount(
3, 3,
@@ -99,7 +99,7 @@ test("目录筛选、双层详情、完整输入、排除、备注与刷新", as
.click(); .click();
const detail = page.getByRole("dialog", { name: "字段详情", exact: true }); const detail = page.getByRole("dialog", { name: "字段详情", exact: true });
await expect(detail).toContainText("FUTURE_TYPE"); await expect(detail).toContainText("FUTURE_TYPE");
expect((await detail.boundingBox())!.width).toBeCloseTo(432, 0); expect((await detail.boundingBox())!.width).toBeCloseTo(864, 0);
await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设"); await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设");
await page.getByRole("button", { name: "保存备注", exact: true }).click(); await page.getByRole("button", { name: "保存备注", exact: true }).click();
await expect(page.getByText("研究备注已保存")).toBeVisible(); await expect(page.getByText("研究备注已保存")).toBeVisible();
@@ -173,7 +173,7 @@ test("窄屏抽屉、键盘隔离和研究范围切换", async ({ page }) => {
await openFields(page); await openFields(page);
const fields = page.getByRole("dialog", { name: "数据字段", exact: true }); const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
await expect(fields).toBeVisible(); await expect(fields).toBeVisible();
expect((await fields.boundingBox())!.width).toBeCloseTo(390, 0); expect((await fields.boundingBox())!.width).toBeCloseTo(234, 0);
await page await page
.getByRole("button", { name: "TEST 字段 000", exact: true }) .getByRole("button", { name: "TEST 字段 000", exact: true })
.click(); .click();
+3 -2
View File
@@ -19,7 +19,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
.getByRole("button", { name: "QuantFlow", exact: true }) .getByRole("button", { name: "QuantFlow", exact: true })
.click(); .click();
await expect( await expect(
page.getByRole("heading", { name: "QuantFlow", exact: true }), page.getByRole("region", { name: "QuantFlow", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.request.put("/api/v1/account/credentials", { await page.request.put("/api/v1/account/credentials", {
headers, headers,
@@ -114,6 +114,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
}) })
).json(); ).json();
await page.reload(); await page.reload();
await page.getByRole("button", { name: "新建流程", exact: true }).click();
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究"); await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究");
async function add(kind: string, label: string) { async function add(kind: string, label: string) {
await page.getByLabel("新增节点类型").selectOption(kind); await page.getByLabel("新增节点类型").selectOption(kind);
@@ -179,7 +180,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
.getByRole("option") .getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) }) .filter({ hasText: fixed.id.slice(0, 8) })
.click(); .click();
await page.getByRole("heading", { name: "QuantFlow", exact: true }).click(); await page.getByLabel("自动研究假设").click();
for (const [label, value] of [ for (const [label, value] of [
["最大轮数", "1"], ["最大轮数", "1"],
["最大模拟条目数", "2"], ["最大模拟条目数", "2"],
+9 -5
View File
@@ -19,7 +19,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
.getByRole("button", { name: "研究流水线", exact: true }) .getByRole("button", { name: "研究流水线", exact: true })
.click(); .click();
await expect( await expect(
page.getByRole("heading", { name: "研究流水线", exact: true }), page.getByRole("region", { name: "研究流水线", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.request.put("/api/v1/account/credentials", { await page.request.put("/api/v1/account/credentials", {
headers, headers,
@@ -94,6 +94,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
).ok(), ).ok(),
).toBe(true); ).toBe(true);
await page.reload(); await page.reload();
await page.getByRole("button", { name: "新建自动研究", exact: true }).click();
await page.getByLabel("自动研究名称").fill("浏览器有限研究"); await page.getByLabel("自动研究名称").fill("浏览器有限研究");
await page.getByLabel("自动研究假设").fill("研究排名稳定性"); await page.getByLabel("自动研究假设").fill("研究排名稳定性");
await page.getByRole("combobox", { name: "自动研究固定输入" }).click(); await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
@@ -101,7 +102,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
.getByRole("option") .getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) }) .filter({ hasText: fixed.id.slice(0, 8) })
.click(); .click();
await page.getByRole("heading", { name: "研究流水线", exact: true }).click(); await page.getByLabel("自动研究名称").click();
for (const [label, value] of [ for (const [label, value] of [
["最大轮数", "1"], ["最大轮数", "1"],
["最大模拟条目数", "2"], ["最大模拟条目数", "2"],
@@ -110,9 +111,9 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
]) ])
await page.getByLabel(label, { exact: true }).fill(value); await page.getByLabel(label, { exact: true }).fill(value);
await page.getByRole("button", { name: "核对并启动研究" }).click(); await page.getByRole("button", { name: "核对并启动研究" }).click();
await expect(page.getByRole("dialog", {name: "确认自动研究授权"})).toContainText( await expect(
"最多 1 轮、2 条模拟、2 次模型调用", page.getByRole("dialog", { name: "确认自动研究授权" }),
); ).toContainText("最多 1 轮、2 条模拟、2 次模型调用");
const started = page.waitForResponse( const started = page.waitForResponse(
(r) => (r) =>
r.url().endsWith("/research/flows/runs") && r.url().endsWith("/research/flows/runs") &&
@@ -143,6 +144,9 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
expect(completed.model_calls_used).toBe(2); expect(completed.model_calls_used).toBe(2);
expect(completed.steps).toHaveLength(8); expect(completed.steps).toHaveLength(8);
await page.reload(); await page.reload();
await page
.getByRole("button", { name: "浏览器有限研究", exact: true })
.click();
await expect( await expect(
page.getByRole("region", { name: "研究运行详情" }), page.getByRole("region", { name: "研究运行详情" }),
).toContainText("浏览器有限研究"); ).toContainText("浏览器有限研究");
+4 -2
View File
@@ -15,7 +15,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
await page.getByLabel("密码", { exact: true }).fill("browser-test-password"); await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click(); await page.getByRole("button", { name: "进入工作空间" }).click();
await expect( await expect(
page.getByRole("heading", { name: "特征工程", exact: true }), page.getByRole("button", { name: "新建特征方案", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.request.put("/api/v1/account/credentials", { await page.request.put("/api/v1/account/credentials", {
headers, headers,
@@ -62,6 +62,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
).json(); ).json();
expect(fixed.id).toBeTruthy(); expect(fixed.id).toBeTruthy();
await page.reload(); await page.reload();
await page.getByRole("button", { name: "新建特征方案", exact: true }).click();
await page.getByLabel("特征方案名称", { exact: true }).fill("浏览器特征验收"); await page.getByLabel("特征方案名称", { exact: true }).fill("浏览器特征验收");
await page await page
.getByLabel("特征经济假设", { exact: true }) .getByLabel("特征经济假设", { exact: true })
@@ -71,7 +72,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
.getByRole("option") .getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) }) .filter({ hasText: fixed.id.slice(0, 8) })
.click(); .click();
await page.getByRole("heading", { name: "特征工程", exact: true }).click(); await page.getByLabel("特征方案名称", { exact: true }).click();
await page.getByRole("button", { name: "添加处理步骤" }).click(); await page.getByRole("button", { name: "添加处理步骤" }).click();
await page.getByLabel("步骤 1 名称").fill("横截面排名"); await page.getByLabel("步骤 1 名称").fill("横截面排名");
await page.getByLabel("步骤 1 理由").fill("减少尺度影响"); await page.getByLabel("步骤 1 理由").fill("减少尺度影响");
@@ -97,6 +98,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
expect(templates.items[0].provenance.feature.content.input_ids).toEqual([ expect(templates.items[0].provenance.feature.content.input_ids).toEqual([
fixed.id, fixed.id,
]); ]);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click(); await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列"); await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列");
await page.getByRole("button", { name: "保存为新视图" }).click(); await page.getByRole("button", { name: "保存为新视图" }).click();