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
+80 -40
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useRef, useState } from "react";
import {
Banner,
@@ -41,6 +42,7 @@ export function FeaturesPage({
onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void;
}) {
const [detailOpen, setDetailOpen] = useState(false);
const [draft, setDraft] = useState<Feature>(blank);
const [asset, setAsset] = useState<FeatureAsset | null>(null);
const [items, setItems] = useState<FeatureAsset[]>([]);
@@ -95,6 +97,7 @@ export function FeaturesPage({
);
}, [active, action]);
async function load(item: FeatureAsset) {
setDetailOpen(true);
setAsset(item);
setDraft(item.content);
setVersions(await api(`/research/assets/${item.id}/versions`));
@@ -127,27 +130,12 @@ export function FeaturesPage({
return saved;
}
return (
<section className="research-workspace">
<div className="section-toolbar">
<div>
<h2>特征工程</h2>
<p className="muted">
围绕固定字段记录处理步骤与经济假设,再进入模板、候选和回测闭环。
</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">
<section className="research-workspace research-list-page">
{error && !detailOpen && <Banner type="danger" description={error} />}
<ResearchList
resetKey={JSON.stringify([q, page])}
label="特征方案"
filters={
<Input
aria-label="搜索特征方案"
placeholder="搜索方案"
@@ -157,24 +145,76 @@ export function FeaturesPage({
setPage(1);
}}
/>
{items.map((item) => (
<Button
key={item.id}
block
theme={asset?.id === item.id ? "light" : "borderless"}
onClick={() => void run("load", () => load(item))}
>
{item.name} · v{item.version}
</Button>
))}
<Pagination
size="small"
currentPage={page}
pageSize={25}
total={total}
onPageChange={setPage}
/>
</aside>
}
actions={
<Button
theme="solid"
onClick={() => {
setAsset(null);
setDraft(blank());
setVersions([]);
setDetailOpen(true);
}}
>
新建特征方案
</Button>
}
pagination={
<>
<span>共 {total} 个方案</span>
{total > 0 && (
<Pagination
size="small"
currentPage={page}
pageSize={25}
total={total}
onPageChange={setPage}
/>
)}
</>
}
>
<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="inline-actions">
{asset && (
@@ -380,7 +420,7 @@ export function FeaturesPage({
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
</p>
</div>
</div>
</ResearchDetail>
</section>
);
}
+98 -90
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useState } from "react";
import {
@@ -61,94 +62,65 @@ export function OperatorsPage({ active }: { active: boolean }) {
if (active) void task(load);
}, [active, query, category, favorite, offset]);
return (
<div className="research-workspace">
<header className="research-page-heading">
<div>
<h2>算子库</h2>
<p>
平台定义只读,本地收藏和研究备注独立保存。同步时间:
{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">
<Input
aria-label="搜索算子"
placeholder="名称、参数或描述"
value={query}
onChange={(value) => {
setQuery(value);
setOffset(0);
}}
/>
<ResearchSelect
label="算子分类"
value={category}
optionList={[
{ value: "", label: "全部分类" },
...result.categories.map((value) => ({ value, label: value })),
]}
onChange={(value) => {
setCategory(value as string);
setOffset(0);
}}
/>
<Checkbox
checked={favorite}
onChange={(event) => {
setFavorite(!!event.target.checked);
setOffset(0);
}}
>
只看收藏
</Checkbox>
</div>
<div className="research-operator-layout">
<section className="research-card">
<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>
)}
<div className="research-workspace research-list-page">
{error && !selected && <Banner type="danger" description={error} />}
<ResearchList
resetKey={JSON.stringify([query, category, favorite, offset])}
label="算子库"
filters={
<div className="research-toolbar">
<Input
aria-label="搜索算子"
placeholder="名称、参数或描述"
value={query}
onChange={(value) => {
setQuery(value);
setOffset(0);
}}
/>
<ResearchSelect
label="算子分类"
value={category}
optionList={[
{ value: "", label: "全部分类" },
...result.categories.map((value) => ({ value, label: value })),
]}
onChange={(value) => {
setCategory(value as string);
setOffset(0);
}}
/>
<Checkbox
checked={favorite}
onChange={(event) => {
setFavorite(!!event.target.checked);
setOffset(0);
}}
>
只看收藏
</Checkbox>
</div>
}
actions={
<>
<Button
theme="solid"
loading={busy}
onClick={() =>
void task(async () => {
await post("/catalog/operators/refresh");
await load();
})
}
>
同步算子目录
</Button>
<span className="muted">
同步时间:{formatTime(result.fetched_at)}
</span>
</>
}
pagination={
<div className="research-toolbar">
<span>共 {result.total} 个</span>
<Button
@@ -164,7 +136,43 @@ export function OperatorsPage({ active }: { active: boolean }) {
下一页
</Button>
</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 && (
<section className="research-card">
<h3>{selected.name}</h3>
@@ -225,7 +233,7 @@ export function OperatorsPage({ active }: { active: boolean }) {
</div>
</section>
)}
</div>
</ResearchDetail>
</div>
);
}
+71 -37
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useState } from "react";
import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api";
@@ -23,7 +24,8 @@ export function PipelinePage({
localStorage.getItem("research-selected-flow"),
);
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 [error, setError] = useState("");
useEffect(() => {
@@ -59,46 +61,78 @@ export function PipelinePage({
};
}, [active, page, selected, revision]);
function select(id: string) {
setRun(null);
setDetailOpen(true);
setSelected(id);
localStorage.setItem("research-selected-flow", id);
setCreating(false);
}
return (
<section className="research-workspace">
<div className="section-toolbar">
<div>
<h2>研究流水线</h2>
<p className="muted">
生成 → 校验与设参 → 回测 → 评估决策 → 增强 → 重新展开
</p>
</div>
<Button onClick={() => setCreating(true)}>新建自动研究</Button>
</div>
{error && <Banner type="danger" description={error} />}
<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>
{flowStatus[item.status]} · 第 {item.round} 轮
</span>
<span>{formatTime(item.created_at)}</span>
</button>
))}
<Pagination
size="small"
total={total}
currentPage={page}
pageSize={25}
onPageChange={setPage}
/>
</aside>
<section className="research-workspace research-list-page">
{error && !detailOpen && <Banner type="danger" description={error} />}
<ResearchList
resetKey={JSON.stringify([page])}
label="研究流水线"
actions={
<Button
theme="solid"
onClick={() => {
setCreating(true);
setDetailOpen(true);
}}
>
新建自动研究
</Button>
}
pagination={
<>
<span>共 {total} 次运行</span>
{total > 0 && (
<Pagination
size="small"
total={total}
currentPage={page}
pageSize={25}
onPageChange={setPage}
/>
)}
</>
}
>
<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">
{creating ? (
<FlowLaunchForm
@@ -119,7 +153,7 @@ export function PipelinePage({
)
)}
</div>
</div>
</ResearchDetail>
</section>
);
}
File diff suppressed because it is too large Load Diff
+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>
);
}
File diff suppressed because it is too large Load Diff
+92 -84
View File
@@ -1,73 +1,25 @@
.research-workspace {
height: 100%;
overflow: auto;
padding: 24px 28px;
color: var(--semi-color-text-0);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
color: var(--semi-color-text-0);
}
.research-page-heading,
.research-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.research-page-heading {
margin-bottom: 24px;
}
.research-page-heading h2 {
font-size: 22px;
margin: 0 0 8px;
}
.research-workspace p,
.research-detail-content p,
.research-hint {
font-size: 13px;
color: var(--semi-color-text-2);
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 {
min-width: 0;
display: grid;
@@ -80,6 +32,10 @@
background: var(--semi-color-bg-1);
min-width: 0;
}
.research-card h3 {
font-size: 16px;
margin: 0 0 12px;
}
.research-card label,
.research-editor label {
display: flex;
@@ -127,13 +83,15 @@
justify-self: start;
}
.research-code-editor textarea,
.research-workspace code {
.research-workspace code,
.research-detail-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.research-workspace pre {
.research-workspace pre,
.research-detail-content pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
@@ -170,19 +128,16 @@
.research-table details {
margin-top: 8px;
}
.research-workspace summary {
.research-workspace summary,
.research-detail-content summary {
cursor: pointer;
font-size: 13px;
}
.research-comparison-grid,
.research-operator-layout {
.research-comparison-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.research-operator-layout {
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
}
.research-comparison-grid article {
padding-top: 12px;
}
@@ -192,31 +147,10 @@
padding: 18px 12px 8px;
}
@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 {
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 {
position: absolute;
@@ -381,3 +315,77 @@
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;
}