feat: 支持研究素材删除并统一 Semi 表格
This commit is contained in:
@@ -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 实例迁移实测。未部署、未迁移正式数据库、未删除真实数据。
|
||||||
@@ -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`。
|
||||||
@@ -13,6 +13,7 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
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)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(200))
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
kind: Mapped[str] = mapped_column(String(30), index=True)
|
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)
|
hypothesis: Mapped[str] = mapped_column(Text)
|
||||||
inputs: Mapped[list] = mapped_column(JSON)
|
inputs: Mapped[list] = mapped_column(JSON)
|
||||||
parents: Mapped[list] = mapped_column(JSON)
|
parents: Mapped[list] = mapped_column(JSON)
|
||||||
|
|||||||
@@ -76,11 +76,12 @@ class Assets:
|
|||||||
ResearchAsset.id == asset_id,
|
ResearchAsset.id == asset_id,
|
||||||
ResearchAsset.version == body.version,
|
ResearchAsset.version == body.version,
|
||||||
ResearchAsset.kind == body.kind,
|
ResearchAsset.kind == body.kind,
|
||||||
|
ResearchAsset.archived.is_(False),
|
||||||
)
|
)
|
||||||
.values(version=body.version + 1, name=content["name"], updated_at=now())
|
.values(version=body.version + 1, name=content["name"], updated_at=now())
|
||||||
)
|
)
|
||||||
if changed.rowcount != 1:
|
if changed.rowcount != 1:
|
||||||
raise HTTPException(409, "素材已变化,保留草稿并读取最新版本")
|
raise HTTPException(409, "素材已变化或已删除,保留草稿并读取最新版本")
|
||||||
version = body.version + 1
|
version = body.version + 1
|
||||||
else:
|
else:
|
||||||
asset_id, version = uid(), 1
|
asset_id, version = uid(), 1
|
||||||
@@ -100,11 +101,15 @@ class Assets:
|
|||||||
async def archive(self, asset_id, version):
|
async def archive(self, asset_id, version):
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
update(ResearchAsset)
|
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())
|
.values(archived=True, version=ResearchAsset.version + 1, updated_at=now())
|
||||||
)
|
)
|
||||||
if result.rowcount != 1:
|
if result.rowcount != 1:
|
||||||
raise HTTPException(409, "素材已变化或不存在")
|
raise HTTPException(409, "素材已变化、已删除或不存在,请刷新后重试")
|
||||||
# Archiving is itself a revision; old references remain resolvable.
|
# Archiving is itself a revision; old references remain resolvable.
|
||||||
previous = await self.db.get(ResearchRevision, (asset_id, version))
|
previous = await self.db.get(ResearchRevision, (asset_id, version))
|
||||||
self.db.add(
|
self.db.add(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import HTTPException
|
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.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source
|
||||||
from ..backtests.service import Backtests, uid
|
from ..backtests.service import Backtests, uid
|
||||||
@@ -243,6 +243,7 @@ class Experiments:
|
|||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"kind",
|
"kind",
|
||||||
|
"archived",
|
||||||
"hypothesis",
|
"hypothesis",
|
||||||
"inputs",
|
"inputs",
|
||||||
"parents",
|
"parents",
|
||||||
@@ -256,7 +257,7 @@ class Experiments:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def list(self, kind=None, limit=25, offset=0):
|
async def list(self, kind=None, limit=25, offset=0):
|
||||||
query = select(ResearchExperiment)
|
query = select(ResearchExperiment).where(ResearchExperiment.archived.is_(False))
|
||||||
if kind:
|
if kind:
|
||||||
query = query.where(ResearchExperiment.kind == kind)
|
query = query.where(ResearchExperiment.kind == kind)
|
||||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
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):
|
async def preview(self, experiment_id, candidate_ids=None, source_kind=None, reference=None):
|
||||||
experiment = await self.get(experiment_id)
|
experiment = await self.get(experiment_id)
|
||||||
candidates = experiment["candidates"]
|
candidates = experiment["candidates"]
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ async def experiment(experiment_id: str, request: Request):
|
|||||||
return await Experiments(db).get(experiment_id)
|
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)
|
@router.post("/experiments/{experiment_id}/preview", status_code=201)
|
||||||
async def preview(experiment_id: str, body: ExperimentPreview, request: Request):
|
async def preview(experiment_id: str, body: ExperimentPreview, request: Request):
|
||||||
async with request.app.state.sessions.begin() as db:
|
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")
|
||||||
@@ -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()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api, formatNumber, formatTime, stateLabels } from "../api";
|
||||||
import { PnlChart } from "../components/PnlChart";
|
import { PnlChart } from "../components/PnlChart";
|
||||||
import type { Alpha, Pnl, Research } from "../types";
|
import type { Alpha, Pnl, Research } from "../types";
|
||||||
@@ -62,26 +62,33 @@ export function AlphaToolCard({ call, onAction, timezone }: ToolCardProps) {
|
|||||||
<small>
|
<small>
|
||||||
{alpha.id} · {alpha.region ?? "地区未提供"}
|
{alpha.id} · {alpha.region ?? "地区未提供"}
|
||||||
</small>
|
</small>
|
||||||
<table>
|
<Table<Alpha>
|
||||||
<thead>
|
dataSource={[alpha]}
|
||||||
<tr>
|
rowKey="id"
|
||||||
<th>Sharpe</th>
|
pagination={false}
|
||||||
<th>Fitness</th>
|
size="small"
|
||||||
<th>Turnover</th>
|
tableLayout="fixed"
|
||||||
</tr>
|
columns={[
|
||||||
</thead>
|
{
|
||||||
<tbody>
|
title: "Sharpe",
|
||||||
<tr>
|
key: "sharpe",
|
||||||
<td>{formatNumber(alpha.sharpe)}</td>
|
render: (_, alpha) => formatNumber(alpha.sharpe),
|
||||||
<td>{formatNumber(alpha.fitness)}</td>
|
},
|
||||||
<td>
|
{
|
||||||
{alpha.turnover == null
|
title: "Fitness",
|
||||||
|
key: "fitness",
|
||||||
|
render: (_, alpha) => formatNumber(alpha.fitness),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Turnover",
|
||||||
|
key: "turnover",
|
||||||
|
render: (_, alpha) =>
|
||||||
|
alpha.turnover == null
|
||||||
? "未提供"
|
? "未提供"
|
||||||
: `${formatNumber(alpha.turnover * 100)}%`}
|
: `${formatNumber(alpha.turnover * 100)}%`,
|
||||||
</td>
|
},
|
||||||
</tr>
|
]}
|
||||||
</tbody>
|
/>
|
||||||
</table>
|
|
||||||
<small>本地快照 · {formatTime(alpha.synced_at, timezone)}</small>
|
<small>本地快照 · {formatTime(alpha.synced_at, timezone)}</small>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -369,26 +369,10 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.ai-alpha-results table {
|
.ai-alpha-results .semi-table {
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.ai-resize {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -4px;
|
left: -4px;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Spin,
|
Spin,
|
||||||
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
@@ -374,46 +375,56 @@ export function AccountPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="usage-table-wrap">
|
<div className="usage-table-wrap">
|
||||||
<table className="usage-table">
|
<Table<{ key: "submissions" | "simulations"; label: string }>
|
||||||
<thead>
|
className="usage-table"
|
||||||
<tr>
|
dataSource={[
|
||||||
<th>用量类型</th>
|
{ key: "submissions", label: "Alpha 提交" },
|
||||||
<th>今日</th>
|
{ key: "simulations", label: "回测" },
|
||||||
<th>昨日</th>
|
]}
|
||||||
<th>每日上限</th>
|
rowKey="key"
|
||||||
<th>剩余额度</th>
|
pagination={false}
|
||||||
</tr>
|
size="small"
|
||||||
</thead>
|
style={{ minWidth: 640 }}
|
||||||
<tbody>
|
columns={[
|
||||||
{(
|
{ title: "用量类型", dataIndex: "label", width: 120 },
|
||||||
[
|
{
|
||||||
["submissions", "Alpha 提交"],
|
title: "今日",
|
||||||
["simulations", "回测"],
|
key: "today",
|
||||||
] as const
|
render: (_, { key }) =>
|
||||||
).map(([key, label]) => (
|
usage?.[key]?.today == null
|
||||||
<tr key={key}>
|
|
||||||
<th scope="row">{label}</th>
|
|
||||||
<td>
|
|
||||||
{usage?.[key]?.today == null
|
|
||||||
? "尚无当日记录"
|
? "尚无当日记录"
|
||||||
: count(usage[key]?.today)}
|
: count(usage[key]?.today),
|
||||||
</td>
|
},
|
||||||
<td title={usage?.[key]?.yesterday_date ?? undefined}>
|
{
|
||||||
|
title: "昨日",
|
||||||
|
key: "yesterday",
|
||||||
|
render: (_, { key }) => (
|
||||||
|
<span title={usage?.[key]?.yesterday_date ?? undefined}>
|
||||||
{count(usage?.[key]?.yesterday)}
|
{count(usage?.[key]?.yesterday)}
|
||||||
</td>
|
</span>
|
||||||
<td>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "每日上限",
|
||||||
|
key: "limit",
|
||||||
|
render: (_, { key }) => (
|
||||||
|
<>
|
||||||
{usage?.[key]?.limit == null
|
{usage?.[key]?.limit == null
|
||||||
? "平台未返回"
|
? "平台未返回"
|
||||||
: count(usage[key]?.limit)}
|
: count(usage[key]?.limit)}
|
||||||
{key === "simulations" &&
|
{key === "simulations" &&
|
||||||
usage?.simulations?.limit != null &&
|
usage?.simulations?.limit != null &&
|
||||||
"(本地设定)"}
|
"(本地设定)"}
|
||||||
</td>
|
</>
|
||||||
<td>{count(usage?.[key]?.remaining)}</td>
|
),
|
||||||
</tr>
|
},
|
||||||
))}
|
{
|
||||||
</tbody>
|
title: "剩余额度",
|
||||||
</table>
|
key: "remaining",
|
||||||
|
render: (_, { key }) => count(usage?.[key]?.remaining),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="usage-note">
|
<p className="usage-note">
|
||||||
回测按本地每日额度计算剩余次数;活动统计可能延迟,当日次数未知时不估算余额。
|
回测按本地每日额度计算剩余次数;活动统计可能延迟,当日次数未知时不估算余额。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api, formatTime } from "../api";
|
||||||
import type { Account } from "../types";
|
import type { Account } from "../types";
|
||||||
import "./home.css";
|
import "./home.css";
|
||||||
@@ -312,27 +312,50 @@ function PyramidPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="home-table-wrap">
|
<div className="home-table-wrap">
|
||||||
<table>
|
<Table<PyramidRow>
|
||||||
<thead>
|
dataSource={rows}
|
||||||
<tr>
|
rowKey={(row) =>
|
||||||
<th>地区</th>
|
row ? `${row.region}:${row.delay}:${row.category_id}` : ""
|
||||||
<th>延迟</th>
|
}
|
||||||
<th>数据类别</th>
|
pagination={false}
|
||||||
<th>{metric}</th>
|
size="small"
|
||||||
</tr>
|
sticky
|
||||||
</thead>
|
style={{ minWidth: 440 }}
|
||||||
<tbody>
|
empty={
|
||||||
{rows.map((row, index) => (
|
state.data.items.length
|
||||||
<tr
|
? "没有匹配的 Pyramid 类别。"
|
||||||
key={`${row.region}:${row.delay}:${row.category_id}:${index}`}
|
: "平台暂未返回 Pyramid 数据。"
|
||||||
>
|
}
|
||||||
<td>{row.region}</td>
|
columns={[
|
||||||
<td>D{row.delay}</td>
|
{
|
||||||
<td>
|
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>
|
<strong>{row.category_name}</strong>
|
||||||
<small>{row.category_id}</small>
|
<small>{row.category_id}</small>
|
||||||
</td>
|
</>
|
||||||
<td>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: metric,
|
||||||
|
key: "value",
|
||||||
|
width: 110,
|
||||||
|
align: "right",
|
||||||
|
render: (_, row) => (
|
||||||
|
<>
|
||||||
<div className="home-value">
|
<div className="home-value">
|
||||||
{row.value === null
|
{row.value === null
|
||||||
? "—"
|
? "—"
|
||||||
@@ -345,18 +368,11 @@ function PyramidPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</>
|
||||||
</tr>
|
),
|
||||||
))}
|
},
|
||||||
</tbody>
|
]}
|
||||||
</table>
|
/>
|
||||||
{!rows.length && (
|
|
||||||
<div className="home-empty">
|
|
||||||
{state.data.items.length
|
|
||||||
? "没有匹配的 Pyramid 类别。"
|
|
||||||
: "平台暂未返回 Pyramid 数据。"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
{metric === "Alpha 数量" && (
|
{metric === "Alpha 数量" && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api, formatTime, post } from "../api";
|
||||||
import "./mcp-keys.css";
|
import "./mcp-keys.css";
|
||||||
|
|
||||||
@@ -247,37 +247,60 @@ export function MCPKeysPage() {
|
|||||||
已创建的 Key <span className="mcp-key-count">{data?.total ?? 0}</span>
|
已创建的 Key <span className="mcp-key-count">{data?.total ?? 0}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mcp-key-table">
|
<div className="mcp-key-table">
|
||||||
<table>
|
<Table<Token>
|
||||||
<thead>
|
dataSource={data?.items ?? []}
|
||||||
<tr>
|
rowKey="id"
|
||||||
<th>名称</th>
|
pagination={false}
|
||||||
<th>权限</th>
|
size="small"
|
||||||
<th>状态</th>
|
style={{ minWidth: 1040 }}
|
||||||
<th>创建时间</th>
|
loading={loading}
|
||||||
<th>到期时间</th>
|
empty="暂无 MCP Key。"
|
||||||
<th>操作</th>
|
columns={[
|
||||||
</tr>
|
{
|
||||||
</thead>
|
title: "名称",
|
||||||
<tbody>
|
key: "name",
|
||||||
{data?.items.map((item) => (
|
width: 240,
|
||||||
<tr key={item.id}>
|
render: (_, item) => (
|
||||||
<td>
|
<>
|
||||||
<strong>{item.name}</strong>
|
<strong>{item.name}</strong>
|
||||||
<small>{item.id}</small>
|
<small>{item.id}</small>
|
||||||
</td>
|
</>
|
||||||
<td>
|
),
|
||||||
{item.scopes.map((scope) => (
|
},
|
||||||
|
{
|
||||||
|
title: "权限",
|
||||||
|
key: "scopes",
|
||||||
|
width: 200,
|
||||||
|
render: (_, item) =>
|
||||||
|
item.scopes.map((scope) => (
|
||||||
<small key={scope}>
|
<small key={scope}>
|
||||||
{scopes.find(([value]) => value === scope)?.[1] ??
|
{scopes.find(([value]) => value === scope)?.[1] ?? scope}
|
||||||
scope}
|
|
||||||
</small>
|
</small>
|
||||||
))}
|
)),
|
||||||
</td>
|
},
|
||||||
<td>{labels[item.status]}</td>
|
{
|
||||||
<td>{formatTime(item.created_at)}</td>
|
title: "状态",
|
||||||
<td>{formatTime(item.expires_at)}</td>
|
key: "status",
|
||||||
<td>
|
width: 100,
|
||||||
{item.revoked_at ? (
|
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 ? (
|
) : revoking === item.id ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -289,10 +312,7 @@ export function MCPKeysPage() {
|
|||||||
>
|
>
|
||||||
确认撤销
|
确认撤销
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button disabled={busy} onClick={() => setRevoking(null)}>
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setRevoking(null)}
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,14 +324,11 @@ export function MCPKeysPage() {
|
|||||||
>
|
>
|
||||||
撤销
|
撤销
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
),
|
||||||
</td>
|
},
|
||||||
</tr>
|
]}
|
||||||
))}
|
/>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{!loading && !data?.items.length && <p>暂无 MCP Key。</p>}
|
|
||||||
<div className="mcp-key-actions">
|
<div className="mcp-key-actions">
|
||||||
<Button
|
<Button
|
||||||
disabled={offset === 0 || loading || busy}
|
disabled={offset === 0 || loading || busy}
|
||||||
|
|||||||
@@ -126,31 +126,6 @@ body[theme-mode="dark"] .home-date-fields input {
|
|||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
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 {
|
.home-panel td strong {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -161,10 +136,6 @@ body[theme-mode="dark"] .home-date-fields input {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.home-panel td:last-child {
|
|
||||||
min-width: 96px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.home-value {
|
.home-value {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -210,8 +181,4 @@ body[theme-mode="dark"] .home-date-fields input {
|
|||||||
.home-heading {
|
.home-heading {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.home-panel td,
|
|
||||||
.home-panel th {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,21 +84,9 @@
|
|||||||
.mcp-key-table {
|
.mcp-key-table {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.mcp-key-table table {
|
.mcp-key-table .semi-table-row-cell {
|
||||||
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);
|
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
overflow-wrap: anywhere;
|
||||||
.mcp-key-table th {
|
|
||||||
color: var(--semi-color-text-2);
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.mcp-key-count {
|
.mcp-key-count {
|
||||||
color: var(--semi-color-text-2);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState } from "react";
|
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 { post, formatTime } from "../api";
|
||||||
import type { UIAction } from "../ai/types";
|
import type { UIAction } from "../ai/types";
|
||||||
import type { Experiment } from "./workspaceTypes";
|
import type { Experiment } from "./workspaceTypes";
|
||||||
@@ -7,13 +14,18 @@ import { EvaluationPanel } from "./EvaluationPanel";
|
|||||||
import { LineagePanel } from "./LineagePanel";
|
import { LineagePanel } from "./LineagePanel";
|
||||||
import { ResearchSelect } from "./ResearchSelect";
|
import { ResearchSelect } from "./ResearchSelect";
|
||||||
import { validationLabel } from "./workspaceTypes";
|
import { validationLabel } from "./workspaceTypes";
|
||||||
|
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||||
|
|
||||||
export function ExperimentView({
|
export function ExperimentView({
|
||||||
experiment,
|
experiment,
|
||||||
onAction,
|
onAction,
|
||||||
|
onDelete,
|
||||||
|
deleting,
|
||||||
}: {
|
}: {
|
||||||
experiment: Experiment;
|
experiment: Experiment;
|
||||||
onAction: (action: UIAction) => void;
|
onAction: (action: UIAction) => void;
|
||||||
|
onDelete?: () => Promise<void>;
|
||||||
|
deleting?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const [assessment, setAssessment] = useState(false);
|
const [assessment, setAssessment] = useState(false);
|
||||||
@@ -61,24 +73,40 @@ export function ExperimentView({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p>{experiment.hypothesis}</p>
|
<p>{experiment.hypothesis}</p>
|
||||||
|
{experiment.archived ? (
|
||||||
|
<Banner
|
||||||
|
type="info"
|
||||||
|
description="此生成记录已删除,当前显示保留的候选与来源。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
onDelete && (
|
||||||
|
<DeleteResearchButton
|
||||||
|
name={experiment.name}
|
||||||
|
label="生成记录"
|
||||||
|
disabled={busy || deleting}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<Banner
|
<Banner
|
||||||
type="info"
|
type="info"
|
||||||
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
|
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
|
||||||
/>
|
/>
|
||||||
<div className="research-table-scroll">
|
<div className="research-table-scroll">
|
||||||
<table className="research-table">
|
<Table<Experiment["candidates"][number]>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={experiment.candidates}
|
||||||
<th>选择</th>
|
rowKey="client_item_id"
|
||||||
<th>候选表达式</th>
|
pagination={false}
|
||||||
<th>市场与设置</th>
|
size="small"
|
||||||
<th>校验</th>
|
style={{ minWidth: 840 }}
|
||||||
</tr>
|
empty="暂无候选表达式。"
|
||||||
</thead>
|
columns={[
|
||||||
<tbody>
|
{
|
||||||
{experiment.candidates.map((candidate) => (
|
title: "选择",
|
||||||
<tr key={candidate.client_item_id}>
|
key: "selection",
|
||||||
<td>
|
width: 64,
|
||||||
|
render: (_, candidate) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`选择候选 ${candidate.client_item_id}`}
|
aria-label={`选择候选 ${candidate.client_item_id}`}
|
||||||
disabled={candidate.validation.status !== "valid"}
|
disabled={candidate.validation.status !== "valid"}
|
||||||
@@ -91,8 +119,13 @@ export function ExperimentView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
),
|
||||||
<td>
|
},
|
||||||
|
{
|
||||||
|
title: "候选表达式",
|
||||||
|
key: "expression",
|
||||||
|
render: (_, candidate) => (
|
||||||
|
<>
|
||||||
<code>{candidate.expression}</code>
|
<code>{candidate.expression}</code>
|
||||||
<details>
|
<details>
|
||||||
<summary>绑定与改动</summary>
|
<summary>绑定与改动</summary>
|
||||||
@@ -107,15 +140,29 @@ export function ExperimentView({
|
|||||||
)}
|
)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</td>
|
</>
|
||||||
<td>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "市场与设置",
|
||||||
|
key: "settings",
|
||||||
|
width: 240,
|
||||||
|
render: (_, candidate) => (
|
||||||
|
<>
|
||||||
{candidate.settings.region} / {candidate.settings.universe} /
|
{candidate.settings.region} / {candidate.settings.universe} /
|
||||||
D{candidate.settings.delay}
|
D{candidate.settings.delay}
|
||||||
<br />
|
<br />
|
||||||
{candidate.settings.neutralization} · decay{" "}
|
{candidate.settings.neutralization} · decay{" "}
|
||||||
{candidate.settings.decay}
|
{candidate.settings.decay}
|
||||||
</td>
|
</>
|
||||||
<td>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "校验",
|
||||||
|
key: "validation",
|
||||||
|
width: 180,
|
||||||
|
render: (_, candidate) => (
|
||||||
|
<>
|
||||||
<Tag
|
<Tag
|
||||||
color={
|
color={
|
||||||
candidate.validation.status === "valid"
|
candidate.validation.status === "valid"
|
||||||
@@ -139,11 +186,11 @@ export function ExperimentView({
|
|||||||
</p>
|
</p>
|
||||||
)),
|
)),
|
||||||
)}
|
)}
|
||||||
</td>
|
</>
|
||||||
</tr>
|
),
|
||||||
))}
|
},
|
||||||
</tbody>
|
]}
|
||||||
</table>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="research-lineage">
|
<div className="research-lineage">
|
||||||
<strong>研究来源</strong>
|
<strong>研究来源</strong>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
Table,
|
||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
@@ -13,6 +14,7 @@ import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
|
|||||||
import { api, post } from "../api";
|
import { api, post } from "../api";
|
||||||
import type { PageContext, UIAction } from "../ai/types";
|
import type { PageContext, UIAction } from "../ai/types";
|
||||||
import { TemplateEditor } from "./TemplateEditor";
|
import { TemplateEditor } from "./TemplateEditor";
|
||||||
|
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||||
import { ResearchSelect } from "./ResearchSelect";
|
import { ResearchSelect } from "./ResearchSelect";
|
||||||
import { blankTemplate } from "./workspaceTypes";
|
import { blankTemplate } from "./workspaceTypes";
|
||||||
import type { Asset, InputSnapshot, Template } from "./workspaceTypes";
|
import type { Asset, InputSnapshot, Template } from "./workspaceTypes";
|
||||||
@@ -71,6 +73,9 @@ export function FeaturesPage({
|
|||||||
.then(([list, fixed]) => {
|
.then(([list, fixed]) => {
|
||||||
setItems(list.items);
|
setItems(list.items);
|
||||||
setTotal(list.total);
|
setTotal(list.total);
|
||||||
|
setPage((current) =>
|
||||||
|
Math.min(current, Math.max(1, Math.ceil(list.total / 25))),
|
||||||
|
);
|
||||||
setInputs(fixed.items);
|
setInputs(fixed.items);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -131,6 +136,21 @@ export function FeaturesPage({
|
|||||||
setRevision((n) => n + 1);
|
setRevision((n) => n + 1);
|
||||||
return saved;
|
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 (
|
return (
|
||||||
<section className="research-workspace research-list-page">
|
<section className="research-workspace research-list-page">
|
||||||
{error && !detailOpen && <Banner type="danger" description={error} />}
|
{error && !detailOpen && <Banner type="danger" description={error} />}
|
||||||
@@ -176,40 +196,67 @@ export function FeaturesPage({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<table className="research-table">
|
<Table<FeatureAsset>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={items}
|
||||||
<th>方案名称</th>
|
rowKey="id"
|
||||||
<th>版本</th>
|
pagination={false}
|
||||||
<th>经济假设</th>
|
size="small"
|
||||||
<th>固定输入</th>
|
sticky
|
||||||
<th>处理步骤</th>
|
style={{ minWidth: 800 }}
|
||||||
</tr>
|
empty="暂无特征方案,请新建方案或调整筛选条件。"
|
||||||
</thead>
|
columns={[
|
||||||
<tbody>
|
{
|
||||||
{items.map((item) => (
|
title: "方案名称",
|
||||||
<tr key={item.id}>
|
key: "name",
|
||||||
<td>
|
width: 200,
|
||||||
|
render: (_, item) => (
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={() => void run("load", () => load(item))}
|
onClick={() => void run("load", () => load(item))}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
),
|
||||||
<td>v{item.version}</td>
|
},
|
||||||
<td>{item.content.hypothesis || "—"}</td>
|
{
|
||||||
<td>{item.content.input_ids.length}</td>
|
title: "版本",
|
||||||
<td>{item.content.steps.length}</td>
|
key: "version",
|
||||||
</tr>
|
width: 80,
|
||||||
))}
|
render: (_, item) => <>v{item.version}</>,
|
||||||
</tbody>
|
},
|
||||||
</table>
|
{
|
||||||
{!items.length && (
|
title: "经济假设",
|
||||||
<p className="research-list-empty">
|
key: "hypothesis",
|
||||||
暂无特征方案,请新建方案或调整筛选条件。
|
render: (_, item) => item.content.hypothesis || "—",
|
||||||
</p>
|
},
|
||||||
)}
|
{
|
||||||
|
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>
|
</ResearchList>
|
||||||
<ResearchDetail
|
<ResearchDetail
|
||||||
width="min(100vw, max(640px, 60vw))"
|
width="min(100vw, max(640px, 60vw))"
|
||||||
@@ -218,6 +265,12 @@ export function FeaturesPage({
|
|||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
>
|
>
|
||||||
{error && <Banner type="danger" description={error} />}
|
{error && <Banner type="danger" description={error} />}
|
||||||
|
{asset?.archived && (
|
||||||
|
<Banner
|
||||||
|
type="info"
|
||||||
|
description="此特征方案已删除,当前显示保留的历史版本。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="research-main">
|
<div className="research-main">
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
{asset && (
|
{asset && (
|
||||||
@@ -401,7 +454,7 @@ export function FeaturesPage({
|
|||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
loading={busy === "save"}
|
loading={busy === "save"}
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy) || asset?.archived}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void run("save", async () => {
|
void run("save", async () => {
|
||||||
await save();
|
await save();
|
||||||
@@ -412,7 +465,7 @@ export function FeaturesPage({
|
|||||||
保存特征方案
|
保存特征方案
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy) || asset?.archived}
|
||||||
loading={busy === "convert"}
|
loading={busy === "convert"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void run("convert", async () => {
|
void run("convert", async () => {
|
||||||
@@ -432,6 +485,14 @@ export function FeaturesPage({
|
|||||||
>
|
>
|
||||||
保存并转为模板
|
保存并转为模板
|
||||||
</Button>
|
</Button>
|
||||||
|
{asset && !asset.archived && (
|
||||||
|
<DeleteResearchButton
|
||||||
|
name={asset.name}
|
||||||
|
label="特征方案"
|
||||||
|
disabled={!!busy}
|
||||||
|
onConfirm={() => run("delete", () => remove(asset))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="research-hint">
|
<p className="research-hint">
|
||||||
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
|
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
|
||||||
|
|||||||
@@ -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 { useState } from "react";
|
||||||
import { post } from "../api";
|
import { post } from "../api";
|
||||||
import type { UIAction } from "../ai/types";
|
import type { UIAction } from "../ai/types";
|
||||||
@@ -98,27 +98,43 @@ export function FlowRunView({
|
|||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
<div className="research-table-scroll">
|
<div className="research-table-scroll">
|
||||||
<table className="research-table">
|
<Table<FlowRun["steps"][number]>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={run.steps}
|
||||||
<th>轮次 / 阶段</th>
|
rowKey="id"
|
||||||
<th>状态</th>
|
pagination={false}
|
||||||
<th>产物与候选池</th>
|
size="small"
|
||||||
</tr>
|
style={{ minWidth: 680 }}
|
||||||
</thead>
|
empty="暂无运行步骤。"
|
||||||
<tbody>
|
columns={[
|
||||||
{run.steps.map((step) => (
|
{
|
||||||
<tr key={step.id}>
|
title: "轮次 / 阶段",
|
||||||
<td>
|
key: "stage",
|
||||||
|
width: 170,
|
||||||
|
render: (_, step) => (
|
||||||
|
<>
|
||||||
{step.round} ·{" "}
|
{step.round} ·{" "}
|
||||||
{run.definition.nodes.find((n) => n.id === step.node_id)
|
{run.definition.nodes.find((n) => n.id === step.node_id)
|
||||||
?.label || step.node_id}
|
?.label || step.node_id}
|
||||||
</td>
|
</>
|
||||||
<td>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
key: "status",
|
||||||
|
width: 140,
|
||||||
|
render: (_, step) => (
|
||||||
|
<>
|
||||||
{flowStatus[step.status] || step.status}
|
{flowStatus[step.status] || step.status}
|
||||||
{step.error && <p>{step.error}</p>}
|
{step.error && <p>{step.error}</p>}
|
||||||
</td>
|
</>
|
||||||
<td>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "产物与候选池",
|
||||||
|
key: "output",
|
||||||
|
render: (_, step) => (
|
||||||
|
<>
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
{step.output.template && (
|
{step.output.template && (
|
||||||
<Button
|
<Button
|
||||||
@@ -184,11 +200,11 @@ export function FlowRunView({
|
|||||||
{JSON.stringify(step.output, null, 2)}
|
{JSON.stringify(step.output, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</td>
|
</>
|
||||||
</tr>
|
),
|
||||||
))}
|
},
|
||||||
</tbody>
|
]}
|
||||||
</table>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Input,
|
Input,
|
||||||
|
Table,
|
||||||
TextArea,
|
TextArea,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
import { api, patch, post, formatTime } from "../api";
|
import { api, patch, post, formatTime } from "../api";
|
||||||
@@ -138,34 +139,40 @@ export function OperatorsPage({ active }: { active: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<table className="research-table">
|
<Table<Operator>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={result.items}
|
||||||
<th>算子</th>
|
rowKey="name"
|
||||||
<th>分类</th>
|
pagination={false}
|
||||||
<th>定义</th>
|
size="small"
|
||||||
</tr>
|
sticky
|
||||||
</thead>
|
style={{ minWidth: 700 }}
|
||||||
<tbody>
|
empty="暂无算子。连接平台并同步目录,或调整筛选条件。"
|
||||||
{result.items.map((item) => (
|
columns={[
|
||||||
<tr key={item.name}>
|
{
|
||||||
<td>
|
title: "算子",
|
||||||
|
key: "name",
|
||||||
|
width: 230,
|
||||||
|
render: (_, item) => (
|
||||||
<Button theme="borderless" onClick={() => setSelected(item)}>
|
<Button theme="borderless" onClick={() => setSelected(item)}>
|
||||||
{item.local.favorite ? "已收藏 · " : ""}
|
{item.local.favorite ? "已收藏 · " : ""}
|
||||||
{item.name}
|
{item.name}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
),
|
||||||
<td>{item.category}</td>
|
},
|
||||||
<td>
|
{
|
||||||
<code>{item.definition}</code>
|
title: "分类",
|
||||||
</td>
|
key: "category",
|
||||||
</tr>
|
width: 150,
|
||||||
))}
|
render: (_, item) => item.category,
|
||||||
</tbody>
|
},
|
||||||
</table>
|
{
|
||||||
{!result.items.length && (
|
title: "定义",
|
||||||
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
|
key: "definition",
|
||||||
)}
|
render: (_, item) => <code>{item.definition}</code>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</ResearchList>
|
</ResearchList>
|
||||||
<ResearchDetail
|
<ResearchDetail
|
||||||
title={selected?.name || "算子详情"}
|
title={selected?.name || "算子详情"}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ResearchList, ResearchDetail } from "./ResearchList";
|
import { ResearchList, ResearchDetail } from "./ResearchList";
|
||||||
import { useEffect, useState } from "react";
|
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 { api, formatTime } from "../api";
|
||||||
import type { PageContext, UIAction } from "../ai/types";
|
import type { PageContext, UIAction } from "../ai/types";
|
||||||
import { FlowLaunchForm } from "./FlowLaunchForm";
|
import { FlowLaunchForm } from "./FlowLaunchForm";
|
||||||
@@ -99,33 +99,45 @@ export function PipelinePage({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<table className="research-table">
|
<Table<FlowRun>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={items}
|
||||||
<th>研究名称</th>
|
rowKey="id"
|
||||||
<th>状态</th>
|
pagination={false}
|
||||||
<th>轮次</th>
|
size="small"
|
||||||
<th>创建时间</th>
|
sticky
|
||||||
</tr>
|
style={{ minWidth: 640 }}
|
||||||
</thead>
|
empty="暂无研究运行,请新建自动研究。"
|
||||||
<tbody>
|
columns={[
|
||||||
{items.map((item) => (
|
{
|
||||||
<tr key={item.id}>
|
title: "研究名称",
|
||||||
<td>
|
key: "name",
|
||||||
|
render: (_, item) => (
|
||||||
<Button theme="borderless" onClick={() => select(item.id)}>
|
<Button theme="borderless" onClick={() => select(item.id)}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
),
|
||||||
<td>{flowStatus[item.status]}</td>
|
},
|
||||||
<td>{item.round}</td>
|
{
|
||||||
<td>{formatTime(item.created_at)}</td>
|
title: "状态",
|
||||||
</tr>
|
key: "status",
|
||||||
))}
|
width: 140,
|
||||||
</tbody>
|
render: (_, item) => flowStatus[item.status],
|
||||||
</table>
|
},
|
||||||
{!items.length && (
|
{
|
||||||
<p className="research-list-empty">暂无研究运行,请新建自动研究。</p>
|
title: "轮次",
|
||||||
)}
|
key: "round",
|
||||||
|
width: 80,
|
||||||
|
render: (_, item) => item.round,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
key: "created_at",
|
||||||
|
width: 190,
|
||||||
|
render: (_, item) => formatTime(item.created_at),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</ResearchList>
|
</ResearchList>
|
||||||
<ResearchDetail
|
<ResearchDetail
|
||||||
title={creating ? "新建自动研究" : "研究运行详情"}
|
title={creating ? "新建自动研究" : "研究运行详情"}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
Table,
|
||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
@@ -422,76 +423,95 @@ export function QuantFlowPage({
|
|||||||
>
|
>
|
||||||
{mode === "runs" ? (
|
{mode === "runs" ? (
|
||||||
<>
|
<>
|
||||||
<table className="research-table">
|
<Table<FlowRun>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={runs}
|
||||||
<th>运行名称</th>
|
rowKey="id"
|
||||||
<th>状态</th>
|
pagination={false}
|
||||||
<th>轮次</th>
|
size="small"
|
||||||
<th>创建时间</th>
|
sticky
|
||||||
</tr>
|
style={{ minWidth: 640 }}
|
||||||
</thead>
|
empty="暂无流程运行记录。"
|
||||||
<tbody>
|
columns={[
|
||||||
{runs.map((r) => (
|
{
|
||||||
<tr key={r.id}>
|
title: "运行名称",
|
||||||
<td>
|
key: "name",
|
||||||
|
render: (_, r) => (
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRunId(r.id);
|
setRunId(r.id);
|
||||||
setRun(null);
|
setRun(null);
|
||||||
localStorage.setItem(
|
localStorage.setItem("quantflow-selected-run", r.id);
|
||||||
"quantflow-selected-run",
|
|
||||||
r.id,
|
|
||||||
);
|
|
||||||
navigate(`/runs/${r.id}`);
|
navigate(`/runs/${r.id}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{r.name}
|
{r.name}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
),
|
||||||
<td>{flowStatus[r.status]}</td>
|
},
|
||||||
<td>{r.round}</td>
|
{
|
||||||
<td>{formatTime(r.created_at)}</td>
|
title: "状态",
|
||||||
</tr>
|
key: "status",
|
||||||
))}
|
width: 140,
|
||||||
</tbody>
|
render: (_, r) => flowStatus[r.status],
|
||||||
</table>
|
},
|
||||||
{!runs.length && (
|
{
|
||||||
<p className="research-list-empty">暂无流程运行记录。</p>
|
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">
|
<Table<WorkflowAsset>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={assets}
|
||||||
<th>流程名称</th>
|
rowKey="id"
|
||||||
<th>版本</th>
|
pagination={false}
|
||||||
<th>节点数量</th>
|
size="small"
|
||||||
<th>连接数量</th>
|
sticky
|
||||||
</tr>
|
style={{ minWidth: 640 }}
|
||||||
</thead>
|
empty="暂无流程,请新建流程或复制固定配方。"
|
||||||
<tbody>
|
columns={[
|
||||||
{assets.map((a) => (
|
{
|
||||||
<tr key={a.id}>
|
title: "流程名称",
|
||||||
<td>
|
key: "name",
|
||||||
|
render: (_, a) => (
|
||||||
<Button theme="borderless" onClick={() => void open(a)}>
|
<Button theme="borderless" onClick={() => void open(a)}>
|
||||||
{a.name}
|
{a.name}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
),
|
||||||
<td>v{a.version}</td>
|
},
|
||||||
<td>{a.content.nodes.length}</td>
|
{
|
||||||
<td>{a.content.edges.length}</td>
|
title: "版本",
|
||||||
</tr>
|
key: "version",
|
||||||
))}
|
width: 80,
|
||||||
</tbody>
|
render: (_, a) => <>v{a.version}</>,
|
||||||
</table>
|
},
|
||||||
{!assets.length && (
|
{
|
||||||
<p className="research-list-empty">
|
title: "节点数量",
|
||||||
暂无流程,请新建流程或复制固定配方。
|
key: "nodes",
|
||||||
</p>
|
width: 110,
|
||||||
)}
|
render: (_, a) => a.content.nodes.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "连接数量",
|
||||||
|
key: "edges",
|
||||||
|
width: 110,
|
||||||
|
render: (_, a) => a.content.edges.length,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ResearchList>
|
</ResearchList>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
|
Table,
|
||||||
TextArea,
|
TextArea,
|
||||||
Toast,
|
Toast,
|
||||||
} from "@douyinfe/semi-ui-19";
|
} from "@douyinfe/semi-ui-19";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
} from "./workspaceTypes";
|
} from "./workspaceTypes";
|
||||||
import { TemplateEditor } from "./TemplateEditor";
|
import { TemplateEditor } from "./TemplateEditor";
|
||||||
import { ExperimentView } from "./ExperimentView";
|
import { ExperimentView } from "./ExperimentView";
|
||||||
|
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||||
import { ComparisonPanel } from "./ComparisonPanel";
|
import { ComparisonPanel } from "./ComparisonPanel";
|
||||||
import "./workspace.css";
|
import "./workspace.css";
|
||||||
|
|
||||||
@@ -107,6 +109,12 @@ export function ResearchWorkspace({
|
|||||||
if (sequence !== refreshSequence.current) return;
|
if (sequence !== refreshSequence.current) return;
|
||||||
setAssetTotal(nextAssets.total);
|
setAssetTotal(nextAssets.total);
|
||||||
setHistoryTotal(nextHistory.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);
|
setAssets(nextAssets.items);
|
||||||
setInputs(nextInputs.items);
|
setInputs(nextInputs.items);
|
||||||
setHistory(nextHistory.items);
|
setHistory(nextHistory.items);
|
||||||
@@ -236,6 +244,31 @@ export function ResearchWorkspace({
|
|||||||
setTemplate(next.content);
|
setTemplate(next.content);
|
||||||
await refresh();
|
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() {
|
async function expand() {
|
||||||
if (dirty) throw new Error("请先保存模板版本,再生成可追溯候选");
|
if (dirty) throw new Error("请先保存模板版本,再生成可追溯候选");
|
||||||
const next = await post<Experiment>("/research/experiments", {
|
const next = await post<Experiment>("/research/experiments", {
|
||||||
@@ -344,19 +377,20 @@ export function ResearchWorkspace({
|
|||||||
>
|
>
|
||||||
{listMode === "assets" ? (
|
{listMode === "assets" ? (
|
||||||
<>
|
<>
|
||||||
<table className="research-table">
|
<Table<Asset>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={assets}
|
||||||
<th>模板名称</th>
|
rowKey="id"
|
||||||
<th>版本</th>
|
pagination={false}
|
||||||
<th>表达式</th>
|
size="small"
|
||||||
<th>分类</th>
|
sticky
|
||||||
</tr>
|
style={{ minWidth: 720 }}
|
||||||
</thead>
|
empty="还没有模板。新建或导入一个模板开始研究。"
|
||||||
<tbody>
|
columns={[
|
||||||
{assets.map((item) => (
|
{
|
||||||
<tr key={item.id}>
|
title: "模板名称",
|
||||||
<td>
|
key: "name",
|
||||||
|
render: (_, item) => (
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -374,38 +408,61 @@ export function ResearchWorkspace({
|
|||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
),
|
||||||
<td>v{item.version}</td>
|
},
|
||||||
<td>
|
{
|
||||||
<code>{item.content.expression}</code>
|
title: "版本",
|
||||||
</td>
|
key: "version",
|
||||||
<td>
|
width: 80,
|
||||||
{item.content.category === "fragment" ? "片段" : "模板"}
|
render: (_, item) => <>v{item.version}</>,
|
||||||
</td>
|
},
|
||||||
</tr>
|
{
|
||||||
))}
|
title: "表达式",
|
||||||
</tbody>
|
key: "expression",
|
||||||
</table>
|
width: 260,
|
||||||
{!assets.length && (
|
render: (_, item) => <code>{item.content.expression}</code>,
|
||||||
<p className="research-list-empty">
|
},
|
||||||
还没有模板。新建或导入一个模板开始研究。
|
{
|
||||||
</p>
|
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">
|
<Table<(typeof history)[number]>
|
||||||
<thead>
|
className="research-table"
|
||||||
<tr>
|
dataSource={history}
|
||||||
<th>研究名称</th>
|
rowKey="id"
|
||||||
<th>候选数量</th>
|
pagination={false}
|
||||||
<th>创建时间</th>
|
size="small"
|
||||||
</tr>
|
sticky
|
||||||
</thead>
|
style={{ minWidth: 640 }}
|
||||||
<tbody>
|
empty="暂无生成记录。"
|
||||||
{history.map((item) => (
|
columns={[
|
||||||
<tr key={item.id}>
|
{
|
||||||
<td>
|
title: "研究名称",
|
||||||
|
key: "name",
|
||||||
|
render: (_, item) => (
|
||||||
<Button
|
<Button
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -420,16 +477,37 @@ export function ResearchWorkspace({
|
|||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
),
|
||||||
<td>{item.total}</td>
|
},
|
||||||
<td>{formatTime(item.created_at)}</td>
|
{
|
||||||
</tr>
|
title: "候选数量",
|
||||||
))}
|
key: "total",
|
||||||
</tbody>
|
width: 110,
|
||||||
</table>
|
render: (_, item) => item.total,
|
||||||
{!history.length && (
|
},
|
||||||
<p className="research-list-empty">暂无生成记录。</p>
|
{
|
||||||
)}
|
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>
|
</ResearchList>
|
||||||
@@ -452,10 +530,20 @@ export function ResearchWorkspace({
|
|||||||
key={experiment.id}
|
key={experiment.id}
|
||||||
experiment={experiment}
|
experiment={experiment}
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
|
deleting={!!busy}
|
||||||
|
onDelete={() =>
|
||||||
|
task("删除生成记录", () => removeExperiment(experiment))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="research-main">
|
<div className="research-main">
|
||||||
|
{asset?.archived && (
|
||||||
|
<Banner
|
||||||
|
type="info"
|
||||||
|
description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<section className="research-card">
|
<section className="research-card">
|
||||||
<h3>研究输入与假设</h3>
|
<h3>研究输入与假设</h3>
|
||||||
{page === "variants" && (
|
{page === "variants" && (
|
||||||
@@ -619,11 +707,21 @@ export function ResearchWorkspace({
|
|||||||
)}
|
)}
|
||||||
<div className="research-toolbar">
|
<div className="research-toolbar">
|
||||||
<Button
|
<Button
|
||||||
disabled={!!busy}
|
disabled={!!busy || asset?.archived}
|
||||||
onClick={() => void task("保存模板", save)}
|
onClick={() => void task("保存模板", save)}
|
||||||
>
|
>
|
||||||
保存模板版本
|
保存模板版本
|
||||||
</Button>
|
</Button>
|
||||||
|
{asset && !asset.archived && (
|
||||||
|
<DeleteResearchButton
|
||||||
|
name={asset.name}
|
||||||
|
label="模板"
|
||||||
|
disabled={!!busy}
|
||||||
|
onConfirm={() =>
|
||||||
|
task("删除模板", () => removeAsset(asset))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span>组合规模:{combination}</span>
|
<span>组合规模:{combination}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="research-form-grid">
|
<div className="research-form-grid">
|
||||||
@@ -776,6 +874,10 @@ export function ResearchWorkspace({
|
|||||||
key={experiment.id}
|
key={experiment.id}
|
||||||
experiment={experiment}
|
experiment={experiment}
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
|
deleting={!!busy}
|
||||||
|
onDelete={() =>
|
||||||
|
task("删除生成记录", () => removeExperiment(experiment))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "templates" && (
|
{page === "templates" && (
|
||||||
|
|||||||
@@ -104,25 +104,8 @@
|
|||||||
.research-table-scroll {
|
.research-table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.research-table {
|
.research-table .semi-table-row-cell {
|
||||||
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;
|
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--semi-color-border);
|
|
||||||
}
|
|
||||||
.research-table td {
|
|
||||||
max-width: 350px;
|
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.research-table details {
|
.research-table details {
|
||||||
@@ -356,16 +339,6 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
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 {
|
.research-detail .semi-sidesheet-body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type Asset = {
|
|||||||
name: string;
|
name: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
version: number;
|
version: number;
|
||||||
|
latest_version?: number;
|
||||||
|
archived?: boolean;
|
||||||
content: Template;
|
content: Template;
|
||||||
provenance?: {
|
provenance?: {
|
||||||
feature?: {
|
feature?: {
|
||||||
@@ -59,6 +61,7 @@ export type ResearchCandidate = Candidate & {
|
|||||||
};
|
};
|
||||||
export type Experiment = {
|
export type Experiment = {
|
||||||
id: string;
|
id: string;
|
||||||
|
archived?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
hypothesis: string;
|
hypothesis: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
@@ -546,26 +546,6 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
|||||||
.usage-table-wrap {
|
.usage-table-wrap {
|
||||||
overflow-x: auto;
|
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 {
|
.usage-note {
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user