feat: 统一研究模块表格布局与详情交互
This commit is contained in:
@@ -24,7 +24,8 @@ export function contextLabel(context: PageContext): string {
|
||||
}
|
||||
|
||||
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)
|
||||
? (page as WorkspacePage)
|
||||
: "alphas";
|
||||
|
||||
@@ -318,7 +318,8 @@ export function BacktestPage({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="backtest-spacer" />
|
||||
</div>
|
||||
<div className="backtest-toolbar">
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={() => {
|
||||
@@ -438,7 +439,7 @@ export function BacktestPage({
|
||||
<SideSheet
|
||||
title={preview ? "确认回测输入" : "准备候选"}
|
||||
visible={editor && drawerVisible}
|
||||
width={Math.min(880, window.innerWidth - chatOffset)}
|
||||
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
||||
style={{ right: chatOffset }}
|
||||
maskStyle={{ right: chatOffset }}
|
||||
onCancel={() => setEditor(false)}
|
||||
@@ -730,7 +731,7 @@ export function BacktestPage({
|
||||
<SideSheet
|
||||
title={run?.name || "回测详情"}
|
||||
visible={!!runId && drawerVisible}
|
||||
width={Math.min(1120, window.innerWidth - chatOffset)}
|
||||
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
||||
style={{ right: chatOffset }}
|
||||
maskStyle={{ right: chatOffset }}
|
||||
onCancel={() => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
gap: 12px;
|
||||
}
|
||||
.backtest-toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -65,8 +66,12 @@
|
||||
margin: 0;
|
||||
}
|
||||
.backtest-page-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.backtest-tool-summary {
|
||||
display: flex;
|
||||
|
||||
@@ -162,7 +162,7 @@ export function AlphaDetail({
|
||||
closeOnEsc={!taskPanelOpen}
|
||||
className="alpha-detail"
|
||||
visible={Boolean(id) && !suspended}
|
||||
width={Math.min(800, window.innerWidth - chatOffset)}
|
||||
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
||||
style={{ right: chatOffset }}
|
||||
maskStyle={{ right: chatOffset }}
|
||||
title={
|
||||
|
||||
@@ -514,45 +514,6 @@ export function AlphaPage({
|
||||
</span>
|
||||
</span>
|
||||
</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
|
||||
className="filter-panel"
|
||||
onSubmit={(e) => {
|
||||
@@ -592,12 +553,6 @@ export function AlphaPage({
|
||||
optionList={stateOptions}
|
||||
onChange={(v) => updateDraft("research_state", v)}
|
||||
/>
|
||||
<Button htmlType="submit" theme="solid">
|
||||
查询
|
||||
</Button>
|
||||
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<details className="advanced-filters">
|
||||
<summary>
|
||||
@@ -724,7 +679,55 @@ export function AlphaPage({
|
||||
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
|
||||
</p>
|
||||
</details>
|
||||
<div className="inline-actions filter-actions">
|
||||
<Button htmlType="submit" theme="solid">
|
||||
查询
|
||||
</Button>
|
||||
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<strong>
|
||||
|
||||
@@ -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>
|
||||
{scopeError && (
|
||||
<Banner
|
||||
@@ -1041,9 +1023,27 @@ export function DatasetPage({
|
||||
optionList={options(data.categories[browse.category] ?? [])}
|
||||
onChange={(v) => editBrowse({ subcategory: String(v ?? "") })}
|
||||
/>
|
||||
<Button onClick={() => setBrowse(initialBrowse)}>重置筛选</Button>
|
||||
</div>
|
||||
<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>
|
||||
{restoreAction}
|
||||
{inputAction}
|
||||
@@ -1075,24 +1075,11 @@ export function DatasetPage({
|
||||
{...panelProps}
|
||||
className="catalog-sheet catalog-fields"
|
||||
visible={fieldsOpen && !suspended && active}
|
||||
width={viewport < 900 ? viewport : viewport * 0.75}
|
||||
width="60%"
|
||||
mask={top === "fields"}
|
||||
>
|
||||
<div className="catalog-layer" ref={fieldsRef}>
|
||||
{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">
|
||||
<span>
|
||||
{scope.region} · {scope.universe} · Delay {scope.delay}
|
||||
@@ -1130,9 +1117,22 @@ export function DatasetPage({
|
||||
editBrowse({ coverage_min: String(v ?? "") }, true)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="catalog-tools">
|
||||
<Button onClick={() => setFieldBrowse(initialBrowse)}>
|
||||
重置筛选
|
||||
</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>
|
||||
<Table<Entry>
|
||||
className="alpha-table catalog-table"
|
||||
@@ -1161,11 +1161,7 @@ export function DatasetPage({
|
||||
zIndex={1101}
|
||||
className="catalog-sheet catalog-detail"
|
||||
visible={!!detail && !suspended && active}
|
||||
width={
|
||||
detail?.field && viewport >= 900
|
||||
? viewport * 0.3
|
||||
: Math.min(560, viewport)
|
||||
}
|
||||
width="60%"
|
||||
>
|
||||
<div className="catalog-layer" ref={detailRef}>
|
||||
<div className="catalog-tools">
|
||||
@@ -1288,7 +1284,7 @@ export function DatasetPage({
|
||||
zIndex={1102}
|
||||
className="catalog-sheet"
|
||||
visible={inputOpen && !suspended && active}
|
||||
width={Math.min(560, viewport)}
|
||||
width="60%"
|
||||
>
|
||||
<div className="catalog-layer" ref={inputRef}>
|
||||
<div className="catalog-tools">
|
||||
@@ -1349,7 +1345,7 @@ export function DatasetPage({
|
||||
zIndex={1103}
|
||||
className="catalog-sheet"
|
||||
visible={historyOpen && !suspended && active}
|
||||
width={Math.min(560, viewport)}
|
||||
width="60%"
|
||||
>
|
||||
<div className="catalog-layer" ref={historyRef}>
|
||||
<div className="catalog-tools">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,9 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
||||
.primary-filters > .semi-select {
|
||||
width: 136px;
|
||||
}
|
||||
.filter-actions {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.advanced-filters {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user