feat: 支持研究素材删除并统一 Semi 表格

This commit is contained in:
yuxuanhui
2026-09-11 23:21:08 +08:00
parent f29063c9a2
commit 8eec12f0e0
26 changed files with 1008 additions and 562 deletions
+27 -20
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Toast } from "@douyinfe/semi-ui-19";
import { Table, Toast } from "@douyinfe/semi-ui-19";
import { api, formatNumber, formatTime, stateLabels } from "../api";
import { PnlChart } from "../components/PnlChart";
import type { Alpha, Pnl, Research } from "../types";
@@ -62,26 +62,33 @@ export function AlphaToolCard({ call, onAction, timezone }: ToolCardProps) {
<small>
{alpha.id} · {alpha.region ?? "地区未提供"}
</small>
<table>
<thead>
<tr>
<th>Sharpe</th>
<th>Fitness</th>
<th>Turnover</th>
</tr>
</thead>
<tbody>
<tr>
<td>{formatNumber(alpha.sharpe)}</td>
<td>{formatNumber(alpha.fitness)}</td>
<td>
{alpha.turnover == null
<Table<Alpha>
dataSource={[alpha]}
rowKey="id"
pagination={false}
size="small"
tableLayout="fixed"
columns={[
{
title: "Sharpe",
key: "sharpe",
render: (_, alpha) => formatNumber(alpha.sharpe),
},
{
title: "Fitness",
key: "fitness",
render: (_, alpha) => formatNumber(alpha.fitness),
},
{
title: "Turnover",
key: "turnover",
render: (_, alpha) =>
alpha.turnover == null
? "未提供"
: `${formatNumber(alpha.turnover * 100)}%`}
</td>
</tr>
</tbody>
</table>
: `${formatNumber(alpha.turnover * 100)}%`,
},
]}
/>
<small>本地快照 · {formatTime(alpha.synced_at, timezone)}</small>
</div>
))}
+1 -17
View File
@@ -369,26 +369,10 @@
text-align: left;
overflow-wrap: anywhere;
}
.ai-alpha-results table {
width: 100%;
text-align: left;
.ai-alpha-results .semi-table {
margin-top: var(--space-2);
border-collapse: collapse;
table-layout: fixed;
font-variant-numeric: tabular-nums;
}
.ai-alpha-results th,
.ai-alpha-results td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 400;
padding: var(--space-1) 0;
}
.ai-alpha-results th {
color: var(--muted);
font-size: 12px;
}
.ai-resize {
position: absolute;
left: -4px;
+44 -33
View File
@@ -5,6 +5,7 @@ import {
Button,
Input,
Spin,
Table,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
@@ -374,46 +375,56 @@ export function AccountPage({
))}
</div>
<div className="usage-table-wrap">
<table className="usage-table">
<thead>
<tr>
<th>用量类型</th>
<th>今日</th>
<th>昨日</th>
<th>每日上限</th>
<th>剩余额度</th>
</tr>
</thead>
<tbody>
{(
[
["submissions", "Alpha 提交"],
["simulations", "回测"],
] as const
).map(([key, label]) => (
<tr key={key}>
<th scope="row">{label}</th>
<td>
{usage?.[key]?.today == null
? "尚无当日记录"
: count(usage[key]?.today)}
</td>
<td title={usage?.[key]?.yesterday_date ?? undefined}>
<Table<{ key: "submissions" | "simulations"; label: string }>
className="usage-table"
dataSource={[
{ key: "submissions", label: "Alpha 提交" },
{ key: "simulations", label: "回测" },
]}
rowKey="key"
pagination={false}
size="small"
style={{ minWidth: 640 }}
columns={[
{ title: "用量类型", dataIndex: "label", width: 120 },
{
title: "今日",
key: "today",
render: (_, { key }) =>
usage?.[key]?.today == null
? "尚无当日记录"
: count(usage[key]?.today),
},
{
title: "昨日",
key: "yesterday",
render: (_, { key }) => (
<span title={usage?.[key]?.yesterday_date ?? undefined}>
{count(usage?.[key]?.yesterday)}
</td>
<td>
</span>
),
},
{
title: "每日上限",
key: "limit",
render: (_, { key }) => (
<>
{usage?.[key]?.limit == null
? "平台未返回"
: count(usage[key]?.limit)}
{key === "simulations" &&
usage?.simulations?.limit != null &&
"(本地设定)"}
</td>
<td>{count(usage?.[key]?.remaining)}</td>
</tr>
))}
</tbody>
</table>
</>
),
},
{
title: "剩余额度",
key: "remaining",
render: (_, { key }) => count(usage?.[key]?.remaining),
},
]}
/>
</div>
<p className="usage-note">
回测按本地每日额度计算剩余次数;活动统计可能延迟,当日次数未知时不估算余额。
+48 -32
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Banner, Button, Input, Spin } from "@douyinfe/semi-ui-19";
import { Banner, Button, Input, Spin, Table } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api";
import type { Account } from "../types";
import "./home.css";
@@ -312,27 +312,50 @@ function PyramidPanel({
</span>
</div>
<div className="home-table-wrap">
<table>
<thead>
<tr>
<th>地区</th>
<th>延迟</th>
<th>数据类别</th>
<th>{metric}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr
key={`${row.region}:${row.delay}:${row.category_id}:${index}`}
>
<td>{row.region}</td>
<td>D{row.delay}</td>
<td>
<Table<PyramidRow>
dataSource={rows}
rowKey={(row) =>
row ? `${row.region}:${row.delay}:${row.category_id}` : ""
}
pagination={false}
size="small"
sticky
style={{ minWidth: 440 }}
empty={
state.data.items.length
? "没有匹配的 Pyramid 类别。"
: "平台暂未返回 Pyramid 数据。"
}
columns={[
{
title: "地区",
key: "region",
width: 64,
render: (_, row) => row.region,
},
{
title: "延迟",
key: "delay",
width: 64,
render: (_, row) => <>D{row.delay}</>,
},
{
title: "数据类别",
key: "category",
render: (_, row) => (
<>
<strong>{row.category_name}</strong>
<small>{row.category_id}</small>
</td>
<td>
</>
),
},
{
title: metric,
key: "value",
width: 110,
align: "right",
render: (_, row) => (
<>
<div className="home-value">
{row.value === null
? "—"
@@ -345,18 +368,11 @@ function PyramidPanel({
}}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
{!rows.length && (
<div className="home-empty">
{state.data.items.length
? "没有匹配的 Pyramid 类别。"
: "平台暂未返回 Pyramid 数据。"}
</div>
)}
</>
),
},
]}
/>
</div>
<footer>
{metric === "Alpha 数量" && (
+75 -58
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Banner, Button, Input, Toast } from "@douyinfe/semi-ui-19";
import { Banner, Button, Input, Table, Toast } from "@douyinfe/semi-ui-19";
import { api, formatTime, post } from "../api";
import "./mcp-keys.css";
@@ -247,71 +247,88 @@ export function MCPKeysPage() {
已创建的 Key <span className="mcp-key-count">{data?.total ?? 0}</span>
</h2>
<div className="mcp-key-table">
<table>
<thead>
<tr>
<th>名称</th>
<th>权限</th>
<th>状态</th>
<th>创建时间</th>
<th>到期时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{data?.items.map((item) => (
<tr key={item.id}>
<td>
<Table<Token>
dataSource={data?.items ?? []}
rowKey="id"
pagination={false}
size="small"
style={{ minWidth: 1040 }}
loading={loading}
empty="暂无 MCP Key。"
columns={[
{
title: "名称",
key: "name",
width: 240,
render: (_, item) => (
<>
<strong>{item.name}</strong>
<small>{item.id}</small>
</td>
<td>
{item.scopes.map((scope) => (
<small key={scope}>
{scopes.find(([value]) => value === scope)?.[1] ??
scope}
</small>
))}
</td>
<td>{labels[item.status]}</td>
<td>{formatTime(item.created_at)}</td>
<td>{formatTime(item.expires_at)}</td>
<td>
{item.revoked_at ? (
"—"
) : revoking === item.id ? (
<div>
<p>撤销后新请求立即失效,已受理回测继续执行。</p>
<Button
type="danger"
disabled={busy}
onClick={() => void revoke(item.id)}
>
确认撤销
</Button>
<Button
disabled={busy}
onClick={() => setRevoking(null)}
>
取消
</Button>
</div>
) : (
</>
),
},
{
title: "权限",
key: "scopes",
width: 200,
render: (_, item) =>
item.scopes.map((scope) => (
<small key={scope}>
{scopes.find(([value]) => value === scope)?.[1] ?? scope}
</small>
)),
},
{
title: "状态",
key: "status",
width: 100,
render: (_, item) => labels[item.status],
},
{
title: "创建时间",
key: "created_at",
width: 180,
render: (_, item) => formatTime(item.created_at),
},
{
title: "到期时间",
key: "expires_at",
width: 180,
render: (_, item) => formatTime(item.expires_at),
},
{
title: "操作",
key: "actions",
render: (_, item) =>
item.revoked_at ? (
"—"
) : revoking === item.id ? (
<div>
<p>撤销后新请求立即失效,已受理回测继续执行。</p>
<Button
type="danger"
disabled={busy}
onClick={() => setRevoking(item.id)}
onClick={() => void revoke(item.id)}
>
撤销
确认撤销
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
<Button disabled={busy} onClick={() => setRevoking(null)}>
取消
</Button>
</div>
) : (
<Button
type="danger"
disabled={busy}
onClick={() => setRevoking(item.id)}
>
撤销
</Button>
),
},
]}
/>
</div>
{!loading && !data?.items.length && <p>暂无 MCP Key。</p>}
<div className="mcp-key-actions">
<Button
disabled={offset === 0 || loading || busy}
-33
View File
@@ -126,31 +126,6 @@ body[theme-mode="dark"] .home-date-fields input {
max-height: 420px;
overflow: auto;
}
.home-panel table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
text-align: left;
font-size: 13px;
}
.home-panel th {
position: sticky;
top: 0;
z-index: 1;
background: var(--canvas);
font-weight: 500;
color: var(--muted);
white-space: nowrap;
}
.home-panel td,
.home-panel th {
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.home-panel td:first-child,
.home-panel td:nth-child(2) {
white-space: nowrap;
}
.home-panel td strong {
font-weight: 400;
overflow-wrap: anywhere;
@@ -161,10 +136,6 @@ body[theme-mode="dark"] .home-date-fields input {
color: var(--muted);
overflow-wrap: anywhere;
}
.home-panel td:last-child {
min-width: 96px;
text-align: right;
}
.home-value {
font-variant-numeric: tabular-nums;
font-weight: 500;
@@ -210,8 +181,4 @@ body[theme-mode="dark"] .home-date-fields input {
.home-heading {
align-items: flex-start;
}
.home-panel td,
.home-panel th {
padding: 10px;
}
}
+2 -14
View File
@@ -84,21 +84,9 @@
.mcp-key-table {
overflow: auto;
}
.mcp-key-table table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.mcp-key-table th,
.mcp-key-table td {
padding: 12px;
border-bottom: 1px solid var(--semi-color-border);
.mcp-key-table .semi-table-row-cell {
vertical-align: top;
}
.mcp-key-table th {
color: var(--semi-color-text-2);
font-weight: 500;
white-space: nowrap;
overflow-wrap: anywhere;
}
.mcp-key-count {
color: var(--semi-color-text-2);
@@ -0,0 +1,30 @@
import { Button, Popconfirm } from "@douyinfe/semi-ui-19";
/** Remove a research item from its list while keeping historical references. */
export function DeleteResearchButton({
name,
label,
disabled,
onConfirm,
}: {
name: string;
label: string;
disabled?: boolean;
onConfirm: () => Promise<void>;
}) {
return (
<Popconfirm
title={`删除${label}“${name}”?`}
okText="删除"
cancelText="取消"
okType="danger"
cancelButtonProps={{ autoFocus: true }}
disabled={disabled}
onConfirm={onConfirm}
>
<Button type="danger" theme="borderless" disabled={disabled}>
删除
</Button>
</Popconfirm>
);
}
+84 -37
View File
@@ -1,5 +1,12 @@
import { useState } from "react";
import { Banner, Button, Checkbox, Tag, Toast } from "@douyinfe/semi-ui-19";
import {
Banner,
Button,
Checkbox,
Table,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { post, formatTime } from "../api";
import type { UIAction } from "../ai/types";
import type { Experiment } from "./workspaceTypes";
@@ -7,13 +14,18 @@ import { EvaluationPanel } from "./EvaluationPanel";
import { LineagePanel } from "./LineagePanel";
import { ResearchSelect } from "./ResearchSelect";
import { validationLabel } from "./workspaceTypes";
import { DeleteResearchButton } from "./DeleteResearchButton";
export function ExperimentView({
experiment,
onAction,
onDelete,
deleting,
}: {
experiment: Experiment;
onAction: (action: UIAction) => void;
onDelete?: () => Promise<void>;
deleting?: boolean;
}) {
const [selected, setSelected] = useState<string[]>([]);
const [assessment, setAssessment] = useState(false);
@@ -61,38 +73,59 @@ export function ExperimentView({
</Button>
</div>
<p>{experiment.hypothesis}</p>
{experiment.archived ? (
<Banner
type="info"
description="此生成记录已删除,当前显示保留的候选与来源。"
/>
) : (
onDelete && (
<DeleteResearchButton
name={experiment.name}
label="生成记录"
disabled={busy || deleting}
onConfirm={onDelete}
/>
)
)}
<Banner
type="info"
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
/>
<div className="research-table-scroll">
<table className="research-table">
<thead>
<tr>
<th>选择</th>
<th>候选表达式</th>
<th>市场与设置</th>
<th>校验</th>
</tr>
</thead>
<tbody>
{experiment.candidates.map((candidate) => (
<tr key={candidate.client_item_id}>
<td>
<Checkbox
aria-label={`选择候选 ${candidate.client_item_id}`}
disabled={candidate.validation.status !== "valid"}
checked={selected.includes(candidate.client_item_id)}
onChange={(event) =>
setSelected((old) =>
event.target.checked
? [...old, candidate.client_item_id]
: old.filter((id) => id !== candidate.client_item_id),
)
}
/>
</td>
<td>
<Table<Experiment["candidates"][number]>
className="research-table"
dataSource={experiment.candidates}
rowKey="client_item_id"
pagination={false}
size="small"
style={{ minWidth: 840 }}
empty="暂无候选表达式。"
columns={[
{
title: "选择",
key: "selection",
width: 64,
render: (_, candidate) => (
<Checkbox
aria-label={`选择候选 ${candidate.client_item_id}`}
disabled={candidate.validation.status !== "valid"}
checked={selected.includes(candidate.client_item_id)}
onChange={(event) =>
setSelected((old) =>
event.target.checked
? [...old, candidate.client_item_id]
: old.filter((id) => id !== candidate.client_item_id),
)
}
/>
),
},
{
title: "候选表达式",
key: "expression",
render: (_, candidate) => (
<>
<code>{candidate.expression}</code>
<details>
<summary>绑定与改动</summary>
@@ -107,15 +140,29 @@ export function ExperimentView({
)}
</pre>
</details>
</td>
<td>
</>
),
},
{
title: "市场与设置",
key: "settings",
width: 240,
render: (_, candidate) => (
<>
{candidate.settings.region} / {candidate.settings.universe} /
D{candidate.settings.delay}
<br />
{candidate.settings.neutralization} · decay{" "}
{candidate.settings.decay}
</td>
<td>
</>
),
},
{
title: "校验",
key: "validation",
width: 180,
render: (_, candidate) => (
<>
<Tag
color={
candidate.validation.status === "valid"
@@ -139,11 +186,11 @@ export function ExperimentView({
</p>
)),
)}
</td>
</tr>
))}
</tbody>
</table>
</>
),
},
]}
/>
</div>
<div className="research-lineage">
<strong>研究来源</strong>
+97 -36
View File
@@ -5,6 +5,7 @@ import {
Button,
Input,
Pagination,
Table,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
@@ -13,6 +14,7 @@ import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
import { api, post } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { TemplateEditor } from "./TemplateEditor";
import { DeleteResearchButton } from "./DeleteResearchButton";
import { ResearchSelect } from "./ResearchSelect";
import { blankTemplate } from "./workspaceTypes";
import type { Asset, InputSnapshot, Template } from "./workspaceTypes";
@@ -71,6 +73,9 @@ export function FeaturesPage({
.then(([list, fixed]) => {
setItems(list.items);
setTotal(list.total);
setPage((current) =>
Math.min(current, Math.max(1, Math.ceil(list.total / 25))),
);
setInputs(fixed.items);
})
.catch((e) => {
@@ -131,6 +136,21 @@ export function FeaturesPage({
setRevision((n) => n + 1);
return saved;
}
async function remove(item: FeatureAsset) {
await api(`/research/assets/${item.id}?version=${item.version}`, {
method: "DELETE",
});
setItems((old) => old.filter((entry) => entry.id !== item.id));
setTotal((old) => Math.max(0, old - 1));
if (asset?.id === item.id) {
setDetailOpen(false);
setAsset(null);
setDraft(blank());
setVersions([]);
}
setRevision((n) => n + 1);
Toast.success("特征方案已删除");
}
return (
<section className="research-workspace research-list-page">
{error && !detailOpen && <Banner type="danger" description={error} />}
@@ -176,40 +196,67 @@ export function FeaturesPage({
</>
}
>
<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>
)}
<Table<FeatureAsset>
className="research-table"
dataSource={items}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 800 }}
empty="暂无特征方案,请新建方案或调整筛选条件。"
columns={[
{
title: "方案名称",
key: "name",
width: 200,
render: (_, item) => (
<Button
theme="borderless"
onClick={() => void run("load", () => load(item))}
>
{item.name}
</Button>
),
},
{
title: "版本",
key: "version",
width: 80,
render: (_, item) => <>v{item.version}</>,
},
{
title: "经济假设",
key: "hypothesis",
render: (_, item) => item.content.hypothesis || "—",
},
{
title: "固定输入",
key: "inputs",
width: 100,
render: (_, item) => item.content.input_ids.length,
},
{
title: "处理步骤",
key: "steps",
width: 100,
render: (_, item) => item.content.steps.length,
},
{
title: "操作",
key: "actions",
width: 80,
render: (_, item) => (
<DeleteResearchButton
name={item.name}
label="特征方案"
disabled={!!busy}
onConfirm={() => run("delete", () => remove(item))}
/>
),
},
]}
/>
</ResearchList>
<ResearchDetail
width="min(100vw, max(640px, 60vw))"
@@ -218,6 +265,12 @@ export function FeaturesPage({
onClose={() => setDetailOpen(false)}
>
{error && <Banner type="danger" description={error} />}
{asset?.archived && (
<Banner
type="info"
description="此特征方案已删除,当前显示保留的历史版本。"
/>
)}
<div className="research-main">
<div className="inline-actions">
{asset && (
@@ -401,7 +454,7 @@ export function FeaturesPage({
<Button
theme="solid"
loading={busy === "save"}
disabled={Boolean(busy)}
disabled={Boolean(busy) || asset?.archived}
onClick={() =>
void run("save", async () => {
await save();
@@ -412,7 +465,7 @@ export function FeaturesPage({
保存特征方案
</Button>
<Button
disabled={Boolean(busy)}
disabled={Boolean(busy) || asset?.archived}
loading={busy === "convert"}
onClick={() =>
void run("convert", async () => {
@@ -432,6 +485,14 @@ export function FeaturesPage({
>
保存并转为模板
</Button>
{asset && !asset.archived && (
<DeleteResearchButton
name={asset.name}
label="特征方案"
disabled={!!busy}
onConfirm={() => run("delete", () => remove(asset))}
/>
)}
</div>
<p className="research-hint">
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
+38 -22
View File
@@ -1,4 +1,4 @@
import { Banner, Button, Tag, Toast } from "@douyinfe/semi-ui-19";
import { Banner, Button, Table, Tag, Toast } from "@douyinfe/semi-ui-19";
import { useState } from "react";
import { post } from "../api";
import type { UIAction } from "../ai/types";
@@ -98,27 +98,43 @@ export function FlowRunView({
</pre>
</details>
<div className="research-table-scroll">
<table className="research-table">
<thead>
<tr>
<th>轮次 / 阶段</th>
<th>状态</th>
<th>产物与候选池</th>
</tr>
</thead>
<tbody>
{run.steps.map((step) => (
<tr key={step.id}>
<td>
<Table<FlowRun["steps"][number]>
className="research-table"
dataSource={run.steps}
rowKey="id"
pagination={false}
size="small"
style={{ minWidth: 680 }}
empty="暂无运行步骤。"
columns={[
{
title: "轮次 / 阶段",
key: "stage",
width: 170,
render: (_, step) => (
<>
{step.round} ·{" "}
{run.definition.nodes.find((n) => n.id === step.node_id)
?.label || step.node_id}
</td>
<td>
</>
),
},
{
title: "状态",
key: "status",
width: 140,
render: (_, step) => (
<>
{flowStatus[step.status] || step.status}
{step.error && <p>{step.error}</p>}
</td>
<td>
</>
),
},
{
title: "产物与候选池",
key: "output",
render: (_, step) => (
<>
<div className="inline-actions">
{step.output.template && (
<Button
@@ -184,11 +200,11 @@ export function FlowRunView({
{JSON.stringify(step.output, null, 2)}
</pre>
</details>
</td>
</tr>
))}
</tbody>
</table>
</>
),
},
]}
/>
</div>
</section>
);
+35 -28
View File
@@ -6,6 +6,7 @@ import {
Button,
Checkbox,
Input,
Table,
TextArea,
} from "@douyinfe/semi-ui-19";
import { api, patch, post, formatTime } from "../api";
@@ -138,34 +139,40 @@ export function OperatorsPage({ active }: { active: boolean }) {
</div>
}
>
<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>
)}
<Table<Operator>
className="research-table"
dataSource={result.items}
rowKey="name"
pagination={false}
size="small"
sticky
style={{ minWidth: 700 }}
empty="暂无算子。连接平台并同步目录,或调整筛选条件。"
columns={[
{
title: "算子",
key: "name",
width: 230,
render: (_, item) => (
<Button theme="borderless" onClick={() => setSelected(item)}>
{item.local.favorite ? "已收藏 · " : ""}
{item.name}
</Button>
),
},
{
title: "分类",
key: "category",
width: 150,
render: (_, item) => item.category,
},
{
title: "定义",
key: "definition",
render: (_, item) => <code>{item.definition}</code>,
},
]}
/>
</ResearchList>
<ResearchDetail
title={selected?.name || "算子详情"}
+40 -28
View File
@@ -1,6 +1,6 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useState } from "react";
import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19";
import { Banner, Button, Pagination, Table } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { FlowLaunchForm } from "./FlowLaunchForm";
@@ -99,33 +99,45 @@ export function PipelinePage({
</>
}
>
<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>
)}
<Table<FlowRun>
className="research-table"
dataSource={items}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 640 }}
empty="暂无研究运行,请新建自动研究。"
columns={[
{
title: "研究名称",
key: "name",
render: (_, item) => (
<Button theme="borderless" onClick={() => select(item.id)}>
{item.name}
</Button>
),
},
{
title: "状态",
key: "status",
width: 140,
render: (_, item) => flowStatus[item.status],
},
{
title: "轮次",
key: "round",
width: 80,
render: (_, item) => item.round,
},
{
title: "创建时间",
key: "created_at",
width: 190,
render: (_, item) => formatTime(item.created_at),
},
]}
/>
</ResearchList>
<ResearchDetail
title={creating ? "新建自动研究" : "研究运行详情"}
+87 -67
View File
@@ -6,6 +6,7 @@ import {
Input,
InputNumber,
Pagination,
Table,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
@@ -422,76 +423,95 @@ export function QuantFlowPage({
>
{mode === "runs" ? (
<>
<table className="research-table">
<thead>
<tr>
<th>运行名称</th>
<th>状态</th>
<th>轮次</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td>
<Button
theme="borderless"
onClick={() => {
setRunId(r.id);
setRun(null);
localStorage.setItem(
"quantflow-selected-run",
r.id,
);
navigate(`/runs/${r.id}`);
}}
>
{r.name}
</Button>
</td>
<td>{flowStatus[r.status]}</td>
<td>{r.round}</td>
<td>{formatTime(r.created_at)}</td>
</tr>
))}
</tbody>
</table>
{!runs.length && (
<p className="research-list-empty">暂无流程运行记录。</p>
)}
<Table<FlowRun>
className="research-table"
dataSource={runs}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 640 }}
empty="暂无流程运行记录。"
columns={[
{
title: "运行名称",
key: "name",
render: (_, r) => (
<Button
theme="borderless"
onClick={() => {
setRunId(r.id);
setRun(null);
localStorage.setItem("quantflow-selected-run", r.id);
navigate(`/runs/${r.id}`);
}}
>
{r.name}
</Button>
),
},
{
title: "状态",
key: "status",
width: 140,
render: (_, r) => flowStatus[r.status],
},
{
title: "轮次",
key: "round",
width: 80,
render: (_, r) => r.round,
},
{
title: "创建时间",
key: "created_at",
width: 190,
render: (_, r) => formatTime(r.created_at),
},
]}
/>
</>
) : (
<>
<table className="research-table">
<thead>
<tr>
<th>流程名称</th>
<th>版本</th>
<th>节点数量</th>
<th>连接数量</th>
</tr>
</thead>
<tbody>
{assets.map((a) => (
<tr key={a.id}>
<td>
<Button theme="borderless" onClick={() => void open(a)}>
{a.name}
</Button>
</td>
<td>v{a.version}</td>
<td>{a.content.nodes.length}</td>
<td>{a.content.edges.length}</td>
</tr>
))}
</tbody>
</table>
{!assets.length && (
<p className="research-list-empty">
暂无流程,请新建流程或复制固定配方。
</p>
)}
<Table<WorkflowAsset>
className="research-table"
dataSource={assets}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 640 }}
empty="暂无流程,请新建流程或复制固定配方。"
columns={[
{
title: "流程名称",
key: "name",
render: (_, a) => (
<Button theme="borderless" onClick={() => void open(a)}>
{a.name}
</Button>
),
},
{
title: "版本",
key: "version",
width: 80,
render: (_, a) => <>v{a.version}</>,
},
{
title: "节点数量",
key: "nodes",
width: 110,
render: (_, a) => a.content.nodes.length,
},
{
title: "连接数量",
key: "edges",
width: 110,
render: (_, a) => a.content.edges.length,
},
]}
/>
</>
)}
</ResearchList>
+186 -84
View File
@@ -6,6 +6,7 @@ import {
Button,
Input,
InputNumber,
Table,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
@@ -23,6 +24,7 @@ import {
} from "./workspaceTypes";
import { TemplateEditor } from "./TemplateEditor";
import { ExperimentView } from "./ExperimentView";
import { DeleteResearchButton } from "./DeleteResearchButton";
import { ComparisonPanel } from "./ComparisonPanel";
import "./workspace.css";
@@ -107,6 +109,12 @@ export function ResearchWorkspace({
if (sequence !== refreshSequence.current) return;
setAssetTotal(nextAssets.total);
setHistoryTotal(nextHistory.total);
setAssetPage((current) =>
Math.min(current, Math.max(0, Math.ceil(nextAssets.total / 25) - 1)),
);
setHistoryPage((current) =>
Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)),
);
setAssets(nextAssets.items);
setInputs(nextInputs.items);
setHistory(nextHistory.items);
@@ -236,6 +244,31 @@ export function ResearchWorkspace({
setTemplate(next.content);
await refresh();
}
async function removeAsset(item: Asset) {
await api(`/research/assets/${item.id}?version=${item.version}`, {
method: "DELETE",
});
setAssets((old) => old.filter((entry) => entry.id !== item.id));
setAssetTotal((old) => Math.max(0, old - 1));
if (asset?.id === item.id) {
if (detailMode === "editor") setDetailOpen(false);
setAsset(null);
setTemplate(blankTemplate());
}
Toast.success("模板已删除");
await refresh();
}
async function removeExperiment(item: { id: string }) {
await api(`/research/experiments/${item.id}`, { method: "DELETE" });
setHistory((old) => old.filter((entry) => entry.id !== item.id));
setHistoryTotal((old) => Math.max(0, old - 1));
if (experiment?.id === item.id) {
if (detailMode === "experiment") setDetailOpen(false);
setExperiment(null);
}
Toast.success("生成记录已删除");
await refresh();
}
async function expand() {
if (dirty) throw new Error("请先保存模板版本,再生成可追溯候选");
const next = await post<Experiment>("/research/experiments", {
@@ -344,92 +377,137 @@ export function ResearchWorkspace({
>
{listMode === "assets" ? (
<>
<table className="research-table">
<thead>
<tr>
<th>模板名称</th>
<th>版本</th>
<th>表达式</th>
<th>分类</th>
</tr>
</thead>
<tbody>
{assets.map((item) => (
<tr key={item.id}>
<td>
<Button
theme="borderless"
onClick={() =>
void task("读取模板", async () => {
const next = await api<Asset>(
`/research/assets/${item.id}`,
);
setAsset(next);
setTemplate(next.content);
setExperiment(null);
setDetailMode("editor");
setDetailOpen(true);
})
}
>
{item.name}
</Button>
</td>
<td>v{item.version}</td>
<td>
<code>{item.content.expression}</code>
</td>
<td>
{item.content.category === "fragment" ? "片段" : "模板"}
</td>
</tr>
))}
</tbody>
</table>
{!assets.length && (
<p className="research-list-empty">
还没有模板。新建或导入一个模板开始研究。
</p>
)}
<Table<Asset>
className="research-table"
dataSource={assets}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 720 }}
empty="还没有模板。新建或导入一个模板开始研究。"
columns={[
{
title: "模板名称",
key: "name",
render: (_, item) => (
<Button
theme="borderless"
onClick={() =>
void task("读取模板", async () => {
const next = await api<Asset>(
`/research/assets/${item.id}`,
);
setAsset(next);
setTemplate(next.content);
setExperiment(null);
setDetailMode("editor");
setDetailOpen(true);
})
}
>
{item.name}
</Button>
),
},
{
title: "版本",
key: "version",
width: 80,
render: (_, item) => <>v{item.version}</>,
},
{
title: "表达式",
key: "expression",
width: 260,
render: (_, item) => <code>{item.content.expression}</code>,
},
{
title: "分类",
key: "category",
width: 90,
render: (_, item) =>
item.content.category === "fragment" ? "片段" : "模板",
},
{
title: "操作",
key: "actions",
width: 80,
render: (_, item) => (
<DeleteResearchButton
name={item.name}
label="模板"
disabled={!!busy}
onConfirm={() =>
task("删除模板", () => removeAsset(item))
}
/>
),
},
]}
/>
</>
) : (
<>
<table className="research-table">
<thead>
<tr>
<th>研究名称</th>
<th>候选数量</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{history.map((item) => (
<tr key={item.id}>
<td>
<Button
theme="borderless"
onClick={() =>
void task("读取实验", async () => {
setExperiment(
await api(`/research/experiments/${item.id}`),
);
setDetailMode("experiment");
setDetailOpen(true);
})
}
>
{item.name}
</Button>
</td>
<td>{item.total}</td>
<td>{formatTime(item.created_at)}</td>
</tr>
))}
</tbody>
</table>
{!history.length && (
<p className="research-list-empty">暂无生成记录。</p>
)}
<Table<(typeof history)[number]>
className="research-table"
dataSource={history}
rowKey="id"
pagination={false}
size="small"
sticky
style={{ minWidth: 640 }}
empty="暂无生成记录。"
columns={[
{
title: "研究名称",
key: "name",
render: (_, item) => (
<Button
theme="borderless"
onClick={() =>
void task("读取实验", async () => {
setExperiment(
await api(`/research/experiments/${item.id}`),
);
setDetailMode("experiment");
setDetailOpen(true);
})
}
>
{item.name}
</Button>
),
},
{
title: "候选数量",
key: "total",
width: 110,
render: (_, item) => item.total,
},
{
title: "创建时间",
key: "created_at",
width: 190,
render: (_, item) => formatTime(item.created_at),
},
{
title: "操作",
key: "actions",
width: 80,
render: (_, item) => (
<DeleteResearchButton
name={item.name}
label="生成记录"
disabled={!!busy}
onConfirm={() =>
task("删除生成记录", () => removeExperiment(item))
}
/>
),
},
]}
/>
</>
)}
</ResearchList>
@@ -452,10 +530,20 @@ export function ResearchWorkspace({
key={experiment.id}
experiment={experiment}
onAction={onAction}
deleting={!!busy}
onDelete={() =>
task("删除生成记录", () => removeExperiment(experiment))
}
/>
)
) : (
<div className="research-main">
{asset?.archived && (
<Banner
type="info"
description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。"
/>
)}
<section className="research-card">
<h3>研究输入与假设</h3>
{page === "variants" && (
@@ -619,11 +707,21 @@ export function ResearchWorkspace({
)}
<div className="research-toolbar">
<Button
disabled={!!busy}
disabled={!!busy || asset?.archived}
onClick={() => void task("保存模板", save)}
>
保存模板版本
</Button>
{asset && !asset.archived && (
<DeleteResearchButton
name={asset.name}
label="模板"
disabled={!!busy}
onConfirm={() =>
task("删除模板", () => removeAsset(asset))
}
/>
)}
<span>组合规模:{combination}</span>
</div>
<div className="research-form-grid">
@@ -776,6 +874,10 @@ export function ResearchWorkspace({
key={experiment.id}
experiment={experiment}
onAction={onAction}
deleting={!!busy}
onDelete={() =>
task("删除生成记录", () => removeExperiment(experiment))
}
/>
)}
{page === "templates" && (
+1 -28
View File
@@ -104,25 +104,8 @@
.research-table-scroll {
overflow-x: auto;
}
.research-table {
border-collapse: collapse;
width: 100%;
font-size: 13px;
}
.research-table th {
color: var(--semi-color-text-2);
font-weight: 500;
background: var(--semi-color-fill-0);
}
.research-table th,
.research-table td {
text-align: left;
.research-table .semi-table-row-cell {
vertical-align: top;
padding: 12px;
border-bottom: 1px solid var(--semi-color-border);
}
.research-table td {
max-width: 350px;
overflow-wrap: anywhere;
}
.research-table details {
@@ -356,16 +339,6 @@
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;
+3
View File
@@ -24,6 +24,8 @@ export type Asset = {
name: string;
kind: string;
version: number;
latest_version?: number;
archived?: boolean;
content: Template;
provenance?: {
feature?: {
@@ -59,6 +61,7 @@ export type ResearchCandidate = Candidate & {
};
export type Experiment = {
id: string;
archived?: boolean;
name: string;
hypothesis: string;
kind: string;
-20
View File
@@ -546,26 +546,6 @@ label:not(.semi-checkbox):not(.semi-radio) {
.usage-table-wrap {
overflow-x: auto;
}
.usage-table {
width: 100%;
border-collapse: collapse;
min-width: 560px;
text-align: left;
font-size: 13px;
}
.usage-table th,
.usage-table td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
font-weight: 400;
}
.usage-table thead {
background: var(--canvas);
color: var(--muted);
}
.usage-table thead th {
font-weight: 500;
}
.usage-note {
margin-top: var(--space-2);
font-size: 12px;