diff --git a/.scratch/table-detail-layout/issues/01-layout.md b/.scratch/table-detail-layout/issues/01-layout.md new file mode 100644 index 0000000..28a2e9d --- /dev/null +++ b/.scratch/table-detail-layout/issues/01-layout.md @@ -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 详情路由。保留工作区同时出现的部署相关改动,本次未提交或部署。 diff --git a/frontend/src/ai/workspace.ts b/frontend/src/ai/workspace.ts index b4ec58c..d8c9084 100644 --- a/frontend/src/ai/workspace.ts +++ b/frontend/src/ai/workspace.ts @@ -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"; diff --git a/frontend/src/backtests/BacktestPage.tsx b/frontend/src/backtests/BacktestPage.tsx index 9b5378e..540ad15 100644 --- a/frontend/src/backtests/BacktestPage.tsx +++ b/frontend/src/backtests/BacktestPage.tsx @@ -318,7 +318,8 @@ export function BacktestPage({ }} /> )} -
+
+
- { - 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)); - }} - />
{ @@ -592,12 +553,6 @@ export function AlphaPage({ optionList={stateOptions} onChange={(v) => updateDraft("research_state", v)} /> - -
@@ -724,7 +679,55 @@ export function AlphaPage({ 数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。

+
+ + +
+ { + 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)); + }} + /> +
diff --git a/frontend/src/pages/DatasetPage.tsx b/frontend/src/pages/DatasetPage.tsx index fc11005..9404cf3 100644 --- a/frontend/src/pages/DatasetPage.tsx +++ b/frontend/src/pages/DatasetPage.tsx @@ -981,24 +981,6 @@ export function DatasetPage({ ) } /> - - - - {formatTime(data.synced_at, account?.timezone)} -
{scopeError && ( editBrowse({ subcategory: String(v ?? "") })} /> -
+ + + + {formatTime(data.synced_at, account?.timezone)} + + {summary} {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"} >
{fieldError && } -
- - - {summary} - {restoreAction} - {inputAction} - - {jobStatus} -
{scope.region} · {scope.universe} · Delay {scope.delay} @@ -1130,9 +1117,22 @@ export function DatasetPage({ editBrowse({ coverage_min: String(v ?? "") }, true) } /> +
+
+ + + {summary} + {restoreAction} + {inputAction} + + {jobStatus}
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%" >
@@ -1288,7 +1284,7 @@ export function DatasetPage({ zIndex={1102} className="catalog-sheet" visible={inputOpen && !suspended && active} - width={Math.min(560, viewport)} + width="60%" >
@@ -1349,7 +1345,7 @@ export function DatasetPage({ zIndex={1103} className="catalog-sheet" visible={historyOpen && !suspended && active} - width={Math.min(560, viewport)} + width="60%" >
diff --git a/frontend/src/research/FeaturesPage.tsx b/frontend/src/research/FeaturesPage.tsx index 45be81e..1a13418 100644 --- a/frontend/src/research/FeaturesPage.tsx +++ b/frontend/src/research/FeaturesPage.tsx @@ -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(blank); const [asset, setAsset] = useState(null); const [items, setItems] = useState([]); @@ -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 ( -
-
-
-

特征工程

-

- 围绕固定字段记录处理步骤与经济假设,再进入模板、候选和回测闭环。 -

-
- -
- {error && } -
- + } + actions={ + + } + pagination={ + <> + 共 {total} 个方案 + {total > 0 && ( + + )} + + } + > + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
方案名称版本经济假设固定输入处理步骤
+ + v{item.version}{item.content.hypothesis || "—"}{item.content.input_ids.length}{item.content.steps.length}
+ {!items.length && ( +

+ 暂无特征方案,请新建方案或调整筛选条件。 +

+ )} + + setDetailOpen(false)} + > + {error && }
{asset && ( @@ -380,7 +420,7 @@ export function FeaturesPage({ 保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。

-
+
); } diff --git a/frontend/src/research/OperatorsPage.tsx b/frontend/src/research/OperatorsPage.tsx index e70380f..9f8ea90 100644 --- a/frontend/src/research/OperatorsPage.tsx +++ b/frontend/src/research/OperatorsPage.tsx @@ -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 ( -
-
-
-

算子库

-

- 平台定义只读,本地收藏和研究备注独立保存。同步时间: - {formatTime(result.fetched_at)} -

-
- -
- {error && } -
- { - setQuery(value); - setOffset(0); - }} - /> - ({ value, label: value })), - ]} - onChange={(value) => { - setCategory(value as string); - setOffset(0); - }} - /> - { - setFavorite(!!event.target.checked); - setOffset(0); - }} - > - 只看收藏 - -
-
-
- - - - - - - - - - {result.items.map((item) => ( - - - - - - ))} - -
算子分类定义
- - {item.category} - {item.definition} -
- {!result.items.length && ( -

暂无算子。连接平台并同步目录,或调整筛选条件。

- )} +
+ {error && !selected && } + + { + setQuery(value); + setOffset(0); + }} + /> + ({ value, label: value })), + ]} + onChange={(value) => { + setCategory(value as string); + setOffset(0); + }} + /> + { + setFavorite(!!event.target.checked); + setOffset(0); + }} + > + 只看收藏 + +
+ } + actions={ + <> + + + 同步时间:{formatTime(result.fetched_at)} + + + } + pagination={
共 {result.total} 个
-
+ } + > + + + + + + + + + + {result.items.map((item) => ( + + + + + + ))} + +
算子分类定义
+ + {item.category} + {item.definition} +
+ {!result.items.length && ( +

暂无算子。连接平台并同步目录,或调整筛选条件。

+ )} + + setSelected(null)} + > + {error && } {selected && (

{selected.name}

@@ -225,7 +233,7 @@ export function OperatorsPage({ active }: { active: boolean }) {
)} -
+
); } diff --git a/frontend/src/research/PipelinePage.tsx b/frontend/src/research/PipelinePage.tsx index d9cc5d1..d47004d 100644 --- a/frontend/src/research/PipelinePage.tsx +++ b/frontend/src/research/PipelinePage.tsx @@ -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(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 ( -
-
-
-

研究流水线

-

- 生成 → 校验与设参 → 回测 → 评估决策 → 增强 → 重新展开 -

-
- -
- {error && } -
- +
+ {error && !detailOpen && } + { + setCreating(true); + setDetailOpen(true); + }} + > + 新建自动研究 + + } + pagination={ + <> + 共 {total} 次运行 + {total > 0 && ( + + )} + + } + > + + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
研究名称状态轮次创建时间
+ + {flowStatus[item.status]}{item.round}{formatTime(item.created_at)}
+ {!items.length && ( +

暂无研究运行,请新建自动研究。

+ )} +
+ setDetailOpen(false)} + > + {error && }
{creating ? ( -
+
); } diff --git a/frontend/src/research/QuantFlowPage.tsx b/frontend/src/research/QuantFlowPage.tsx index 4dd72b0..fdf0e4f 100644 --- a/frontend/src/research/QuantFlowPage.tsx +++ b/frontend/src/research/QuantFlowPage.tsx @@ -1,3 +1,4 @@ +import { ResearchList } from "./ResearchList"; import { useEffect, useState } from "react"; import { Banner, @@ -8,7 +9,7 @@ import { TextArea, Toast, } from "@douyinfe/semi-ui-19"; -import { api, post } from "../api"; +import { api, post, formatTime } from "../api"; import type { PageContext, UIAction } from "../ai/types"; import { FlowCanvas, type NodeType } from "./FlowCanvas"; import { FlowLaunchForm } from "./FlowLaunchForm"; @@ -50,6 +51,18 @@ export function QuantFlowPage({ onContext: (c: PageContext) => 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([]), [total, setTotal] = useState(0), [page, setPage] = useState(1); @@ -106,7 +119,7 @@ export function QuantFlowPage({ const c = new AbortController(); Promise.all([ 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 }, ), api<{ items: NodeType[] }>("/research/flows/nodes", { signal: c.signal }), @@ -127,7 +140,7 @@ export function QuantFlowPage({ if (!c.signal.aborted) setError(e.message); }); return () => c.abort(); - }, [active, page, revision]); + }, [active, page, revision, query]); useEffect(() => { if (!active || mode !== "runs") return; const c = new AbortController(); @@ -156,6 +169,41 @@ export function QuantFlowPage({ clearTimeout(timer); }; }, [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( + `/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) { setGraph(value); setValid(false); @@ -179,6 +227,7 @@ export function QuantFlowPage({ /* Keep the server version when the local draft is unavailable. */ } } + navigate(`/workflows/${item.id}`); setAsset(baseline); change(version ? loaded.content : readDraft(loaded.id, loaded.content)); 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); setGraph(result.content); setValid(true); @@ -288,488 +339,592 @@ export function QuantFlowPage({ change({ ...graph, edges: [...graph.edges, edge] }); } return ( -
-
-
-

QuantFlow

-

组合原生研究节点,复用回测、评估与有限预算。

-
-
- - - - -
-
+
{error && } -
- -
- {mode === "runs" ? ( - run ? ( - <> - - {selected && ( -
-

所选节点产物

- {run.steps - .filter((s) => s.node_id === selected) - .map((s) => ( -
- - 第 {s.round} 轮 · {flowStatus[s.status]} - -
-                            {JSON.stringify(s.output, null, 2)}
-                          
-
- ))} -
- )} - setRevision((v) => v + 1)} - onAction={onAction} - /> - - ) : ( -

选择一条运行查看节点状态和产物。

- ) - ) : mode === "launch" && asset ? ( - <> - - { - setRun(r); - setRunId(r.id); - localStorage.setItem("quantflow-selected-run", r.id); - setMode("runs"); - }} - /> - - ) : ( - <> -
-
- change({ ...graph, name })} - /> -
- {asset && ( - +
)} - - - {asset && ( - - )} - -
-
-

- {valid - ? "连接校验通过。" - : "保存时核验节点类型、连接和循环。"}{" "} - {dirty - ? "有未保存草稿,启动前请保存版本。" - : `当前 v${asset?.version},运行固定此版本。`}{" "} - 普通连接无环;终点迭代节点重复整个流程,每次运行都受预算约束。 -

-
-
-
- setRevision((v) => v + 1)} + onAction={onAction} + /> + + ) : ( +

+ 选择一条运行查看节点状态和产物。 +

+ ) + ) : mode === "launch" && asset ? ( + <> + + { + setRun(r); + setRunId(r.id); + localStorage.setItem("quantflow-selected-run", r.id); + setMode("runs"); + navigate(`/runs/${r.id}`); + }} /> + + ) : ( + <>
-

连线

-
- - → - - - -
-
- {graph.edges.map((e, i) => ( -
- - {graph.nodes.find((n) => n.id === e.source)?.label}{" "} - →{" "} - {graph.nodes.find((n) => n.id === e.target)?.label}{" "} - {e.branch && `· ${e.branch}`} - - -
- ))} -
-
-
-