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
@@ -0,0 +1,14 @@
# 研究素材和 Alpha 变体删除
Type: task
Status: resolved
为模板工坊、特征工程、Alpha 变体的列表和详情补齐删除操作。沿用素材软删除,生成记录新增软删除标记;移出列表时保留不可变候选、历史版本、回测和来源引用。删除需要页面确认,素材删除使用当前版本防止覆盖并发修改;删除后刷新列表并修正空页。
验证:删除/取消/冲突、历史引用保留、列表总数与分页、后端测试及静态检查、前端构建和浏览器交互。只使用隔离测试数据,不删除实际研究数据、不部署。
## Answer
已实现素材与生成记录的列表、详情删除和确认,删除后清理当前选择并刷新/修正分页。素材删除与保存均拒绝已删除状态及过期版本;生成记录不可变,重复删除幂等。删除仅移出日常列表,明确引用的历史版本仍可供来源追溯、复用和已授权运行使用,不取消已有回测。
验证:`uv run ruff check app tests` 通过;研究删除、工作空间、结果、流水线、QuantFlow 相关后端测试 82 passed;前端 `pnpm build` 通过(已有 lottie-web eval 和大包提示)。现有 research-outcomes / research-flows / quantflow 浏览器回归 3 passed。另用 Playwright CLI 和隔离合成库实测三处列表/详情删除、取消、26→25→24 总数、最后一页自动回退、模板并发删除 409 与未保存草稿保留;已查看 `output/playwright/research-delete-confirm.png`。
新增迁移 `0014`:隔离 SQLite 上升级、降级、再升级及旧记录保留已验证。Docker 服务未运行,未做 PostgreSQL 实例迁移实测。未部署、未迁移正式数据库、未删除真实数据。
+23
View File
@@ -0,0 +1,23 @@
# 统一业务表格为 Semi Table
Type: task
Status: ready-for-agent
Progress: completed
## Scope
将 11 个文件内的 13 处原生业务表格替换为项目已安装的 Semi 2.103.0 Table。保留筛选、分页、操作、候选选择和详情联动;删除按钮继续仅显示“删除”。AI Markdown 内容的渲染器保持原样。
## Validation
运行前端构建与现有浏览器回归,并补充关键表格、空状态和滚动的浏览器核验。
## Result
13 处业务表格全部改用 Semi Table,移除原生表格的样式覆盖和外置空提示。保留外部分页、稳定行标识和单元格操作;研究列表及首页使用原生 sticky 表头。
验证:`pnpm build` 通过,`pnpm test` 28 项全部通过,`git diff --check` 通过。静态检索 `frontend/src` 未发现手写 table/thead/tbody/tr/td/th。
隔离浏览器补充核验通过:模板表头滚动保持位置;26 条跨页显示,取消删除保留数据,删除第 2 页唯一条目后回到第 1 页显示 25 条;筛选空状态无重复;390px 横向滚动和页面宽度正常。首页 6 条 Pyramid 数据及筛选、算子详情联动、AI Alpha 结果 5 个 Semi Table 和窄屏宽度均通过。
构建保留第三方 lottie-web eval 与大 bundle 提示,未影响构建成功。未部署或提交。截图位于 `output/playwright/semi-*.png`。
+2
View File
@@ -13,6 +13,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
false,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -443,6 +444,7 @@ class ResearchExperiment(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(200))
kind: Mapped[str] = mapped_column(String(30), index=True)
archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
hypothesis: Mapped[str] = mapped_column(Text)
inputs: Mapped[list] = mapped_column(JSON)
parents: Mapped[list] = mapped_column(JSON)
+8 -3
View File
@@ -76,11 +76,12 @@ class Assets:
ResearchAsset.id == asset_id,
ResearchAsset.version == body.version,
ResearchAsset.kind == body.kind,
ResearchAsset.archived.is_(False),
)
.values(version=body.version + 1, name=content["name"], updated_at=now())
)
if changed.rowcount != 1:
raise HTTPException(409, "素材已变化,保留草稿并读取最新版本")
raise HTTPException(409, "素材已变化或已删除,保留草稿并读取最新版本")
version = body.version + 1
else:
asset_id, version = uid(), 1
@@ -100,11 +101,15 @@ class Assets:
async def archive(self, asset_id, version):
result = await self.db.execute(
update(ResearchAsset)
.where(ResearchAsset.id == asset_id, ResearchAsset.version == version)
.where(
ResearchAsset.id == asset_id,
ResearchAsset.version == version,
ResearchAsset.archived.is_(False),
)
.values(archived=True, version=ResearchAsset.version + 1, updated_at=now())
)
if result.rowcount != 1:
raise HTTPException(409, "素材已变化或不存在")
raise HTTPException(409, "素材已变化、已删除或不存在,请刷新后重试")
# Archiving is itself a revision; old references remain resolvable.
previous = await self.db.get(ResearchRevision, (asset_id, version))
self.db.add(
+16 -2
View File
@@ -5,7 +5,7 @@ import json
from collections import defaultdict
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy import func, select, update
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source
from ..backtests.service import Backtests, uid
@@ -243,6 +243,7 @@ class Experiments:
"id",
"name",
"kind",
"archived",
"hypothesis",
"inputs",
"parents",
@@ -256,7 +257,7 @@ class Experiments:
)
async def list(self, kind=None, limit=25, offset=0):
query = select(ResearchExperiment)
query = select(ResearchExperiment).where(ResearchExperiment.archived.is_(False))
if kind:
query = query.where(ResearchExperiment.kind == kind)
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
@@ -281,6 +282,19 @@ class Experiments:
}
)
async def archive(self, experiment_id):
"""Hide an immutable experiment; backtests and lineage must still resolve it.
Returns an acknowledgement, or raises HTTP 404 for an unknown ID. Repeated
deletion is idempotent because candidate contents cannot change.
"""
result = await self.db.execute(
update(ResearchExperiment).where(ResearchExperiment.id == experiment_id).values(archived=True)
)
if result.rowcount != 1:
raise HTTPException(404, "研究实验不存在")
return {"ok": True}
async def preview(self, experiment_id, candidate_ids=None, source_kind=None, reference=None):
experiment = await self.get(experiment_id)
candidates = experiment["candidates"]
+6
View File
@@ -143,6 +143,12 @@ async def experiment(experiment_id: str, request: Request):
return await Experiments(db).get(experiment_id)
@router.delete("/experiments/{experiment_id}")
async def archive_experiment(experiment_id: str, request: Request):
async with request.app.state.sessions.begin() as db:
return await Experiments(db).archive(experiment_id)
@router.post("/experiments/{experiment_id}/preview", status_code=201)
async def preview(experiment_id: str, body: ExperimentPreview, request: Request):
async with request.app.state.sessions.begin() as db:
@@ -0,0 +1,20 @@
"""Hide deleted research experiments while preserving candidate and source history."""
import sqlalchemy as sa
from alembic import op
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"research_experiments",
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade():
op.drop_column("research_experiments", "archived")
+121
View File
@@ -0,0 +1,121 @@
"""Deleting workspace items hides them without severing research provenance."""
import importlib.util
from pathlib import Path
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from tests.test_backtests import execute, setup, start
from tests.test_catalog import catalog as catalog_fixture
from tests.test_research_workspace import expansion, template
from tests.test_research_workspace import research_input as research_input_fixture
catalog = catalog_fixture
research_input = research_input_fixture
@pytest.mark.parametrize("kind", ["template", "feature"])
async def test_asset_deletion_checks_version_and_preserves_revisions(logged_in, research_input, kind):
content = template() if kind == "template" else {
"name": "待删除特征",
"hypothesis": "排名比较",
"input_ids": [research_input["id"]],
"template": template(),
}
response = await logged_in.post("/api/v1/research/assets", json={"kind": kind, "content": content})
assert response.status_code == 201, response.text
original = response.json()
url = f"/api/v1/research/assets/{original['id']}"
updated = await logged_in.put(url, json={"kind": kind, "version": 1, "content": {**content, "name": "新版"}})
assert updated.status_code == 200
assert (await logged_in.delete(f"{url}?version=1")).status_code == 409
assert not (await logged_in.get(url)).json()["archived"]
assert (await logged_in.delete(f"{url}?version=2")).status_code == 200
listing = (await logged_in.get(f"/api/v1/research/assets?kind={kind}")).json()
assert listing["total"] == 0 and listing["items"] == []
historical = (await logged_in.get(f"{url}?version=1")).json()
assert historical["archived"] is True
assert historical["content"] == original["content"]
assert historical["provenance"] == original["provenance"]
latest = (await logged_in.get(url)).json()
assert latest["version"] == 3
assert (await logged_in.delete(f"{url}?version=3")).status_code == 409
assert (await logged_in.put(url, json={"kind": kind, "version": 3, "content": content})).status_code == 409
assert (await logged_in.get(url)).json()["version"] == 3
async def test_experiment_deletion_preserves_backtest_and_child_sources(app, logged_in, research_input):
created = await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))
assert created.status_code == 201, created.text
parent = created.json()
generated = await logged_in.post(
"/api/v1/research/experiments",
json=expansion(research_input["id"], parent_experiment_ids=[parent["id"]]),
)
assert generated.status_code == 201, generated.text
variant = generated.json()
assert variant["kind"] == "variant"
child = await logged_in.post(
"/api/v1/research/experiments",
json=expansion(research_input["id"], parent_experiment_ids=[variant["id"]]),
)
assert child.status_code == 201
_, lane = await setup(app)
url = f"/api/v1/research/experiments/{variant['id']}"
preview = await logged_in.post(f"{url}/preview", json={})
assert preview.status_code == 201, preview.text
run = await start(logged_in, preview.json(), "delete-variant-provenance")
await execute(app, lane, run["backtest_run_id"])
before = (await logged_in.get(url)).json()
assert before["backtest_run_ids"] == [run["backtest_run_id"]]
assert (await logged_in.delete(url)).status_code == 200
assert (await logged_in.delete(url)).status_code == 200
all_items = (await logged_in.get("/api/v1/research/experiments")).json()
assert all_items["total"] == 2
assert variant["id"] not in {item["id"] for item in all_items["items"]}
variants = (await logged_in.get("/api/v1/research/experiments?kind=variant&limit=1&offset=1")).json()
assert variants["total"] == 1 and variants["items"] == []
after = (await logged_in.get(url)).json()
assert after == {**before, "archived": True}
saved_child = (await logged_in.get(f"/api/v1/research/experiments/{child.json()['id']}")).json()
assert saved_child["parents"] == child.json()["parents"]
results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json()
alpha_id = results["items"][0]["alpha_id"]
sources = (await logged_in.get(f"/api/v1/alphas/{alpha_id}/sources")).json()
assert sources["items"][0]["source"]["research_id"] == variant["id"]
assert (await logged_in.delete(f"/api/v1/research/experiments/{parent['id']}")).status_code == 200
assert (await logged_in.get("/api/v1/research/experiments")).json()["total"] == 1
async def test_delete_research_requires_auth_and_known_id(client):
for url in ("assets/missing?version=1", "experiments/missing"):
assert (await client.delete(f"/api/v1/research/{url}")).status_code == 401
async def test_delete_unknown_experiment(logged_in):
assert (await logged_in.delete("/api/v1/research/experiments/missing")).status_code == 404
def test_archive_migration_preserves_existing_rows(tmp_path):
path = Path(__file__).parents[1] / "migrations/versions/0014_research_experiment_archive.py"
spec = importlib.util.spec_from_file_location("research_archive_migration", path)
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
engine = sa.create_engine(f"sqlite:///{tmp_path}/migration.db")
with engine.begin() as connection:
connection.execute(sa.text("CREATE TABLE research_experiments (id VARCHAR(36) PRIMARY KEY, evidence JSON NOT NULL)"))
connection.execute(sa.text("INSERT INTO research_experiments VALUES ('existing', :evidence)"), {"evidence": '{"source":"old"}'})
with Operations.context(MigrationContext.configure(connection)):
migration.upgrade()
assert connection.execute(sa.text("SELECT archived FROM research_experiments")).scalar_one() == 0
connection.execute(sa.text("UPDATE research_experiments SET archived = 1"))
migration.downgrade()
migration.upgrade()
assert connection.execute(sa.text("SELECT evidence FROM research_experiments")).scalar_one() == '{"source":"old"}'
column = next(c for c in sa.inspect(connection).get_columns("research_experiments") if c["name"] == "archived")
assert column["nullable"] is False
engine.dispose()
+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;