feat: unify scope settings and catalog table interactions
Deploy production / deploy (push) Successful in 54s

This commit is contained in:
yuxuanhui
2026-09-12 12:11:53 +08:00
parent 9dee2de039
commit 8981c24fda
29 changed files with 2521 additions and 796 deletions
@@ -0,0 +1,17 @@
# 统一目录工具栏与验证
Type: task
Status: ready-for-agent
- [x] 共用工具栏、列配置和图标操作
- [x] 三个列表接入筛选、排序与列显隐
- [x] 必需的服务端排序支持
- [x] 类型/构建、后端与浏览器验证
## Comments
用户已授权开发;保留上一轮组合/参数组件改动。
## 验收结果
2026-09-12 本地实现完成:三个主列表使用共用筛选/列设置/排序工具栏,常用动作图标化;列选择保存在各自 localStorage 键。数据准备按名称、创建时间、更新时间在服务端排序,默认更新时间降序;在线字段排序保留禁用说明。
验证:后端目录/数据准备/参数元数据相关 38 项测试通过,ruff 通过;前端构建通过。目录与参数组件共 8 个浏览器用例已通过(首轮 6 项通过,修正复选框测试定位和新建图标无障碍名称后,失败的 2 项分别复测通过);新增组合筛选嵌套交互也通过。检查了桌面筛选/排序截图,已有用例验证 40px 行高、截断、窄屏底栏、跨页选择和在线查询约束。测试使用隔离模拟平台,未访问真实 WQ;构建仍提示已有依赖 eval 与大包体积警告。
@@ -0,0 +1,5 @@
# 统一目录表格交互
用户要求数据目录、字段目录、数据准备的表格交互与 Alpha 管理一致。统一筛选浮层(草稿/查询应用/重置)、列显隐持久化、独立排序浮层与左右工具栏;常用动作图标化,保留 aria-label 与提示。保留已完成的组合组件;不新增未授权的保存视图、导出或上游排序能力。服务端分页保持全量排序,在线字段仍需明确条件并点击查询,切换范围清空结果/勾选。
本地验证覆盖筛选生效时机、排序跨页、列配置刷新恢复、图标名称、40px 行高和窄屏布局。保留前一任务全部改动。
@@ -0,0 +1,15 @@
# 实现与验证
Type: task
Status: ready-for-agent
- [x] 补齐设置选项元数据与测试
- [x] 实现 ScopePicker 与 SimulationSettingsEditor
- [x] 接入目录及三个回测入口
- [x] 类型检查、后端测试、浏览器回归
## Comments
用户已批准方案,本轮开始实现;不修改旧项目。
实现完成(2026-09-12)。新增共享选项模型、ScopePicker、SimulationSettingsEditor;目录通过 ScopeControls 兼容入口复用,三个回测入口使用同一编辑器。补齐服务端字段 choices/数值范围及 Delay/Universe 依赖解析,保留快照兼容。移除隐式 JSON 覆盖,历史 JSON 逐项校验且不覆盖原文。
验证:后端相关 60 项通过;ruff check app tests 通过;前端 build(含类型检查)通过。前端相关 12 项验收通过(backtests 3、dataset 3、research-flows 1、research-outcomes 1、settings 4);最后的选项过滤及收起态提示变更另复跑 settings 4 项通过。已查看桌面/390px 参数截图和集合内嵌选择面板截图。测试使用隔离数据库与模拟上游,没有访问真实 WorldQuant 或部署。构建保留既有 lottie eval 与包体积警告。
@@ -0,0 +1,5 @@
# 通用组合与回测参数组件
用户已确认开发。组合采用左摘要、右选择按钮,展开后以临时值编辑,应用时原子提交;取消不影响调用方。保留平台、本地与固定输入三种范围语义。回测表单复用组合选择;有 choices 的字段使用下拉,范围数值使用数字输入。参数选项与联动校验集中维护,历史非法值可见但不可预览。移除共用参数 JSON 隐式覆盖,保留独立候选 JSON 模式及服务端校验。
接入数据准备、数据/字段目录、回测准备、研究工作台、自动研究启动。补齐设置快照字段元数据,保留已有 items 兼容性。验收覆盖选项解析、联动/取消、固定范围、错误重试、最终参数一致性与窄屏。
+29 -3
View File
@@ -30,7 +30,7 @@ def setting_rows(data):
try: try:
children = data["actions"]["POST"]["settings"]["children"] children = data["actions"]["POST"]["settings"]["children"]
def choices(key, instrument=None, region=None): def choices(key, instrument=None, region=None, delay=None, universe=None):
value = children[key]["choices"] value = children[key]["choices"]
if isinstance(value, dict) and "instrumentType" in value: if isinstance(value, dict) and "instrumentType" in value:
value = value["instrumentType"][instrument] value = value["instrumentType"][instrument]
@@ -38,6 +38,14 @@ def setting_rows(data):
value = value[instrument] value = value[instrument]
if isinstance(value, dict) and "region" in value: if isinstance(value, dict) and "region" in value:
value = value["region"][region] value = value["region"][region]
for dimension, selected in (("delay", delay), ("universe", universe)):
if isinstance(value, dict) and dimension in value:
branches = value[dimension]
if not isinstance(branches, dict):
raise ValueError()
value = branches.get(str(selected), branches.get(selected))
if not isinstance(value, list):
raise ValueError()
return [item["value"] for item in value] return [item["value"] for item in value]
rows = [] rows = []
@@ -48,12 +56,29 @@ def setting_rows(data):
for delay in choices("delay", instrument, region): for delay in choices("delay", instrument, region):
if type(delay) is not int or delay not in (0, 1): if type(delay) is not int or delay not in (0, 1):
continue continue
for universe in choices("universe", instrument, region): for universe in choices("universe", instrument, region, delay):
neutralizations = ( neutralizations = (
choices("neutralization", instrument, region) choices("neutralization", instrument, region, delay, universe)
if "neutralization" in children if "neutralization" in children
else [] else []
) )
fields = {}
for key in (
"decay", "truncation", "pasteurization", "unitHandling",
"nanHandling", "language", "visualization", "maxTrade", "maxPosition",
):
definition = children.get(key)
if not isinstance(definition, dict):
continue
field = {}
if "choices" in definition:
field["choices"] = choices(key, instrument, region, delay, universe)
if any(type(v) not in (str, int, float, bool) for v in field["choices"]):
raise ValueError()
for source, target in (("min_value", "minimum"), ("max_value", "maximum")):
if type(definition.get(source)) in (int, float):
field[target] = definition[source]
fields[key] = field
rows.append( rows.append(
{ {
"instrument_type": instrument, "instrument_type": instrument,
@@ -61,6 +86,7 @@ def setting_rows(data):
"universe": universe, "universe": universe,
"delay": delay, "delay": delay,
"neutralizations": neutralizations, "neutralizations": neutralizations,
"fields": fields,
} }
) )
if not rows: if not rows:
+4 -2
View File
@@ -1,6 +1,6 @@
"""Authenticated preparation and field-directory endpoints.""" """Authenticated preparation and field-directory endpoints."""
from typing import Annotated from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import delete, select from sqlalchemy import delete, select
@@ -55,9 +55,11 @@ async def preparations(
scope_key: str | None = None, scope_key: str | None = None,
limit: int = Query(25, ge=1, le=100), limit: int = Query(25, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
sort: Literal["name", "created_at", "updated_at"] = "updated_at",
direction: Literal["asc", "desc"] = "desc",
): ):
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
return await Preparations(db).list(q, scope_key, limit, offset) return await Preparations(db).list(q, scope_key, limit, offset, sort, direction)
@router.post("/data-preparations", status_code=201) @router.post("/data-preparations", status_code=201)
+14 -7
View File
@@ -138,9 +138,12 @@ class Preparations:
async def online_fields(self, filters): async def online_fields(self, filters):
text_filters = (filters.q, filters.dataset_id, filters.field_type) text_filters = (filters.q, filters.dataset_id, filters.field_type)
numeric_filters = ( numeric_filters = (
filters.coverage_min, filters.coverage_max, filters.coverage_min,
filters.user_count_min, filters.user_count_max, filters.coverage_max,
filters.alpha_count_min, filters.alpha_count_max, filters.user_count_min,
filters.user_count_max,
filters.alpha_count_min,
filters.alpha_count_max,
) )
if not any(value and value.strip() for value in text_filters) and not any( if not any(value and value.strip() for value in text_filters) and not any(
value is not None for value in numeric_filters value is not None for value in numeric_filters
@@ -304,7 +307,7 @@ class Preparations:
) )
) )
async def list(self, q="", scope_key=None, limit=25, offset=0): async def list(self, q="", scope_key=None, limit=25, offset=0, sort="updated_at", direction="desc"):
query = select(DataPreparation) query = select(DataPreparation)
if scope_key: if scope_key:
query = query.where(DataPreparation.scope_key == scope_key) query = query.where(DataPreparation.scope_key == scope_key)
@@ -316,9 +319,13 @@ class Preparations:
) )
) )
total = await self.db.scalar(select(func.count()).select_from(query.subquery())) total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
rows = await self.db.scalars( column = {
query.order_by(DataPreparation.updated_at.desc(), DataPreparation.id).limit(limit).offset(offset) "name": DataPreparation.name,
) "created_at": DataPreparation.created_at,
"updated_at": DataPreparation.updated_at,
}[sort]
order = column.asc() if direction == "asc" else column.desc()
rows = await self.db.scalars(query.order_by(order, DataPreparation.id).limit(limit).offset(offset))
return page([await self.output(row) for row in rows], total, limit, offset) return page([await self.output(row) for row in rows], total, limit, offset)
async def members(self, preparation_id, q="", dataset_id=None, limit=25, offset=0): async def members(self, preparation_id, q="", dataset_id=None, limit=25, offset=0):
+33
View File
@@ -0,0 +1,33 @@
"""Preparation sorting applies before pagination and rejects unsupported keys."""
async def test_preparation_sort_pagination(logged_in):
client = logged_in
scope = {"instrument_type": "EQUITY", "region": "USA", "universe": "TOP3000", "delay": 1}
for name in ["sort-C", "sort-A", "sort-B", "unrelated"]:
response = await client.post("/api/v1/data-preparations", json={"name": name, "scope": scope})
assert response.status_code == 201
for direction, expected in [
("asc", ["sort-A", "sort-B", "sort-C"]),
("desc", ["sort-C", "sort-B", "sort-A"]),
]:
names = []
for offset in range(3):
response = await client.get(
"/api/v1/data-preparations",
params={
"q": "sort-",
"sort": "name",
"direction": direction,
"limit": 1,
"offset": offset,
},
)
assert response.status_code == 200
assert response.json()["total"] == 3
names.append(response.json()["items"][0]["name"])
assert names == expected
for params in [{"sort": "field_count"}, {"direction": "sideways"}]:
assert (await client.get("/api/v1/data-preparations", params=params)).status_code == 422
default = (await client.get("/api/v1/data-preparations")).json()
assert default["items"][0]["name"] == "unrelated"
+56
View File
@@ -0,0 +1,56 @@
"""Settings metadata retains platform choices, numeric bounds and scope dependencies."""
import pytest
from fastapi import HTTPException
from app.catalog.research_metadata import ResearchMetadata, setting_rows
from tests.catalog_fake import platform_response
def settings_response():
data = platform_response()
children = data["actions"]["POST"]["settings"]["children"]
children["neutralization"] = {"choices": [{"value": "INDUSTRY"}, {"value": "NONE"}]}
children["decay"] = {"min_value": 0, "max_value": 500}
children["truncation"] = {"choices": [{"value": 0.05}, {"value": 0.08}]}
children["visualization"] = {"choices": [{"value": False}, {"value": True}]}
children["nanHandling"] = {"choices": {"instrumentType": {"EQUITY": {"region": {
region: {"delay": {str(delay): [{"value": "OFF" if delay == 0 else "ON"}] for delay in [0, 1]}}
for region in ["USA", "CHN", "IND"]
}}}}}
return data
def test_metadata_preserves_types_and_resolves_delay():
rows = setting_rows(settings_response())
first = next(r for r in rows if r["region"] == "USA" and r["delay"] == 0)
second = next(r for r in rows if r["region"] == "USA" and r["delay"] == 1)
assert first["fields"]["decay"] == {"minimum": 0, "maximum": 500}
assert first["fields"]["truncation"]["choices"] == [0.05, 0.08]
assert first["fields"]["visualization"]["choices"] == [False, True]
assert first["fields"]["nanHandling"]["choices"] == ["OFF"]
assert second["fields"]["nanHandling"]["choices"] == ["ON"]
assert first["neutralizations"] == ["INDUSTRY", "NONE"]
async def test_refresh_publishes_fields_and_keeps_previous_snapshot_on_invalid_options(app):
class Client:
async def research_setting_options(self):
return settings_response()
async with app.state.sessions.begin() as db:
service = ResearchMetadata(db, Client())
previous = await service.refresh_settings()
assert previous["content"]["items"][0]["fields"]["visualization"]["choices"] == [False, True]
class InvalidClient:
async def research_setting_options(self):
data = settings_response()
data["actions"]["POST"]["settings"]["children"]["nanHandling"]["choices"] = {"unsupported": []}
return data
service.client = InvalidClient()
with pytest.raises(HTTPException) as error:
await service.refresh_settings()
assert error.value.status_code == 502
assert (await service.get("settings"))["content"] == previous["content"]
+58 -95
View File
@@ -1,3 +1,6 @@
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions";
import { settingsErrors } from "../settings/model";
import { import {
ResearchDataInput, ResearchDataInput,
researchSelection, researchSelection,
@@ -104,7 +107,11 @@ export function BacktestPage({
const [text, setText] = useState(""); const [text, setText] = useState("");
const [mode, setMode] = useState("lines"); const [mode, setMode] = useState("lines");
const [settings, setSettings] = useState<SimulationSettings>(initialSettings); const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
const [advanced, setAdvanced] = useState(""); const options = useSettingsOptions(active && editor);
const [settingsValid, setSettingsValid] = useState(false);
const fixedScopes = inputIds.length
? inputs.filter((r) => inputIds.includes(r.id)).map((r) => r.scope)
: undefined;
const [preview, setPreview] = useState<Preview | null>(null); const [preview, setPreview] = useState<Preview | null>(null);
const [excluded, setExcluded] = useState<string[]>([]); const [excluded, setExcluded] = useState<string[]>([]);
const [referenceUrls, setReferenceUrls] = useState<Record<string, string>>( const [referenceUrls, setReferenceUrls] = useState<Record<string, string>>(
@@ -250,12 +257,13 @@ export function BacktestPage({
setText(""); setText("");
setSettings(initialSettings); setSettings(initialSettings);
setMode("lines"); setMode("lines");
setAdvanced("");
setPreview(null); setPreview(null);
setEditor(true); setEditor(true);
setRunId(""); setRunId("");
} }
function input() { function input() {
if (options.loading || options.error)
throw new Error("请等待参数选项加载完成,或重试同步合法设置");
const candidates: Candidate[] = const candidates: Candidate[] =
mode === "json" mode === "json"
? JSON.parse(text) ? JSON.parse(text)
@@ -266,13 +274,20 @@ export function BacktestPage({
.map((expression, i) => ({ .map((expression, i) => ({
client_item_id: `item-${i + 1}`, client_item_id: `item-${i + 1}`,
expression, expression,
settings: { settings: { ...settings },
...settings,
...(advanced.trim() ? JSON.parse(advanced) : {}),
},
})); }));
if (!Array.isArray(candidates) || !candidates.length) if (!Array.isArray(candidates) || !candidates.length)
throw new Error("请提供非空候选集合"); throw new Error("请提供非空候选集合");
for (const [index, candidate] of candidates.entries()) {
if (!candidate || !candidate.settings)
throw new Error(`候选 ${index + 1} 缺少完整参数`);
// Defaults match the server contract; explicit imported values always win.
const resolved = { ...initialSettings, ...candidate.settings };
const issues = settingsErrors(resolved, options.rows, fixedScopes);
if (issues.length)
throw new Error(`候选 ${index + 1}:${issues.join(";")}`);
candidate.settings = resolved;
}
return { name, source, candidates, ...researchSelection(inputIds, inputs) }; return { name, source, candidates, ...researchSelection(inputIds, inputs) };
} }
async function loadDraft(id: string) { async function loadDraft(id: string) {
@@ -488,7 +503,7 @@ export function BacktestPage({
<SideSheet <SideSheet
title={preview ? "确认回测输入" : "准备候选"} title={preview ? "确认回测输入" : "准备候选"}
visible={editor && drawerVisible} visible={editor && drawerVisible}
width={`min(60vw, calc(100vw - ${chatOffset}px))`} width={`min(max(640px, 60vw), calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
onCancel={() => setEditor(false)} onCancel={() => setEditor(false)}
@@ -614,12 +629,24 @@ export function BacktestPage({
) : ( ) : (
<> <>
<div className="backtest-toolbar"> <div className="backtest-toolbar">
<Button disabled={busy} onClick={() => void perform(save)}> <Button
disabled={
busy ||
options.loading ||
(mode === "lines" && !settingsValid)
}
onClick={() => void perform(save)}
>
保存草稿 保存草稿
</Button> </Button>
<Button <Button
theme="solid" theme="solid"
loading={busy} loading={busy}
disabled={
options.loading ||
!!options.error ||
(mode === "lines" && !settingsValid)
}
onClick={() => void perform(prepare)} onClick={() => void perform(prepare)}
> >
预览回测 预览回测
@@ -683,94 +710,30 @@ export function BacktestPage({
]} ]}
/> />
</label> </label>
{mode === "lines" && ( {mode === "lines" ? (
<> <SimulationSettingsEditor
<div className="backtest-form-grid"> value={settings}
<label> onChange={setSettings}
Region {...options}
<Input fetchedAt={options.fetchedAt}
aria-label="Region" onRefresh={() => void options.refresh()}
placeholder="例如 USA" fixedScopes={fixedScopes}
value={settings.region} disabled={busy}
onChange={(region) => onValidityChange={setSettingsValid}
setSettings({ ...settings, region })
}
/> />
</label> ) : (
<label> <div className="settings-actions">
Universe <Button
<Input loading={options.loading}
aria-label="Universe" onClick={() => void options.refresh()}
placeholder="例如 TOP3000" >
value={settings.universe} 同步合法设置
onChange={(universe) => </Button>
setSettings({ ...settings, universe }) <span>逐项校验完整参数;预览展示最终值。</span>
} {options.error && (
/> <Banner type="warning" description={options.error} />
</label> )}
<label>
Delay
<Select
aria-label="Delay"
value={settings.delay}
onChange={(v) =>
setSettings({
...settings,
delay: Number(v) as 0 | 1,
})
}
optionList={[
{ value: 0, label: "0" },
{ value: 1, label: "1" },
]}
/>
</label>
<label>
Decay
<InputNumber
aria-label="Decay"
value={settings.decay}
min={0}
onChange={(v) =>
setSettings({ ...settings, decay: Number(v) })
}
/>
</label>
<label>
Neutralization
<Input
aria-label="Neutralization"
value={settings.neutralization}
onChange={(neutralization) =>
setSettings({ ...settings, neutralization })
}
/>
</label>
<label>
Truncation
<InputNumber
aria-label="Truncation"
value={settings.truncation}
min={0}
max={1}
step={0.01}
onChange={(v) =>
setSettings({ ...settings, truncation: Number(v) })
}
/>
</label>
</div> </div>
<details>
<summary>其他参数(JSON 覆盖,预览展示最终值)</summary>
<TextArea
aria-label="其他参数"
value={advanced}
onChange={setAdvanced}
placeholder={'{"nanHandling":"ON"}'}
rows={3}
/>
</details>
</>
)} )}
<label> <label>
{mode === "lines" ? "表达式" : "候选 JSON"} {mode === "lines" ? "表达式" : "候选 JSON"}
@@ -797,7 +760,7 @@ export function BacktestPage({
<SideSheet <SideSheet
title={run?.name || "回测详情"} title={run?.name || "回测详情"}
visible={!!runId && drawerVisible} visible={!!runId && drawerVisible}
width={`min(60vw, calc(100vw - ${chatOffset}px))`} width={`min(max(640px, 60vw), calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
onCancel={() => { onCancel={() => {
+1
View File
@@ -28,6 +28,7 @@ export const initialSettings: SimulationSettings = {
language: "FASTEXPR", language: "FASTEXPR",
visualization: false, visualization: false,
maxTrade: "OFF", maxTrade: "OFF",
maxPosition: "OFF",
}; };
export type Candidate = { export type Candidate = {
client_item_id: string; client_item_id: string;
@@ -0,0 +1,264 @@
import {
useEffect,
useId,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import { Button, CheckboxGroup, Popover, Tooltip } from "@douyinfe/semi-ui-19";
import {
IconClose,
IconFilter,
IconSetting,
IconSort,
} from "@douyinfe/semi-icons";
import { ResearchSelect } from "../research/ResearchSelect";
import "./catalog-table-toolbar.css";
export type ColumnChoice = { key: string; label: string; required?: boolean };
/** Each catalog keeps its own columns. Ignore stale or malformed saved keys. */
export function useCatalogColumns(storageKey: string, choices: ColumnChoice[]) {
const [visible, setVisible] = useState<string[]>(() => {
try {
const saved: unknown = JSON.parse(
localStorage.getItem(storageKey) ?? "null",
);
if (Array.isArray(saved))
return choices
.filter((c) => c.required || saved.includes(c.key))
.map((c) => c.key);
} catch {
/* A storage restriction must not prevent reading the catalog. */
}
return choices.map((c) => c.key);
});
return {
choices,
visible,
onChange: (keys: string[]) => {
const next = choices
.filter((c) => c.required || keys.includes(c.key))
.map((c) => c.key);
setVisible(next);
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch {
/* Session-only preferences remain usable. */
}
},
};
}
export function CatalogIconAction({
label,
...props
}: ComponentProps<typeof Button> & { label: string }) {
return (
<Tooltip content={props.title ?? label}>
<span className="catalog-icon-action">
<Button
theme="borderless"
type="tertiary"
{...props}
aria-label={label}
/>
</span>
</Tooltip>
);
}
/** Shared interaction shell; callers own draft filters, remote sorting and pagination. */
export function CatalogTableToolbar({
label,
active = true,
filterCount = 0,
filters,
onApply,
onReset,
canApply = true,
columns,
sort,
actions,
selection,
}: {
label: string;
active?: boolean;
filterCount?: number;
filters: ReactNode;
onApply: () => void;
onReset: () => void;
canApply?: boolean;
columns: ReturnType<typeof useCatalogColumns>;
sort?: {
value: string;
direction: string;
options: { value: string; label: string }[];
onChange: (value: string, direction: string) => void;
};
actions?: ReactNode;
selection?: ReactNode;
}) {
const [open, setOpen] = useState<"filter" | "columns" | "sort" | null>(null);
const triggerId = useId();
useEffect(() => {
if (!active) setOpen(null);
}, [active]);
const close = () => {
setOpen(null);
document.getElementById(`${triggerId}-${open}`)?.focus();
};
const popup = (
kind: "filter" | "columns" | "sort",
content: ReactNode,
icon: ReactNode,
name: string,
) => (
<Popover
trigger="custom"
position="bottomLeft"
visible={active && open === kind}
onClickOutSide={() => setOpen(null)}
onEscKeyDown={close}
content={content}
>
<Button
id={`${triggerId}-${kind}`}
icon={icon}
aria-label={name}
title={name}
type={
open === kind || (kind === "filter" && filterCount)
? "primary"
: "tertiary"
}
theme={
open === kind || (kind === "filter" && filterCount)
? "light"
: "borderless"
}
onClick={() => setOpen(open === kind ? null : kind)}
>
{kind === "filter" && filterCount > 0 && (
<span className="catalog-filter-count">{filterCount}</span>
)}
</Button>
</Popover>
);
return (
<>
<div
className="catalog-table-toolbar"
role="toolbar"
aria-label={`${label}表格工具栏`}
>
<div className="catalog-toolbar-tools">
{popup(
"filter",
<form
className="catalog-filter-panel"
aria-label={`${label}筛选条件`}
onSubmit={(e) => {
e.preventDefault();
if (canApply) {
onApply();
close();
}
}}
>
<header>
<div>
<h2>筛选{label}</h2>
<p>设置条件后点击查询,应用到当前列表。</p>
</div>
<Button
icon={<IconClose aria-hidden="true" />}
theme="borderless"
type="tertiary"
aria-label="关闭筛选"
onClick={close}
/>
</header>
<div className="catalog-filter-body">{filters}</div>
<footer>
<Button
onClick={() => {
onReset();
close();
}}
>
重置
</Button>
<Button onClick={close}>取消</Button>
<Button htmlType="submit" theme="solid" disabled={!canApply}>
查询
</Button>
</footer>
</form>,
<IconFilter aria-hidden="true" />,
`筛选${label}`,
)}
{popup(
"columns",
<div className="catalog-column-panel">
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={columns.visible}
options={columns.choices.map((c) => ({
value: c.key,
label: c.label,
disabled: c.required,
}))}
onChange={(keys) => columns.onChange(keys.map(String))}
/>
<Button
theme="borderless"
onClick={() =>
columns.onChange(columns.choices.map((c) => c.key))
}
>
恢复默认列
</Button>
</div>,
<IconSetting aria-hidden="true" />,
"显示列设置",
)}
{sort ? (
popup(
"sort",
<div className="catalog-sort-panel">
<h2>排序</h2>
<ResearchSelect
label="排序字段"
value={sort.value}
optionList={sort.options}
onChange={(v) => sort.onChange(String(v), sort.direction)}
/>
<ResearchSelect
label="排序方向"
value={sort.direction}
optionList={[
{ value: "asc", label: "升序" },
{ value: "desc", label: "降序" },
]}
onChange={(v) => sort.onChange(sort.value, String(v))}
/>
</div>,
<IconSort aria-hidden="true" />,
"排序设置",
)
) : (
<CatalogIconAction
label="排序设置"
title="平台接口未提供排序,按平台返回顺序展示"
icon={<IconSort aria-hidden="true" />}
disabled
/>
)}
</div>
<div className="catalog-toolbar-actions">{actions}</div>
</div>
{selection && (
<div className="catalog-selection-actions">{selection}</div>
)}
</>
);
}
@@ -0,0 +1,128 @@
.catalog-table-toolbar {
display: flex;
align-items: center;
gap: 8px 16px;
flex-wrap: wrap;
padding: 4px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--semi-color-border);
}
.catalog-toolbar-tools,
.catalog-toolbar-actions,
.catalog-selection-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.catalog-toolbar-tools {
gap: 4px;
}
.catalog-toolbar-actions {
margin-left: auto;
}
.catalog-icon-action {
display: inline-flex;
}
.catalog-selection-actions {
padding: 4px 12px;
flex-shrink: 0;
background: var(--semi-color-primary-light-default);
border-bottom: 1px solid var(--semi-color-border);
}
.catalog-filter-count {
margin-left: 4px;
font-variant-numeric: tabular-nums;
}
.catalog-filter-panel {
display: flex;
flex-direction: column;
width: min(640px, calc(100vw - 32px));
max-height: min(700px, calc(100dvh - 120px));
color: var(--semi-color-text-0);
}
.catalog-filter-panel > header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 20px 16px;
}
.catalog-filter-panel h2,
.catalog-sort-panel h2 {
margin: 0;
font-size: 16px;
}
.catalog-filter-panel header p {
margin: 4px 0 0;
color: var(--semi-color-text-2);
font-size: 13px;
}
.catalog-filter-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 20px 20px;
}
.catalog-filter-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.catalog-filter-grid > label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.catalog-filter-grid .semi-select {
width: 100%;
}
.catalog-filter-wide {
grid-column: 1 / -1;
}
.catalog-filter-body details {
margin-top: 16px;
}
.catalog-filter-body details > summary {
cursor: pointer;
margin-bottom: 12px;
}
.catalog-filter-panel > footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--semi-color-border);
}
.catalog-filter-panel > footer > :first-child {
margin-right: auto;
}
.catalog-column-panel {
padding: 16px;
min-width: 192px;
max-height: min(480px, 60dvh);
overflow-y: auto;
}
.catalog-column-panel > strong {
display: block;
margin-bottom: 12px;
}
.catalog-sort-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) 100px;
gap: 12px;
padding: 16px;
width: min(360px, calc(100vw - 32px));
}
.catalog-sort-panel > h2 {
grid-column: 1 / -1;
}
.catalog-sort-panel .semi-select {
width: 100%;
}
@media (max-width: 600px) {
.catalog-filter-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+184 -110
View File
@@ -1,3 +1,16 @@
import {
CatalogTableToolbar,
CatalogIconAction,
useCatalogColumns,
} from "../components/CatalogTableToolbar";
import {
IconRefresh,
IconDownload,
IconAIFilledLevel3,
IconEyeOpened,
IconPlus,
} from "@douyinfe/semi-icons";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import { WorkspaceTable as Table } from "../components/WorkspaceTable"; import { WorkspaceTable as Table } from "../components/WorkspaceTable";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -76,6 +89,19 @@ export function DatasetPage({
[q, setQ] = useState(""), [q, setQ] = useState(""),
[category, setCategory] = useState(""), [category, setCategory] = useState(""),
[subcategory, setSubcategory] = useState(""); [subcategory, setSubcategory] = useState("");
const [filterDraft, setFilterDraft] = useState({
q: "",
category: "",
subcategory: "",
});
const columnSettings = useCatalogColumns("catalog-dataset-columns", [
{ key: "dataset", label: "数据集", required: true },
{ key: "category", label: "分类" },
{ key: "field_count", label: "字段数" },
{ key: "value_score", label: "价值评分" },
{ key: "status", label: "同步状态" },
{ key: "actions", label: "操作", required: true },
]);
const [sort, setSort] = useState("name"), const [sort, setSort] = useState("name"),
[direction, setDirection] = useState("asc"), [direction, setDirection] = useState("asc"),
[page, setPage] = useState(1), [page, setPage] = useState(1),
@@ -223,98 +249,7 @@ export function DatasetPage({
setNoteVersion(n.version); setNoteVersion(n.version);
Toast.success("备注已保存"); Toast.success("备注已保存");
} }
return ( const columns: ColumnProps<Entry>[] = [
<section className="preparation-page">
<ScopeControls
value={scope}
onChange={setScope}
active={active}
revision={version}
/>
{error && <Banner type="danger" description={error} />}
{copied && (
<Banner
type="success"
description={
<>
{copied.name} 已创建{" "}
<a href={`#preparations?id=${copied.id}`}>打开数据准备</a>
</>
}
/>
)}
<div className="preparation-tools">
<Input
aria-label="搜索数据集"
placeholder="搜索数据集名称或 ID"
value={q}
onChange={(v) => {
setQ(v);
setPage(1);
}}
/>
<ResearchSelect
label="分类"
placeholder="全部分类"
showClear
value={category || undefined}
optionList={Object.keys(data.categories).map((v) => ({
value: v,
label: v,
}))}
onChange={(v) => {
setCategory(String(v ?? ""));
setSubcategory("");
setPage(1);
}}
/>
<ResearchSelect
label="子分类"
placeholder="全部子分类"
showClear
value={subcategory || undefined}
optionList={(data.categories[category] ?? []).map((v) => ({
value: v,
label: v,
}))}
onChange={(v) => {
setSubcategory(String(v ?? ""));
setPage(1);
}}
/>
<ResearchSelect
label="数据集排序"
value={sort}
optionList={[
{ value: "name", label: "名称" },
{ value: "field_count", label: "字段数" },
{ value: "value_score", label: "价值评分" },
]}
onChange={(v) => {
setSort(String(v));
setPage(1);
}}
/>
<Button
onClick={() => setDirection((v) => (v === "asc" ? "desc" : "asc"))}
>
{direction === "asc" ? "升序" : "降序"}
</Button>
<Button disabled={busy} onClick={() => void work(() => sync())}>
同步目录
</Button>
<Button onClick={() => setRev((v) => v + 1)}>刷新</Button>
<Button onClick={onChat}>AI 助手</Button>
</div>
<Table<Entry>
fill
rowKey="id"
size="small"
dataSource={data.items}
pagination={false}
loading={loading}
scroll={{ x: 1180, y: "100%" }}
columns={[
{ {
title: "数据集", title: "数据集",
width: 280, width: 280,
@@ -344,35 +279,174 @@ export function DatasetPage({
}, },
{ {
title: "操作", title: "操作",
width: 240, width: 140,
render: (_, r) => ( render: (_, r) => (
<div className="workspace-cell-actions"> <div className="workspace-cell-actions">
<Button <CatalogIconAction
theme="borderless" label="查看"
icon={<IconEyeOpened aria-hidden="true" />}
disabled={busy} disabled={busy}
onClick={() => void work(() => view(r))} onClick={() => void work(() => view(r))}
> />
查看 <CatalogIconAction
</Button> label="同步"
<Button icon={<IconDownload aria-hidden="true" />}
theme="borderless"
disabled={busy} disabled={busy}
onClick={() => void work(() => sync(r.id))} onClick={() => void work(() => sync(r.id))}
> />
同步 <CatalogIconAction
</Button> label="使用"
<Button icon={<IconPlus aria-hidden="true" />}
theme="borderless"
disabled={busy || !r.collection_version} disabled={busy || !r.collection_version}
title={!r.collection_version ? "请先同步全部字段" : undefined} title={!r.collection_version ? "请先同步全部字段" : "使用"}
onClick={() => void work(() => use(r))} onClick={() => void work(() => use(r))}
> />
使用
</Button>
</div> </div>
), ),
}, },
]} ];
const visibleColumns = columns.filter((_, index) =>
columnSettings.visible.includes(columnSettings.choices[index].key),
);
return (
<section className="preparation-page">
<ScopeControls
value={scope}
onChange={setScope}
active={active}
revision={version}
/>
{error && <Banner type="danger" description={error} />}
{copied && (
<Banner
type="success"
description={
<>
{copied.name} 已创建{" "}
<a href={`#preparations?id=${copied.id}`}>打开数据准备</a>
</>
}
/>
)}
<CatalogTableToolbar
label="数据目录"
active={active && !suspended && !detail}
filterCount={[q, category, subcategory].filter(Boolean).length}
columns={columnSettings}
filters={
<div className="catalog-filter-grid">
<label className="catalog-filter-wide">
搜索
<Input
aria-label="搜索数据集"
placeholder="搜索数据集名称或 ID"
value={filterDraft.q}
onChange={(q) => setFilterDraft({ ...filterDraft, q })}
showClear
/>
</label>
<label>
分类
<ResearchSelect
label="分类"
placeholder="全部分类"
showClear
value={filterDraft.category || undefined}
optionList={Object.keys(data.categories).map((v) => ({
value: v,
label: v,
}))}
onChange={(v) =>
setFilterDraft({
...filterDraft,
category: String(v ?? ""),
subcategory: "",
})
}
/>
</label>
<label>
子分类
<ResearchSelect
label="子分类"
placeholder="全部子分类"
showClear
value={filterDraft.subcategory || undefined}
optionList={(data.categories[filterDraft.category] ?? []).map(
(v) => ({ value: v, label: v }),
)}
onChange={(v) =>
setFilterDraft({
...filterDraft,
subcategory: String(v ?? ""),
})
}
/>
</label>
</div>
}
onApply={() => {
setQ(filterDraft.q);
setCategory(filterDraft.category);
setSubcategory(filterDraft.subcategory);
setPage(1);
}}
onReset={() => {
setFilterDraft({ q: "", category: "", subcategory: "" });
setQ("");
setCategory("");
setSubcategory("");
setPage(1);
}}
sort={{
value: sort,
direction,
options: [
{ value: "name", label: "名称" },
{ value: "field_count", label: "字段数" },
{ value: "value_score", label: "价值评分" },
],
onChange: (value, dir) => {
setSort(value);
setDirection(dir);
setPage(1);
},
}}
actions={
<>
<CatalogIconAction
label="刷新"
icon={<IconRefresh aria-hidden="true" />}
onClick={() => setRev((v) => v + 1)}
/>
<CatalogIconAction
label="AI 助手"
icon={<IconAIFilledLevel3 aria-hidden="true" />}
onClick={onChat}
/>
<Button
theme="solid"
icon={<IconDownload aria-hidden="true" />}
disabled={busy}
onClick={() => void work(() => sync())}
>
同步目录
</Button>
</>
}
/>
<Table<Entry>
fill
rowKey="id"
size="small"
dataSource={data.items}
pagination={false}
loading={loading}
scroll={{
x: visibleColumns.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
y: "100%",
}}
columns={visibleColumns}
/> />
<div className="table-pagination workspace-table-footer"> <div className="table-pagination workspace-table-footer">
<span> <span>
+195 -89
View File
@@ -1,7 +1,22 @@
import {
CatalogTableToolbar,
CatalogIconAction,
useCatalogColumns,
} from "../components/CatalogTableToolbar";
import {
IconPlus,
IconRefresh,
IconDelete,
IconCopy,
IconEyeOpened,
IconClose,
} from "@douyinfe/semi-icons";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table";
import { WorkspaceTable as Table } from "../components/WorkspaceTable"; import { WorkspaceTable as Table } from "../components/WorkspaceTable";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Banner, Banner,
Checkbox,
Button, Button,
Input, Input,
Modal, Modal,
@@ -39,6 +54,21 @@ export function DataPreparationPage({ active }: { active: boolean }) {
const [selected, setSelected] = useState<Record<string, Preparation>>({}), const [selected, setSelected] = useState<Record<string, Preparation>>({}),
[scope, setScope] = useState(defaultScope), [scope, setScope] = useState(defaultScope),
[scopeFilter, setScopeFilter] = useState(false); [scopeFilter, setScopeFilter] = useState(false);
const [draftQ, setDraftQ] = useState("");
const [filterScope, setFilterScope] = useState(defaultScope);
const [draftScope, setDraftScope] = useState(defaultScope);
const [draftScopeFilter, setDraftScopeFilter] = useState(false);
const [sort, setSort] = useState("updated_at");
const [direction, setDirection] = useState("desc");
const columnSettings = useCatalogColumns("catalog-preparations-columns", [
{ key: "name", label: "集合名称", required: true },
{ key: "scope", label: "范围" },
{ key: "field_count", label: "字段数" },
{ key: "dataset_count", label: "数据集数" },
{ key: "updated_at", label: "更新时间" },
{ key: "actions", label: "操作", required: true },
]);
const [scopeValid, setScopeValid] = useState(false);
const [detail, setDetail] = useState<Preparation | null>(null), const [detail, setDetail] = useState<Preparation | null>(null),
[creating, setCreating] = useState(false), [creating, setCreating] = useState(false),
[name, setName] = useState(""), [name, setName] = useState(""),
@@ -61,7 +91,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
if (!active) return; if (!active) return;
const c = new AbortController(); const c = new AbortController();
api<Page<Preparation>>( api<Page<Preparation>>(
`/data-preparations?${queryString({ q, limit: pageSize, offset: (page - 1) * pageSize, scope_key: scopeFilter ? scopeKey(scope) : undefined })}`, `/data-preparations?${queryString({ q, limit: pageSize, offset: (page - 1) * pageSize, sort, direction, scope_key: scopeFilter ? scopeKey(filterScope) : undefined })}`,
{ signal: c.signal }, { signal: c.signal },
) )
.then((r) => { .then((r) => {
@@ -72,7 +102,17 @@ export function DataPreparationPage({ active }: { active: boolean }) {
if (!c.signal.aborted) setError(e.message); if (!c.signal.aborted) setError(e.message);
}); });
return () => c.abort(); return () => c.abort();
}, [active, q, page, pageSize, rev, scopeFilter, scopeKey(scope)]); }, [
active,
q,
page,
pageSize,
rev,
sort,
direction,
scopeFilter,
scopeKey(filterScope),
]);
useEffect(() => { useEffect(() => {
if (!detail) return; if (!detail) return;
const c = new AbortController(); const c = new AbortController();
@@ -119,76 +159,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
), ),
); );
}, [active]); }, [active]);
return ( const columns: ColumnProps<Preparation>[] = [
<section className="preparation-page">
{error && <Banner type="danger" description={error} />}
<div className="preparation-tools">
<Input
aria-label="搜索准备集合"
placeholder="搜索名称或备注"
value={q}
onChange={(v) => {
setQ(v);
setPage(1);
}}
/>
<Button
theme="solid"
onClick={() => {
setCreating(true);
setName("新数据准备集合");
setNote("");
}}
>
新建集合
</Button>
<Button
onClick={() => {
setScopeFilter((v) => !v);
setPage(1);
}}
>
{scopeFilter ? "全部范围" : "按范围筛选"}
</Button>
<Button
type="danger"
disabled={!Object.keys(selected).length}
onClick={() => setDeleting(Object.values(selected))}
>
删除所选
</Button>
</div>
{scopeFilter && (
<ScopeControls
local
value={scope}
onChange={(s) => {
setScope(s);
setPage(1);
setSelected({});
}}
/>
)}
<Table<Preparation>
fill
rowKey="id"
size="small"
dataSource={data.items}
pagination={false}
scroll={{ x: 1140, y: "100%" }}
rowSelection={{
selectedRowKeys: Object.keys(selected),
onChange: (keys) =>
setSelected((old) => {
const n: Record<string, Preparation> = {};
for (const k of keys ?? []) {
const r = data.items.find((x) => x.id === k) ?? old[k];
if (r) n[k] = r;
}
return n;
}),
}}
columns={[
{ {
title: "集合名称", title: "集合名称",
width: 260, width: 260,
@@ -214,14 +185,17 @@ export function DataPreparationPage({ active }: { active: boolean }) {
}, },
{ {
title: "操作", title: "操作",
width: 200, width: 140,
render: (_, r) => ( render: (_, r) => (
<div className="workspace-cell-actions"> <div className="workspace-cell-actions">
<Button theme="borderless" onClick={() => open(r)}> <CatalogIconAction
查看 label="查看"
</Button> icon={<IconEyeOpened aria-hidden="true" />}
<Button onClick={() => open(r)}
theme="borderless" />
<CatalogIconAction
label="复制"
icon={<IconCopy aria-hidden="true" />}
disabled={busy} disabled={busy}
onClick={() => onClick={() =>
void work(async () => { void work(async () => {
@@ -232,20 +206,147 @@ export function DataPreparationPage({ active }: { active: boolean }) {
open(copy); open(copy);
}) })
} }
> />
复制 <CatalogIconAction
</Button> label="删除"
<Button icon={<IconDelete aria-hidden="true" />}
theme="borderless"
type="danger" type="danger"
onClick={() => setDeleting([r])} onClick={() => setDeleting([r])}
> />
删除
</Button>
</div> </div>
), ),
}, },
]} ];
const visibleColumns = columns.filter((_, i) =>
columnSettings.visible.includes(columnSettings.choices[i].key),
);
return (
<section className="preparation-page">
{error && <Banner type="danger" description={error} />}
<CatalogTableToolbar
label="数据准备"
active={active && !creating && !detail && !deleting.length}
columns={columnSettings}
filterCount={Number(Boolean(q)) + Number(scopeFilter)}
filters={
<div className="catalog-filter-grid">
<label className="catalog-filter-wide">
搜索
<Input
aria-label="搜索准备集合"
placeholder="搜索名称或备注"
value={draftQ}
onChange={setDraftQ}
/>
</label>
<div className="catalog-filter-wide">
<Checkbox
checked={draftScopeFilter}
onChange={(e) => setDraftScopeFilter(Boolean(e.target.checked))}
>
按范围筛选
</Checkbox>
{draftScopeFilter && (
<ScopeControls
local
value={draftScope}
onChange={setDraftScope}
/>
)}
</div>
</div>
}
onApply={() => {
setQ(draftQ);
setScopeFilter(draftScopeFilter);
setFilterScope(draftScope);
setPage(1);
setSelected({});
}}
onReset={() => {
setQ("");
setDraftQ("");
setScopeFilter(false);
setDraftScopeFilter(false);
setPage(1);
setSelected({});
}}
sort={{
value: sort,
direction,
options: [
{ value: "name", label: "集合名称" },
{ value: "created_at", label: "创建时间" },
{ value: "updated_at", label: "更新时间" },
],
onChange: (v, d) => {
setSort(v);
setDirection(d);
setPage(1);
},
}}
actions={
<>
<CatalogIconAction
label="刷新"
icon={<IconRefresh aria-hidden="true" />}
onClick={() => setRev((v) => v + 1)}
/>
<Button
icon={<IconPlus aria-hidden="true" />}
theme="solid"
onClick={() => {
setCreating(true);
setName("新数据准备集合");
setNote("");
}}
>
新建集合
</Button>
</>
}
selection={
Object.keys(selected).length > 0 ? (
<>
<span>已选择 {Object.keys(selected).length} 个集合</span>
<CatalogIconAction
label="删除所选"
icon={<IconDelete aria-hidden="true" />}
type="danger"
onClick={() => setDeleting(Object.values(selected))}
/>
<CatalogIconAction
label="清空选择"
icon={<IconClose aria-hidden="true" />}
onClick={() => setSelected({})}
/>
</>
) : undefined
}
/>
<Table<Preparation>
fill
rowKey="id"
size="small"
dataSource={data.items}
pagination={false}
scroll={{
x: visibleColumns.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
y: "100%",
}}
rowSelection={{
selectedRowKeys: Object.keys(selected),
onChange: (keys) =>
setSelected((old) => {
const n: Record<string, Preparation> = {};
for (const k of keys ?? []) {
const r = data.items.find((x) => x.id === k) ?? old[k];
if (r) n[k] = r;
}
return n;
}),
}}
columns={visibleColumns}
/> />
<footer className="table-pagination workspace-table-footer"> <footer className="table-pagination workspace-table-footer">
<span>共 {data.total} 个集合</span> <span>共 {data.total} 个集合</span>
@@ -268,7 +369,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
closeOnEsc closeOnEsc
onCancel={() => setCreating(false)} onCancel={() => setCreating(false)}
confirmLoading={busy} confirmLoading={busy}
okButtonProps={{ disabled: !name.trim() || busy }} okButtonProps={{ disabled: !name.trim() || busy || !scopeValid }}
onOk={() => onOk={() =>
work(async () => { work(async () => {
const row = await post<Preparation>("/data-preparations", { const row = await post<Preparation>("/data-preparations", {
@@ -283,7 +384,12 @@ export function DataPreparationPage({ active }: { active: boolean }) {
> >
{error && <Banner type="danger" description={error} />} {error && <Banner type="danger" description={error} />}
<Input aria-label="集合名称" value={name} onChange={setName} /> <Input aria-label="集合名称" value={name} onChange={setName} />
<ScopeControls value={scope} onChange={setScope} /> <ScopeControls
value={scope}
onChange={setScope}
active={creating && active}
onValidityChange={setScopeValid}
/>
<TextArea aria-label="集合备注" value={note} onChange={setNote} /> <TextArea aria-label="集合备注" value={note} onChange={setNote} />
</Modal> </Modal>
<Modal <Modal
+135 -78
View File
@@ -1,3 +1,9 @@
import {
CatalogTableToolbar,
CatalogIconAction,
useCatalogColumns,
} from "../components/CatalogTableToolbar";
import { IconPlus, IconRefresh, IconClose } from "@douyinfe/semi-icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Banner, Banner,
@@ -13,7 +19,7 @@ import { api, patch, post, queryString } from "../api";
import { ResearchSelect } from "../research/ResearchSelect"; import { ResearchSelect } from "../research/ResearchSelect";
import { ScopeControls } from "./ScopeControls"; import { ScopeControls } from "./ScopeControls";
import { DataPreparationPicker } from "./DataPreparationPicker"; import { DataPreparationPicker } from "./DataPreparationPicker";
import { FieldTable } from "./FieldTable"; import { FieldTable, fieldColumnChoices } from "./FieldTable";
import { import {
defaultScope, defaultScope,
fieldReference, fieldReference,
@@ -118,8 +124,13 @@ export function FieldBrowser({
[queriedScope, setQueriedScope] = useState<string | null>(null); [queriedScope, setQueriedScope] = useState<string | null>(null);
const [page, setPage] = useState(1), const [page, setPage] = useState(1),
[size, setSize] = useState(25), [size, setSize] = useState(25),
[refresh, setRefresh] = useState(0), [refresh, setRefresh] = useState(0);
[advanced, setAdvanced] = useState(false); const [sort, setSort] = useState("id"),
[direction, setDirection] = useState("asc");
const columnSettings = useCatalogColumns(
`catalog-fields-${source}-columns`,
fieldColumnChoices,
);
const [data, setData] = useState<Page<FieldRecord>>({ const [data, setData] = useState<Page<FieldRecord>>({
items: [], items: [],
total: 0, total: 0,
@@ -160,7 +171,7 @@ export function FieldBrowser({
setLoading(true); setLoading(true);
setError(""); setError("");
api<Page<FieldRecord>>( api<Page<FieldRecord>>(
`/catalog/${source === "local" ? "fields" : "worldquant/fields"}?${queryString({ ...actual, ...filters, limit: size, offset: (page - 1) * size })}`, `/catalog/${source === "local" ? "fields" : "worldquant/fields"}?${queryString({ ...actual, ...filters, ...(source === "local" ? { sort, direction } : {}), limit: size, offset: (page - 1) * size })}`,
{ signal: c.signal }, { signal: c.signal },
) )
.then((r) => { .then((r) => {
@@ -181,7 +192,19 @@ export function FieldBrowser({
if (!c.signal.aborted) setLoading(false); if (!c.signal.aborted) setLoading(false);
}); });
return () => c.abort(); return () => c.abort();
}, [active, source, key, filters, page, size, refresh, revision, canRead]); }, [
active,
source,
key,
filters,
sort,
direction,
page,
size,
refresh,
revision,
canRead,
]);
const update = (k: string, v: string) => const update = (k: string, v: string) =>
setDraft((old) => ({ ...old, [k]: v })); setDraft((old) => ({ ...old, [k]: v }));
return ( return (
@@ -195,33 +218,13 @@ export function FieldBrowser({
revision={revision} revision={revision}
/> />
)} )}
<div className="preparation-tools"> <CatalogTableToolbar
<Input label="字段目录"
aria-label="搜索字段" active={active && !adding}
placeholder="字段 ID、名称或描述" columns={columnSettings}
value={draft.q ?? ""} filterCount={Object.values(filters).filter(Boolean).length}
onChange={(v) => update("q", v)} canApply={source === "local" || (hasCriteria && !loading)}
/> onApply={() => {
<Input
aria-label="字段数据集"
placeholder="数据集 ID"
value={draft.dataset_id ?? ""}
onChange={(v) => update("dataset_id", v)}
/>
<ResearchSelect
label="字段类型"
placeholder="全部类型"
showClear
value={draft.field_type || undefined}
optionList={["MATRIX", "VECTOR", "GROUP"].map((v) => ({
label: v,
value: v,
}))}
onChange={(v) => update("field_type", String(v ?? ""))}
/>
<Button
disabled={source === "worldquant" && (!hasCriteria || loading)}
onClick={() => {
setQueriedScope(key); setQueriedScope(key);
setFilters({ setFilters({
...draft, ...draft,
@@ -234,13 +237,9 @@ export function FieldBrowser({
}); });
setPage(1); setPage(1);
setRefresh((v) => v + 1); setRefresh((v) => v + 1);
setSelected({});
}} }}
> onReset={() => {
查询
</Button>
<Button onClick={() => setAdvanced((v) => !v)}>更多筛选</Button>
<Button
onClick={() => {
setDraft({}); setDraft({});
setFilters({}); setFilters({});
setQueriedScope(null); setQueriedScope(null);
@@ -251,12 +250,46 @@ export function FieldBrowser({
setData({ items: [], total: 0, limit: size, offset: 0 }); setData({ items: [], total: 0, limit: size, offset: 0 });
setPage(1); setPage(1);
}} }}
> filters={
重置 <>
</Button> <div className="catalog-filter-grid">
<label className="catalog-filter-wide">
搜索
<Input
aria-label="搜索字段"
placeholder="字段 ID、名称或描述"
value={draft.q ?? ""}
onChange={(v) => update("q", v)}
showClear
/>
</label>
<label>
数据集
<Input
aria-label="字段数据集"
placeholder="数据集 ID"
value={draft.dataset_id ?? ""}
onChange={(v) => update("dataset_id", v)}
/>
</label>
<label>
类型
<ResearchSelect
label="字段类型"
placeholder="全部类型"
showClear
value={draft.field_type || undefined}
optionList={["MATRIX", "VECTOR", "GROUP"].map((v) => ({
label: v,
value: v,
}))}
onChange={(v) => update("field_type", String(v ?? ""))}
/>
</label>
</div> </div>
{advanced && ( <details>
<div className="preparation-filters"> <summary>更多筛选</summary>
<div className="catalog-filter-grid">
{source === "local" && {source === "local" &&
["category", "subcategory"].map((k, i) => ( ["category", "subcategory"].map((k, i) => (
<label key={k}> <label key={k}>
@@ -297,6 +330,7 @@ export function FieldBrowser({
<label> <label>
同步起始时间 同步起始时间
<input <input
aria-label="同步起始时间"
type="datetime-local" type="datetime-local"
value={draft.synced_from ?? ""} value={draft.synced_from ?? ""}
onChange={(e) => update("synced_from", e.target.value)} onChange={(e) => update("synced_from", e.target.value)}
@@ -305,47 +339,51 @@ export function FieldBrowser({
<label> <label>
同步结束时间 同步结束时间
<input <input
aria-label="同步结束时间"
type="datetime-local" type="datetime-local"
value={draft.synced_to ?? ""} value={draft.synced_to ?? ""}
onChange={(e) => update("synced_to", e.target.value)} onChange={(e) => update("synced_to", e.target.value)}
/> />
</label> </label>
<ResearchSelect
label="字段排序"
value={draft.sort ?? "id"}
optionList={[
"id",
"name",
"dataset_id",
"coverage",
"user_count",
"alpha_count",
"synced_at",
].map((v) => ({ value: v, label: v }))}
onChange={(v) => update("sort", String(v))}
/>
<ResearchSelect
label="排序方向"
value={draft.direction ?? "asc"}
optionList={[
{ value: "asc", label: "升序" },
{ value: "desc", label: "降序" },
]}
onChange={(v) => update("direction", String(v))}
/>
</> </>
)} )}
</div> </div>
)} </details>
{error && <Banner type="danger" description={error} />} </>
{source === "worldquant" && !canRead && ( }
<p className="preparation-query-hint"> sort={
请输入关键词、数据集或其他筛选条件,然后点击查询。 source === "local"
</p> ? {
)} value: sort,
<div className="preparation-tools"> direction,
options: [
{ value: "id", label: "字段 ID" },
{ value: "name", label: "名称" },
{ value: "dataset_id", label: "数据集" },
{ value: "coverage", label: "覆盖率" },
{ value: "user_count", label: "用户数" },
{ value: "alpha_count", label: "Alpha 数" },
{ value: "synced_at", label: "同步时间" },
],
onChange: (value, dir) => {
setSort(value);
setDirection(dir);
setPage(1);
},
}
: undefined
}
actions={
<>
<CatalogIconAction
label="刷新字段"
icon={<IconRefresh aria-hidden="true" />}
disabled={!canRead || loading}
onClick={() => setRefresh((v) => v + 1)}
/>
<Button <Button
theme="solid" theme="solid"
icon={<IconPlus aria-hidden="true" />}
disabled={!Object.keys(selected).length || loading || !!error} disabled={!Object.keys(selected).length || loading || !!error}
onClick={() => onClick={() =>
onAdd ? onAdd(Object.values(selected)) : setAdding(true) onAdd ? onAdd(Object.values(selected)) : setAdding(true)
@@ -353,12 +391,31 @@ export function FieldBrowser({
> >
加入数据准备 加入数据准备
</Button> </Button>
<span>已选 {Object.keys(selected).length} 个字段 · 表头选择当前页</span> </>
{!!Object.keys(selected).length && ( }
<Button onClick={() => setSelected({})}>清空选择</Button> selection={
Object.keys(selected).length ? (
<>
<span>
已选 {Object.keys(selected).length} 个字段 · 表头选择当前页
</span>
<CatalogIconAction
label="清空选择"
icon={<IconClose aria-hidden="true" />}
onClick={() => setSelected({})}
/>
</>
) : undefined
}
/>
{error && <Banner type="danger" description={error} />}
{source === "worldquant" && !canRead && (
<p className="preparation-query-hint">
请输入关键词、数据集或其他筛选条件,然后点击查询。
</p>
)} )}
</div>
<FieldTable <FieldTable
visibleColumns={columnSettings.visible}
fill={!fixedScope} fill={!fixedScope}
items={data.items} items={data.items}
loading={loading} loading={loading}
+21 -2
View File
@@ -3,6 +3,16 @@ import { Button, Tag } from "@douyinfe/semi-ui-19";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface"; import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import { displayValue, formatTime } from "../api"; import { displayValue, formatTime } from "../api";
import type { FieldRecord } from "./types"; import type { FieldRecord } from "./types";
export const fieldColumnChoices = [
{ key: "id", label: "字段", required: true },
{ key: "dataset_id", label: "数据集" },
{ key: "description", label: "字段描述" },
{ key: "field_type", label: "类型" },
{ key: "coverage", label: "覆盖率" },
{ key: "user_count", label: "用户数" },
{ key: "alpha_count", label: "Alpha 数" },
{ key: "synced_at", label: "同步时间" },
];
export function FieldTable({ export function FieldTable({
items, items,
loading = false, loading = false,
@@ -10,10 +20,12 @@ export function FieldTable({
selected, selected,
onSelect, onSelect,
onOpen, onOpen,
visibleColumns,
}: { }: {
items: FieldRecord[]; items: FieldRecord[];
loading?: boolean; loading?: boolean;
fill?: boolean; fill?: boolean;
visibleColumns?: string[];
onOpen?: (field: FieldRecord) => void; onOpen?: (field: FieldRecord) => void;
selected?: string[]; selected?: string[];
onSelect?: (ids: string[]) => void; onSelect?: (ids: string[]) => void;
@@ -78,6 +90,10 @@ export function FieldTable({
), ),
}, },
]; ];
const shown = columns.filter(
(_, index) =>
!visibleColumns || visibleColumns.includes(fieldColumnChoices[index].key),
);
return ( return (
<Table<FieldRecord> <Table<FieldRecord>
fill={fill} fill={fill}
@@ -85,10 +101,13 @@ export function FieldTable({
className="preparation-table" className="preparation-table"
rowKey={(r) => `${r?.dataset_id}|${r?.id}`} rowKey={(r) => `${r?.dataset_id}|${r?.id}`}
dataSource={items} dataSource={items}
columns={columns} columns={shown}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 1420, y: fill ? "100%" : 400 }} scroll={{
x: shown.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
y: fill ? "100%" : 400,
}}
rowSelection={ rowSelection={
onSelect onSelect
? { ? {
+2 -132
View File
@@ -1,132 +1,2 @@
import { useEffect, useState } from "react"; // Keep directory callers on one implementation while preserving their source/revision contract.
import { Banner, Button } from "@douyinfe/semi-ui-19"; export { ScopePicker as ScopeControls } from "../settings/ScopePicker";
import { api } from "../api";
import { ResearchSelect } from "../research/ResearchSelect";
import { type Scope } from "./types";
type Option = Omit<Scope, "universe"> & { universes: string[] };
export function ScopeControls({
value,
onChange,
local = false,
active = true,
revision: sourceRevision = "",
}: {
value: Scope;
onChange: (s: Scope) => void;
local?: boolean;
active?: boolean;
revision?: string;
}) {
const [rows, setRows] = useState<Option[]>([]),
[error, setError] = useState(""),
[revision, setRevision] = useState(0);
useEffect(() => {
if (!active) return;
const c = new AbortController();
api<{ instrument_options: Option[] }>(
local ? "/catalog/local-scopes" : "/catalog/scopes",
{ signal: c.signal },
)
.then((r) => {
setRows(r.instrument_options);
setError("");
})
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [local, revision, sourceRevision, active]);
function update(next: Scope) {
const row =
rows.find(
(r) =>
r.instrument_type === next.instrument_type &&
r.region === next.region &&
r.delay === next.delay,
) ??
rows.find(
(r) =>
r.instrument_type === next.instrument_type &&
r.region === next.region,
) ??
rows.find((r) => r.instrument_type === next.instrument_type) ??
rows[0];
onChange(
row
? {
...next,
instrument_type: row.instrument_type,
region: row.region,
delay: row.delay,
universe: row.universes.includes(next.universe)
? next.universe
: row.universes[0],
}
: next,
);
}
const option = rows.find(
(r) =>
r.instrument_type === value.instrument_type &&
r.region === value.region &&
r.delay === value.delay,
);
return (
<>
<div className="preparation-tools">
{(["instrument_type", "region", "universe", "delay"] as const).map(
(key) => {
const values =
key === "universe"
? (option?.universes ?? [value.universe])
: rows
.filter(
(r) =>
key === "instrument_type" ||
(r.instrument_type === value.instrument_type &&
(key !== "delay" || r.region === value.region)),
)
.map((r) => r[key]);
return (
<ResearchSelect
key={key}
label={
{
instrument_type: "InstrumentType",
region: "Region",
universe: "Universe",
delay: "Delay",
}[key]
}
value={value[key]}
optionList={[...new Set([value[key], ...values])].map((v) => ({
value: v,
label: key === "delay" ? `Delay ${v}` : String(v),
}))}
onChange={(v) =>
update({
...value,
[key]: key === "delay" ? Number(v) : String(v),
})
}
/>
);
},
)}
</div>
{error && (
<Banner
type="warning"
description={
<>
{error}{" "}
<Button onClick={() => setRevision((v) => v + 1)}>
重试范围选项
</Button>
</>
}
/>
)}
</>
);
}
+23 -56
View File
@@ -1,3 +1,6 @@
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions";
import { initialSettings } from "../backtests/types";
import { import {
ResearchDataInput, ResearchDataInput,
researchSelection, researchSelection,
@@ -40,21 +43,9 @@ export function FlowLaunchForm({
const [seed, setSeed] = useState(0); const [seed, setSeed] = useState(0);
const [confirmation, setConfirmation] = useState<FlowLaunch | null>(null); const [confirmation, setConfirmation] = useState<FlowLaunch | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [settings, setSettings] = useState<SimulationSettings>({ const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
instrumentType: "EQUITY", const options = useSettingsOptions();
region: "USA", const [settingsValid, setSettingsValid] = useState(false);
universe: "TOP3000",
delay: 1,
decay: 0,
neutralization: "INDUSTRY",
truncation: 0.08,
pasteurization: "ON",
unitHandling: "VERIFY",
nanHandling: "OFF",
language: "FASTEXPR",
visualization: false,
maxTrade: "OFF",
});
useEffect(() => { useEffect(() => {
const c = new AbortController(); const c = new AbortController();
Promise.all([ Promise.all([
@@ -169,47 +160,17 @@ export function FlowLaunchForm({
onChange={(v) => setTemplate(v ? String(v) : undefined)} onChange={(v) => setTemplate(v ? String(v) : undefined)}
/> />
</label> </label>
<p> <SimulationSettingsEditor
{settings.region} / {settings.universe} / D{settings.delay} · REGULAR / value={settings}
FASTEXPR / EQUITY onChange={setSettings}
</p> {...options}
<div className="research-form-grid"> onRefresh={() => void options.refresh()}
<label> fixedScopes={inputs
中性化 .filter((r) => ids.includes(r.id))
<Input .map((r) => r.scope)}
aria-label="自动研究中性化" disabled={busy}
value={settings.neutralization} onValidityChange={setSettingsValid}
onChange={(neutralization) =>
setSettings({ ...settings, neutralization })
}
/> />
</label>
<label>
Decay
<InputNumber
aria-label="自动研究 Decay"
min={0}
value={settings.decay}
onChange={(v) => {
if (typeof v === "number") setSettings({ ...settings, decay: v });
}}
/>
</label>
<label>
Truncation
<InputNumber
aria-label="自动研究 Truncation"
min={0}
max={1}
step={0.01}
value={settings.truncation}
onChange={(v) => {
if (typeof v === "number")
setSettings({ ...settings, truncation: v });
}}
/>
</label>
</div>
<h4>本次授权预算</h4> <h4>本次授权预算</h4>
<div className="research-form-grid"> <div className="research-form-grid">
{( {(
@@ -263,7 +224,13 @@ export function FlowLaunchForm({
/> />
<Button <Button
theme="solid" theme="solid"
disabled={!ids.length || !hypothesis.trim() || !name.trim()} disabled={
!ids.length ||
!hypothesis.trim() ||
!name.trim() ||
!settingsValid ||
busy
}
onClick={preview} onClick={preview}
> >
核对并启动研究 核对并启动研究
+19 -102
View File
@@ -1,3 +1,5 @@
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions";
import { import {
ResearchDataInput, ResearchDataInput,
researchSelection, researchSelection,
@@ -67,7 +69,8 @@ export function ResearchWorkspace({
const [parent, setParent] = useState(""); const [parent, setParent] = useState("");
const [method, setMethod] = useState("structure"); const [method, setMethod] = useState("structure");
const [settings, setSettings] = useState<SimulationSettings>(initialSettings); const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
const [neutralizations, setNeutralizations] = useState<string[]>([]); const options = useSettingsOptions(active && detailOpen);
const [settingsValid, setSettingsValid] = useState(false);
const [mode, setMode] = useState("all"); const [mode, setMode] = useState("all");
const [limit, setLimit] = useState(100); const [limit, setLimit] = useState(100);
const [seed, setSeed] = useState(0); const [seed, setSeed] = useState(0);
@@ -188,28 +191,6 @@ export function ResearchWorkspace({
setMethod("structure"); setMethod("structure");
} }
}, [active, action]); }, [active, action]);
async function refreshSettings() {
const data = await post<{
content: {
items: {
region: string;
universe: string;
delay: number;
neutralizations: string[];
}[];
};
}>("/catalog/setting-options/refresh");
const options = data.content.items
.filter(
(row) =>
row.region === settings.region &&
row.universe === settings.universe &&
row.delay === settings.delay,
)
.flatMap((row) => row.neutralizations);
setNeutralizations([...new Set(options)]);
Toast.success("合法设置已同步");
}
async function save() { async function save() {
const next = asset const next = asset
? await api<Asset>(`/research/assets/${asset.id}`, { ? await api<Asset>(`/research/assets/${asset.id}`, {
@@ -759,88 +740,24 @@ export function ResearchWorkspace({
/> />
</label> </label>
</div> </div>
<div className="research-section-heading"> <SimulationSettingsEditor
<h4>模拟设置</h4> value={settings}
<Button onChange={setSettings}
disabled={!inputIds.length || !!busy} {...options}
onClick={() => void task("同步合法设置", refreshSettings)} onRefresh={() => void options.refresh()}
> fixedScopes={selectedInputs.map((r) => r.scope)}
同步合法设置 disabled={!!busy}
</Button> onValidityChange={setSettingsValid}
</div>
<p>
{settings.region || "未选择市场"} /{" "}
{settings.universe || "未选择股票池"} / D{settings.delay}
(来自固定输入)
</p>
<div className="research-form-grid">
<label>
中性化
<Input
list={`${page}-neutralizations`}
aria-label="中性化"
value={settings.neutralization}
onChange={(neutralization) =>
setSettings({ ...settings, neutralization })
}
/> />
<datalist id={`${page}-neutralizations`}>
{neutralizations.map((value) => (
<option key={value} value={value} />
))}
</datalist>
</label>
<label>
Decay
<InputNumber
aria-label="Decay"
min={0}
value={settings.decay}
onChange={(decay) =>
setSettings({ ...settings, decay: Number(decay) })
}
/>
</label>
<label>
Truncation
<InputNumber
aria-label="Truncation"
min={0}
max={1}
step={0.01}
value={settings.truncation}
onChange={(truncation) =>
setSettings({
...settings,
truncation: Number(truncation),
})
}
/>
</label>
</div>
<div className="research-form-grid">
{(["pasteurization", "nanHandling", "maxTrade"] as const).map(
(key) => (
<label key={key}>
{key}
<ResearchSelect
label={key}
value={settings[key]}
optionList={["ON", "OFF"].map((value) => ({
value,
label: value,
}))}
onChange={(value) =>
setSettings({ ...settings, [key]: value })
}
/>
</label>
),
)}
</div>
<Button <Button
theme="solid" theme="solid"
disabled={dirty || !inputIds.length || !hypothesis || !!busy} disabled={
dirty ||
!inputIds.length ||
!hypothesis ||
!!busy ||
!settingsValid
}
onClick={() => void task("生成候选", expand)} onClick={() => void task("生成候选", expand)}
> >
保存候选研究记录 保存候选研究记录
+222
View File
@@ -0,0 +1,222 @@
import { useEffect, useId, useState } from "react";
import { Banner, Button } from "@douyinfe/semi-ui-19";
import { api } from "../api";
import { ResearchSelect } from "../research/ResearchSelect";
import type { Scope } from "../preparations/types";
import {
changeScope,
scopeFields,
scopeLabels,
scopeValues,
validScope,
type ScopeOption,
} from "./model";
import "./style.css";
/** Edit a scope transactionally. Supplied options bypass network reads (fixed simulation metadata). */
export function ScopePicker({
value,
onChange,
local = false,
active = true,
revision = "",
options,
readOnly = false,
disabled = false,
onValidityChange,
}: {
value: Scope;
onChange: (scope: Scope) => void;
local?: boolean;
active?: boolean;
revision?: string;
options?: ScopeOption[];
readOnly?: boolean;
disabled?: boolean;
onValidityChange?: (valid: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<Partial<Scope>>(value);
const [rows, setRows] = useState<ScopeOption[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const id = useId();
const choices = options ?? rows;
useEffect(() => {
if (!active || options || readOnly) return;
const c = new AbortController();
setRows([]);
setLoading(true);
setError("");
api<{ instrument_options: ScopeOption[] }>(
local ? "/catalog/local-scopes" : "/catalog/scopes",
{ signal: c.signal },
)
.then((result) => {
if (!c.signal.aborted) setRows(result.instrument_options);
})
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
})
.finally(() => {
if (!c.signal.aborted) setLoading(false);
});
return () => c.abort();
}, [active, local, revision, retry, options, readOnly]);
useEffect(() => {
setOpen(false);
}, [
active,
local,
revision,
readOnly,
value.instrument_type,
value.region,
value.delay,
value.universe,
]);
const valid = validScope(choices, value) && !loading && !error;
useEffect(() => {
onValidityChange?.(valid && !open);
}, [valid, open, onValidityChange]);
const complete =
value.instrument_type &&
value.region &&
value.universe &&
Number.isFinite(value.delay);
return (
<section className="scope-picker" aria-label="组合选择">
<div className="scope-picker-summary">
<div>
<span className="settings-label">组合</span>
<span>
{complete
? `${value.instrument_type} / ${value.region} / ${value.universe} / Delay ${value.delay}`
: "请选择组合"}
</span>
{readOnly && <small>来自固定数据准备</small>}
</div>
{!readOnly && (
<Button
disabled={disabled || !active}
aria-expanded={open}
aria-controls={id}
onClick={() => {
setDraft(value);
setOpen(!open);
}}
>
{complete ? "更改组合" : "选择组合"}
</Button>
)}
</div>
{!readOnly &&
!options &&
!open &&
(loading ? (
<small className="settings-hint">正在加载组合选项…</small>
) : error ? (
<Banner
type="warning"
description={
<>
{error}{" "}
<Button onClick={() => setRetry((n) => n + 1)}>
重试范围选项
</Button>
</>
}
/>
) : !choices.length ? (
<small className="settings-hint">
暂无可用组合
{local ? ",请先同步本地数据" : ",请检查平台连接或重试范围选项"}
</small>
) : !valid ? (
<small className="settings-error">当前组合不可用,请重新选择</small>
) : null)}
{open && (
<div id={id} className="scope-picker-panel">
<div className="settings-grid">
{scopeFields.map((key) => {
const values = scopeValues(choices, draft, key);
const current = draft[key];
const invalid =
current !== undefined &&
current !== "" &&
!values.includes(current);
return (
<div className="settings-field" key={key}>
<span>{scopeLabels[key]}</span>
<ResearchSelect
label={scopeLabels[key]}
value={current}
loading={loading}
disabled={disabled || loading || !values.length}
filter={values.length > 6}
placeholder={`请选择 ${scopeLabels[key]}`}
validateStatus={invalid ? "error" : "default"}
optionList={values.map((v) => ({
value: v,
label: key === "delay" ? `Delay ${v}` : String(v),
}))}
onChange={(v) =>
setDraft(
changeScope(
choices,
draft,
key,
key === "delay" ? Number(v) : String(v),
),
)
}
/>
{invalid && (
<small className="settings-error">
原值 {String(current)} 不可用,请重新选择
</small>
)}
</div>
);
})}
</div>
{error ? (
<Banner type="warning" description={error} />
) : !loading && !choices.length ? (
<p>
暂无可用组合
{local ? ",请先同步本地数据" : ",请同步选项或检查平台连接"}
</p>
) : (
<p className="settings-hint">
更改上级选项后,请补齐失效的下级选项。应用后生效。
</p>
)}
<div className="settings-actions">
{!options && (
<Button loading={loading} onClick={() => setRetry((n) => n + 1)}>
重试范围选项
</Button>
)}
<Button onClick={() => setOpen(false)}>取消</Button>
<Button
theme="solid"
disabled={
disabled || loading || !!error || !validScope(choices, draft)
}
onClick={() => {
if (validScope(choices, draft)) {
onChange(draft);
setOpen(false);
}
}}
>
应用组合
</Button>
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { Banner, Button, InputNumber } from "@douyinfe/semi-ui-19";
import { formatTime } from "../api";
import type { SimulationSettings } from "../backtests/types";
import type { Scope } from "../preparations/types";
import { ResearchSelect } from "../research/ResearchSelect";
import { ScopePicker } from "./ScopePicker";
import {
asScopeOptions,
fieldOptions,
parameterLabels,
sameScope,
settingsErrors,
settingsScope,
type SettingsRow,
} from "./model";
/** One typed settings value for all producers; fixed inputs constrain the scope, never the numeric parameters. */
export function SimulationSettingsEditor({
value,
onChange,
rows,
loading,
error,
fetchedAt,
onRefresh,
fixedScopes,
disabled = false,
onValidityChange,
}: {
value: SimulationSettings;
onChange: (value: SimulationSettings) => void;
rows: SettingsRow[];
loading: boolean;
error: string;
fetchedAt?: string | null;
onRefresh: () => void;
fixedScopes?: Scope[];
disabled?: boolean;
onValidityChange?: (valid: boolean) => void;
}) {
const scope = settingsScope(value);
const row = rows.find((r) => sameScope(r, scope));
const errors = settingsErrors(value, rows, fixedScopes);
const [scopeReady, setScopeReady] = useState(false);
const valid =
!loading &&
!error &&
!errors.length &&
(fixedScopes !== undefined || scopeReady);
useEffect(() => {
onValidityChange?.(valid);
}, [valid, onValidityChange]);
function field(key: string) {
const options = fieldOptions(row, key);
const current =
value[key as keyof SimulationSettings] ??
(key === "maxPosition" ? "OFF" : undefined);
const message = errors.find((e) =>
e.startsWith(parameterLabels[key] + " "),
);
const label = parameterLabels[key];
return (
<div className="settings-field" key={key}>
<span>{label}</span>
{options.choices ? (
<ResearchSelect
label={label}
value={typeof current === "boolean" ? String(current) : current}
disabled={
disabled ||
loading ||
!options.choices.length ||
(options.choices.length === 1 && !message)
}
filter={options.choices.length > 6}
placeholder={`请选择 ${label}`}
validateStatus={message ? "error" : "default"}
optionList={options.choices.map((v) => ({
value: typeof v === "boolean" ? String(v) : v,
label: typeof v === "boolean" ? (v ? "是" : "否") : String(v),
}))}
onChange={(next) =>
onChange({
...value,
[key]: key === "visualization" ? next === "true" : next,
})
}
/>
) : (
<InputNumber
aria-label={label}
value={
typeof current === "number" && Number.isFinite(current)
? current
: undefined
}
disabled={disabled || loading}
min={options.minimum}
max={options.maximum}
precision={key === "decay" ? 0 : undefined}
step={key === "decay" ? 1 : 0.01}
validateStatus={message ? "error" : "default"}
onChange={(next) =>
onChange({
...value,
[key]: typeof next === "number" ? next : NaN,
})
}
/>
)}
{message && <small className="settings-error">{message}</small>}
</div>
);
}
return (
<section className="simulation-settings" aria-label="回测参数">
<div className="simulation-settings-header">
<strong>回测参数</strong>
<Button disabled={disabled} loading={loading} onClick={onRefresh}>
同步合法设置
</Button>
</div>
<small className="settings-hint">
选项更新时间:{formatTime(fetchedAt ?? null)}
</small>
{error && (
<Banner type="warning" description={`${error};请重试同步合法设置`} />
)}
{!loading && !rows.length && !error && (
<Banner type="info" description="尚无参数选项,请先同步合法设置。" />
)}
<ScopePicker
value={scope}
options={asScopeOptions(rows)}
readOnly={fixedScopes !== undefined}
disabled={disabled || loading}
onValidityChange={setScopeReady}
onChange={(next) =>
onChange({
...value,
instrumentType: next.instrument_type as "EQUITY",
region: next.region,
universe: next.universe,
delay: next.delay as 0 | 1,
})
}
/>
{errors
.filter(
(e) =>
!Object.values(parameterLabels).some((label) =>
e.startsWith(label + " "),
),
)
.map((e) => (
<small className="settings-error" key={e}>
{e}
</small>
))}
<div className="settings-grid">
{["neutralization", "decay", "truncation"].map(field)}
</div>
<details>
<summary>更多参数</summary>
<div className="settings-grid">
{[
"pasteurization",
"nanHandling",
"maxTrade",
"maxPosition",
"unitHandling",
"language",
"visualization",
].map(field)}
</div>
</details>
{errors.some((e) =>
[
"Pasteurization",
"NaN Handling",
"Max Trade",
"Max Position",
"Unit Handling",
"Language",
"Visualization",
].some((label) => e.startsWith(label + " ")),
) && (
<small className="settings-error">
更多参数中存在无效值,请展开检查。
</small>
)}
</section>
);
}
+198
View File
@@ -0,0 +1,198 @@
import type { SimulationSettings } from "../backtests/types";
import type { Scope } from "../preparations/types";
export type ScopeOption = Omit<Scope, "universe"> & { universes: string[] };
export type FieldOption = {
choices?: (string | number | boolean)[];
minimum?: number;
maximum?: number;
};
export type SettingsRow = Scope & {
neutralizations: string[];
fields?: Record<string, FieldOption>;
};
export type SettingsSnapshot = {
content: { items?: SettingsRow[] };
fetched_at: string | null;
};
export const scopeFields = [
"instrument_type",
"region",
"delay",
"universe",
] as const;
export const scopeLabels = {
instrument_type: "InstrumentType",
region: "Region",
delay: "Delay",
universe: "Universe",
};
export const settingsScope = (s: SimulationSettings): Scope => ({
instrument_type: s.instrumentType,
region: s.region,
delay: s.delay,
universe: s.universe,
});
export const sameScope = (a: Scope, b: Scope) =>
scopeFields.every((k) => a[k] === b[k]);
export function scopeValues(
rows: ScopeOption[],
value: Partial<Scope>,
key: keyof Scope,
): (string | number)[] {
const preceding = scopeFields.slice(0, scopeFields.indexOf(key));
const filtered = rows.filter((row) =>
preceding.every((k) => k === "universe" || row[k] === value[k]),
);
return [
...new Set(
filtered.flatMap((row) =>
key === "universe" ? row.universes : [row[key]],
),
),
];
}
/** Preserve valid descendants; only auto-select a descendant when its choice is unique. */
export function changeScope(
rows: ScopeOption[],
value: Partial<Scope>,
key: keyof Scope,
next: string | number,
): Partial<Scope> {
const draft = { ...value, [key]: next };
for (const child of scopeFields.slice(scopeFields.indexOf(key) + 1)) {
const values = scopeValues(rows, draft, child);
if (!values.includes(draft[child] as string | number)) {
Object.assign(draft, {
[child]: values.length === 1 ? values[0] : undefined,
});
}
}
return draft;
}
export function validScope(
rows: ScopeOption[],
value: Partial<Scope>,
): value is Scope {
return rows.some(
(row) =>
row.instrument_type === value.instrument_type &&
row.region === value.region &&
row.delay === value.delay &&
row.universes.includes(value.universe ?? ""),
);
}
export function asScopeOptions(rows: SettingsRow[]): ScopeOption[] {
return rows.map((row) => ({ ...row, universes: [row.universe] }));
}
export const parameterLabels: Record<string, string> = {
neutralization: "Neutralization",
decay: "Decay",
truncation: "Truncation",
pasteurization: "Pasteurization",
nanHandling: "NaN Handling",
maxTrade: "Max Trade",
maxPosition: "Max Position",
unitHandling: "Unit Handling",
language: "Language",
visualization: "Visualization",
};
// These are application contract limits, not a substitute for account-specific market choices.
const contractFields: Record<string, FieldOption> = {
decay: { minimum: 0, maximum: 10000 },
truncation: { minimum: 0, maximum: 1 },
pasteurization: { choices: ["ON", "OFF"] },
nanHandling: { choices: ["ON", "OFF"] },
maxTrade: { choices: ["ON", "OFF"] },
maxPosition: { choices: ["ON", "OFF"] },
unitHandling: { choices: ["VERIFY"] },
language: { choices: ["FASTEXPR"] },
visualization: { choices: [false, true] },
};
export function fieldOptions(
row: SettingsRow | undefined,
key: string,
): FieldOption {
if (key === "neutralization") return { choices: row?.neutralizations ?? [] };
const contract = contractFields[key] ?? {};
const platform = row?.fields?.[key];
const result: FieldOption = {
...contract,
...platform,
...(platform?.choices && contract.choices
? {
choices: platform.choices.filter((v) =>
contract.choices!.includes(v),
),
}
: {}),
...(contract.minimum !== undefined
? {
minimum: Math.max(
contract.minimum,
platform?.minimum ?? contract.minimum,
),
}
: {}),
...(contract.maximum !== undefined
? {
maximum: Math.min(
contract.maximum,
platform?.maximum ?? contract.maximum,
),
}
: {}),
};
if (result.choices && (key === "decay" || key === "truncation")) {
result.choices = result.choices.filter(
(v) =>
typeof v === "number" &&
Number.isFinite(v) &&
v >= result.minimum! &&
v <= result.maximum! &&
(key !== "decay" || Number.isInteger(v)),
);
}
return result;
}
/** Validate the exact values that will be submitted; never repair historical settings silently. */
export function settingsErrors(
value: SimulationSettings,
rows: SettingsRow[],
fixedScopes?: Scope[],
): string[] {
const errors: string[] = [];
const scope = settingsScope(value);
const row = rows.find((r) => sameScope(r, scope));
if (!row) errors.push("当前组合不在可用选项中,请选择组合或同步合法设置");
if (
fixedScopes &&
(!fixedScopes.length || fixedScopes.some((s) => !sameScope(s, scope)))
)
errors.push("所选数据准备必须具有相同组合,并与回测组合一致");
for (const key of Object.keys(parameterLabels)) {
const current =
value[key as keyof SimulationSettings] ??
(key === "maxPosition" ? "OFF" : undefined);
const options = fieldOptions(row, key);
if (
options.choices &&
!options.choices.includes(current as string | number | boolean)
)
errors.push(
`${parameterLabels[key]} 当前值 ${String(current ?? "未提供")} 不可用,请重新选择`,
);
if (
(key === "decay" || key === "truncation") &&
(typeof current !== "number" ||
!Number.isFinite(current) ||
current < options.minimum! ||
current > options.maximum! ||
(key === "decay" && !Number.isInteger(current)))
)
errors.push(
`${parameterLabels[key]} 必须为 ${options.minimum}–${options.maximum} 的${key === "decay" ? "整数" : "数值"}`,
);
}
return errors;
}
+85
View File
@@ -0,0 +1,85 @@
.scope-picker {
min-width: 0;
margin: 8px 0;
}
.scope-picker-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
border: 1px solid var(--semi-color-border);
border-radius: 6px;
}
.scope-picker-summary > div {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
overflow-wrap: anywhere;
}
.scope-picker-summary > button {
flex: none;
}
.scope-picker-summary small,
.settings-hint {
color: var(--semi-color-text-2);
}
.settings-label {
font-weight: 600;
}
.scope-picker-panel {
padding: 16px 12px;
border: 1px solid var(--semi-color-border);
border-top: 0;
border-radius: 0 0 6px 6px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.settings-field > .semi-select,
.settings-field > .semi-input-number {
width: 100%;
}
.settings-error {
color: var(--semi-color-danger);
overflow-wrap: anywhere;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.simulation-settings {
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.simulation-settings-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.simulation-settings details > summary {
cursor: pointer;
margin-bottom: 12px;
}
@media (max-width: 600px) {
.settings-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api";
import type { SettingsSnapshot } from "./model";
/** Read the saved snapshot on entry; refresh explicitly to keep account reads bounded. */
export function useSettingsOptions(active = true) {
const [snapshot, setSnapshot] = useState<SettingsSnapshot>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const controller = useRef<AbortController | null>(null);
const load = useCallback(async (refresh = false) => {
controller.current?.abort();
const c = new AbortController();
controller.current = c;
setLoading(true);
setError("");
try {
const result = await api<SettingsSnapshot>(
`/catalog/setting-options${refresh ? "/refresh" : ""}`,
{
method: refresh ? "POST" : "GET",
signal: c.signal,
},
);
if (!c.signal.aborted) setSnapshot(result);
} catch (e) {
if (!c.signal.aborted) setError((e as Error).message);
} finally {
if (!c.signal.aborted) setLoading(false);
}
}, []);
useEffect(() => {
if (active) void load();
return () => controller.current?.abort();
}, [active, load]);
return {
rows: snapshot?.content.items ?? [],
fetchedAt: snapshot?.fetched_at,
loading,
error,
refresh: () => load(true),
};
}
+9 -3
View File
@@ -27,10 +27,16 @@ test("draft, immutable preview, mixed-result persistence and responsive workspac
await login(page); await login(page);
await page.getByRole("button", { name: "新建回测" }).click(); await page.getByRole("button", { name: "新建回测" }).click();
await page.getByLabel("运行名称", { exact: true }).fill("浏览器回测验收"); await page.getByLabel("运行名称", { exact: true }).fill("浏览器回测验收");
await page.getByRole("textbox", { name: "Region", exact: true }).fill("USA"); await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
await page.getByRole("button", { name: "选择组合", exact: true }).click();
await page.getByRole("combobox", { name: "Region", exact: true }).click();
await page.getByRole("option").filter({ hasText: /^USA$/ }).click();
await page.getByRole("combobox", { name: "Universe", exact: true }).click();
await page await page
.getByRole("textbox", { name: "Universe", exact: true }) .getByRole("option")
.fill("TOP3000"); .filter({ hasText: /^TOP3000$/ })
.click();
await page.getByRole("button", { name: "应用组合", exact: true }).click();
await page.getByLabel("回测候选").fill("rank(close)\n-rank(volume)"); await page.getByLabel("回测候选").fill("rank(close)\n-rank(volume)");
await page.getByRole("button", { name: "保存草稿", exact: true }).click(); await page.getByRole("button", { name: "保存草稿", exact: true }).click();
await expect(page.getByText("草稿已保存", { exact: true })).toBeVisible(); await expect(page.getByText("草稿已保存", { exact: true })).toBeVisible();
+174 -9
View File
@@ -129,16 +129,23 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段",
).toHaveAttribute("aria-selected", "true"); ).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click(); await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
const online = page.getByRole("tabpanel", { name: "worldquant接口" }); const online = page.getByRole("tabpanel", { name: "worldquant接口" });
await online
.getByRole("button", { name: "筛选字段目录", exact: true })
.click();
await expect( await expect(
online.getByRole("button", { name: "查询", exact: true }), page.getByRole("button", { name: "查询", exact: true }),
).toBeDisabled(); ).toBeDisabled();
await expect( await expect(
online.getByText("请输入关键词、数据集或其他筛选条件,然后点击查询。"), online.getByText("请输入关键词、数据集或其他筛选条件,然后点击查询。"),
).toBeVisible(); ).toBeVisible();
expect(queries).toEqual([]); expect(queries).toEqual([]);
await online.getByLabel("字段数据集").fill("TEST_FIN"); if (!(await page.getByRole("form", { name: "字段目录筛选条件" }).isVisible()))
await online
.getByRole("button", { name: "筛选字段目录", exact: true })
.click();
await page.getByLabel("字段数据集").fill("TEST_FIN");
expect(queries).toEqual([]); expect(queries).toEqual([]);
await online.getByRole("button", { name: "查询", exact: true }).click(); await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible(); await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
await online await online
.getByRole("row") .getByRole("row")
@@ -166,8 +173,11 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段",
await page.getByRole("tab", { name: "本地同步", exact: true }).click(); await page.getByRole("tab", { name: "本地同步", exact: true }).click();
const local = page.getByRole("tabpanel", { name: "本地同步" }); const local = page.getByRole("tabpanel", { name: "本地同步" });
// Other browser tests share this isolated server, so verify the unsynced dataset explicitly. // Other browser tests share this isolated server, so verify the unsynced dataset explicitly.
await local.getByLabel("字段数据集").fill("TEST_NEWS"); await local
await local.getByRole("button", { name: "查询", exact: true }).click(); .getByRole("button", { name: "筛选字段目录", exact: true })
.click();
await page.getByLabel("字段数据集").fill("TEST_NEWS");
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(local.getByText("共 0 个字段", { exact: true })).toBeVisible(); await expect(local.getByText("共 0 个字段", { exact: true })).toBeVisible();
await page.screenshot({ await page.screenshot({
path: "test-results/preparations-fields.png", path: "test-results/preparations-fields.png",
@@ -239,19 +249,174 @@ test("目录表格保持紧凑行高、截断和固定分页", async ({ page })
await page.screenshot({ path: "test-results/field-table-layout-narrow.png" }); await page.screenshot({ path: "test-results/field-table-layout-narrow.png" });
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click(); await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
const online = page.getByRole("tabpanel", { name: "worldquant接口" }); const online = page.getByRole("tabpanel", { name: "worldquant接口" });
await online.getByLabel("字段数据集").fill("TEST_FIN"); if (!(await page.getByRole("form", { name: "字段目录筛选条件" }).isVisible()))
await online.getByRole("button", { name: "查询", exact: true }).click(); await online
.getByRole("button", { name: "筛选字段目录", exact: true })
.click();
await page.getByLabel("字段数据集").fill("TEST_FIN");
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible(); await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
let queries = 0; let queries = 0;
page.on("request", (req) => { page.on("request", (req) => {
if (req.url().includes("/catalog/worldquant/fields")) queries++; if (req.url().includes("/catalog/worldquant/fields")) queries++;
}); });
await online.getByRole("button", { name: "重置", exact: true }).click(); await online
.getByRole("button", { name: "筛选字段目录", exact: true })
.click();
await page.getByRole("button", { name: "重置", exact: true }).click();
await online
.getByRole("button", { name: "筛选字段目录", exact: true })
.click();
await expect( await expect(
online.getByRole("button", { name: "查询", exact: true }), page.getByRole("button", { name: "查询", exact: true }),
).toBeDisabled(); ).toBeDisabled();
await expect(online.getByText("TEST_FIN_001", { exact: true })).toHaveCount( await expect(online.getByText("TEST_FIN_001", { exact: true })).toHaveCount(
0, 0,
); );
expect(queries).toBe(0); expect(queries).toBe(0);
}); });
async function selectToolbarOption(page: Page, label: string, value: string) {
const control = page.getByRole("combobox", { name: label, exact: true });
await control.click();
const popup = page.locator(
`[id="${await control.getAttribute("aria-controls")}"]`,
);
await popup
.getByRole("option")
.filter({ hasText: new RegExp(`^${value}$`) })
.click();
await expect(control).toContainText(value);
await expect(popup).not.toBeVisible();
}
test("共用工具栏延迟应用筛选、保存列设置并发送服务端排序", async ({ page }) => {
await setup(page);
await sync(page, null);
await sync(page, "TEST_FIN");
await page.reload();
await expect(
page.getByRole("row").filter({ hasText: "TEST 财务报表" }),
).toBeVisible();
const requests: string[] = [];
page.on("request", (req) => {
if (
req.url().includes("/catalog/datasets?") ||
req.url().includes("/catalog/fields?") ||
req.url().includes("/data-preparations?")
)
requests.push(req.url());
});
await page.getByRole("button", { name: "筛选数据目录", exact: true }).click();
const filter = page.getByRole("form", { name: "数据目录筛选条件" });
await filter.getByLabel("搜索数据集").fill("TEST_FIN");
expect(requests.some((url) => url.includes("q=TEST_FIN"))).toBe(false);
await filter.getByRole("button", { name: "查询", exact: true }).click();
await expect
.poll(() =>
requests.some((url) => new URL(url).searchParams.get("q") === "TEST_FIN"),
)
.toBe(true);
await page.getByRole("button", { name: "显示列设置", exact: true }).click();
await page
.locator(".catalog-column-panel .semi-checkbox")
.filter({ hasText: "价值评分" })
.click();
await expect(
page.getByRole("checkbox", { name: "价值评分", exact: true }),
).not.toBeChecked();
await page.keyboard.press("Escape");
await expect(
page.getByRole("columnheader", { name: "价值评分", exact: true }),
).toHaveCount(0);
await page.reload();
await expect(
page.getByRole("row").filter({ hasText: "TEST 财务报表" }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: "价值评分", exact: true }),
).toHaveCount(0);
await page.getByRole("button", { name: "排序设置", exact: true }).click();
await selectToolbarOption(page, "排序字段", "字段数");
await selectToolbarOption(page, "排序方向", "降序");
await expect
.poll(() =>
requests.some(
(url) =>
url.includes("sort=field_count") && url.includes("direction=desc"),
),
)
.toBe(true);
await page.screenshot({ path: "test-results/catalog-sort-popover.png" });
await page.keyboard.press("Escape");
await page
.getByRole("navigation", { name: "主导航" })
.getByRole("button", { name: "字段目录", exact: true })
.click();
const local = page.getByRole("tabpanel", { name: "本地同步" });
await expect(local.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
await local.locator(".semi-page-item").filter({ hasText: /^2$/ }).click();
await expect
.poll(() =>
requests.some(
(url) => url.includes("/catalog/fields?") && url.includes("offset=25"),
),
)
.toBe(true);
await local.getByRole("button", { name: "排序设置", exact: true }).click();
await selectToolbarOption(page, "排序方向", "降序");
await expect
.poll(() =>
requests.some(
(url) =>
url.includes("/catalog/fields?") &&
url.includes("direction=desc") &&
url.includes("offset=0"),
),
)
.toBe(true);
await page.keyboard.press("Escape");
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
await expect(
page
.getByRole("tabpanel", { name: "worldquant接口" })
.getByRole("button", { name: "排序设置", exact: true }),
).toBeDisabled();
await page
.getByRole("navigation", { name: "主导航" })
.getByRole("button", { name: "数据准备", exact: true })
.click();
await page.getByRole("button", { name: "筛选数据准备", exact: true }).click();
await page.getByLabel("搜索准备集合").fill("toolbar-preparation");
await page
.locator(".catalog-filter-panel .semi-checkbox")
.filter({ hasText: "按范围筛选" })
.click();
await page.getByRole("button", { name: "更改组合", exact: true }).click();
await selectToolbarOption(page, "Region", "USA");
await page.getByRole("button", { name: "应用组合", exact: true }).click();
await expect(
page.getByRole("form", { name: "数据准备筛选条件" }),
).toBeVisible();
expect(requests.some((url) => url.includes("q=toolbar-preparation"))).toBe(
false,
);
await page.screenshot({
path: "test-results/preparation-filter-popover.png",
});
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect
.poll(() => requests.some((url) => url.includes("q=toolbar-preparation")))
.toBe(true);
await page.getByRole("button", { name: "排序设置", exact: true }).click();
await selectToolbarOption(page, "排序字段", "集合名称");
await expect
.poll(() =>
requests.some(
(url) =>
url.includes("/data-preparations?") && url.includes("sort=name"),
),
)
.toBe(true);
});
+15
View File
@@ -86,6 +86,21 @@ test("feature conversion, saved views and immutable evaluations", async ({
await expect( await expect(
page.getByRole("textbox", { name: "模板名称", exact: true }), page.getByRole("textbox", { name: "模板名称", exact: true }),
).toHaveValue("特征转换模板"); ).toHaveValue("特征转换模板");
const settingsEditor = page.getByRole("region", {
name: "回测参数",
exact: true,
});
await settingsEditor.getByRole("button", { name: "同步合法设置" }).click();
await expect(
settingsEditor.getByRole("combobox", {
name: "Neutralization",
exact: true,
}),
).toContainText("INDUSTRY");
await expect(
settingsEditor.getByRole("button", { name: "更改组合" }),
).toHaveCount(0);
await expect(settingsEditor).toContainText("来自固定数据准备");
const templates = await ( const templates = await (
await page.request.get( await page.request.get(
"/api/v1/research/assets?kind=template&q=特征转换模板", "/api/v1/research/assets?kind=template&q=特征转换模板",
+264
View File
@@ -0,0 +1,264 @@
import { expect, test, type Page } from "@playwright/test";
import { initialSettings } from "../src/backtests/types";
import {
changeScope,
fieldOptions,
settingsErrors,
type SettingsRow,
} from "../src/settings/model";
const headers = { "X-WQ-Request": "1" };
async function login(page: Page) {
await page.goto("/#backtests");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(page.getByRole("button", { name: "新建回测" })).toBeVisible();
await page.request.put("/api/v1/account/credentials", {
headers,
data: { email: "test@example.com", password: "synthetic-password" },
});
await page.request.post("/api/v1/account/connect", { headers });
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/account")).json())
.connection_status,
)
.toBe("connected");
}
async function select(page: Page, name: string, option: string) {
const control = page.getByRole("combobox", { name, exact: true });
await control.click();
const popup = page.locator(
`[id="${await control.getAttribute("aria-controls")}"]`,
);
await popup
.getByRole("option")
.filter({ hasText: new RegExp(`^${option}$`) })
.click();
await expect(control).toContainText(option);
await expect(popup).not.toBeVisible();
}
async function openEditor(page: Page) {
await page.getByRole("button", { name: "新建回测", exact: true }).click();
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
await page.getByRole("button", { name: "选择组合", exact: true }).click();
await select(page, "Region", "USA");
await select(page, "Universe", "TOP3000");
await page.getByRole("button", { name: "应用组合", exact: true }).click();
}
test("scope changes are transactional and explicit parameters reach the preview unchanged", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await login(page);
await openEditor(page);
const picker = page.getByRole("region", { name: "组合选择", exact: true });
await expect(picker).toContainText("EQUITY / USA / TOP3000 / Delay 1");
await page.getByRole("button", { name: "更改组合", exact: true }).click();
await select(page, "Region", "CHN");
await expect(picker).toContainText("TOP2000U");
await picker.getByRole("button", { name: "取消", exact: true }).click();
await expect(picker).toContainText("EQUITY / USA / TOP3000 / Delay 1");
await page.getByRole("button", { name: "更改组合", exact: true }).click();
await expect(
page.getByRole("combobox", { name: "Region", exact: true }),
).toContainText("USA");
await select(page, "Region", "CHN");
await expect(
page.getByRole("combobox", { name: "Region", exact: true }),
).toContainText("CHN");
await page.getByRole("button", { name: "应用组合", exact: true }).click();
await expect(picker).toContainText("EQUITY / CHN / TOP2000U / Delay 1");
await select(page, "Neutralization", "NONE");
await page.getByLabel("Decay", { exact: true }).fill("12");
await page.getByText("更多参数", { exact: true }).click();
await select(page, "NaN Handling", "ON");
await select(page, "Max Position", "ON");
await select(page, "Visualization", "是");
await expect(page.getByLabel("其他参数", { exact: true })).toHaveCount(0);
await page.getByLabel("运行名称", { exact: true }).fill("组合参数一致性");
await page.getByLabel("回测候选").fill("rank(close)");
for (const width of [1440, 900, 390]) {
await page.setViewportSize({ width, height: 1000 });
const bounds = await page
.locator(".simulation-settings .settings-field:visible")
.evaluateAll((nodes) =>
nodes.map((node) => {
const r = node.getBoundingClientRect();
return { left: r.left, right: r.right, width: r.width };
}),
);
expect(
bounds.every((r) => r.left >= 0 && r.right <= width + 1 && r.width > 80),
).toBeTruthy();
await page.screenshot({
path: `../output/playwright/shared-settings-${width}.png`,
});
}
await page.setViewportSize({ width: 1440, height: 1000 });
const response = page.waitForResponse(
(r) =>
r.url().endsWith("/backtests/previews") &&
r.request().method() === "POST",
);
await page.getByRole("button", { name: "预览回测", exact: true }).click();
const preview = await (await response).json();
expect(preview.items[0].settings).toMatchObject({
region: "CHN",
universe: "TOP2000U",
neutralization: "NONE",
decay: 12,
nanHandling: "ON",
maxPosition: "ON",
visualization: true,
});
expect(errors).toEqual([]);
});
test("metadata failure retries and invalid imported settings stay visible without submission", async ({
page,
}) => {
await login(page);
await page.getByRole("button", { name: "新建回测", exact: true }).click();
await page.route("**/catalog/setting-options/refresh", (route) =>
route.fulfill({ status: 502, json: { detail: "模拟选项同步失败" } }),
);
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
await expect(page.getByText(/模拟选项同步失败/)).toBeVisible();
await expect(
page.getByRole("button", { name: "预览回测", exact: true }),
).toBeDisabled();
await page.unroute("**/catalog/setting-options/refresh");
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
await expect(page.getByText(/模拟选项同步失败/)).toHaveCount(0);
await page.getByText("每行一条表达式,共用参数", { exact: true }).click();
await page
.getByRole("option")
.filter({ hasText: "结构化候选 JSON,逐项完整参数" })
.click();
const text = JSON.stringify([
{
client_item_id: "old",
expression: "rank(close)",
settings: {
...initialSettings,
region: "USA",
universe: "TOP3000",
neutralization: "REMOVED_VALUE",
},
},
]);
await page.getByLabel("回测候选").fill(text);
let submits = 0;
page.on("request", (request) => {
if (
request.url().endsWith("/backtests/previews") &&
request.method() === "POST"
)
submits++;
});
await page.getByRole("button", { name: "预览回测", exact: true }).click();
await expect(page.getByText(/REMOVED_VALUE 不可用/)).toBeVisible();
await expect(page.getByLabel("回测候选")).toHaveValue(text);
expect(submits).toBe(0);
});
test("scope invalidation and platform restrictions preserve types and reject incompatible fixed inputs", () => {
const scope = {
instrument_type: "EQUITY",
region: "USA",
delay: 1,
universe: "TOP3000",
};
const options = [
{ ...scope, universes: ["TOP3000"] },
{ ...scope, region: "CHN", universes: ["TOP2000U", "TOP500"] },
];
expect(changeScope(options, scope, "region", "CHN")).toEqual({
...scope,
region: "CHN",
universe: undefined,
});
const row: SettingsRow = {
...scope,
neutralizations: ["NONE"],
fields: {
visualization: { choices: [false] },
decay: { minimum: -10, maximum: 20000 },
},
};
expect(fieldOptions(row, "decay")).toEqual({ minimum: 0, maximum: 10000 });
expect(fieldOptions(row, "visualization").choices).toEqual([false]);
expect(
fieldOptions(
{ ...row, fields: { decay: { choices: [-1, 0, 1.5, 10, 20000] } } },
"decay",
).choices,
).toEqual([0, 10]);
expect(
settingsErrors(
{
...initialSettings,
region: "USA",
universe: "TOP3000",
neutralization: "NONE",
},
[row],
[scope],
),
).toEqual([]);
const errors = settingsErrors(
{
...initialSettings,
region: "USA",
universe: "TOP3000",
decay: 0.5,
visualization: "false" as unknown as boolean,
},
[row],
[{ ...scope, region: "CHN" }],
);
expect(errors.join(" ")).toContain("相同组合");
expect(errors.join(" ")).toContain("INDUSTRY 不可用");
expect(errors.join(" ")).toContain("整数");
expect(errors.join(" ")).toContain("Visualization");
});
test("new preparation edits its scope inline and cancels without changing the collection", async ({
page,
}) => {
await login(page);
await page
.getByRole("navigation", { name: "主导航" })
.getByRole("button", { name: "数据准备", exact: true })
.click();
await page.getByRole("button", { name: "新建集合", exact: true }).click();
const dialog = page.getByRole("dialog", {
name: "新建数据准备集合",
exact: true,
});
const picker = dialog.getByRole("region", { name: "组合选择", exact: true });
await picker.getByRole("button", { name: "更改组合" }).click();
await select(page, "Region", "CHN");
await expect(dialog).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(1);
await picker.getByRole("button", { name: "取消", exact: true }).click();
await expect(picker).toContainText("EQUITY / USA / TOP3000 / Delay 1");
await picker.getByRole("button", { name: "更改组合" }).click();
await select(page, "Region", "CHN");
await page.screenshot({
path: "../output/playwright/preparation-scope-picker.png",
});
await picker.getByRole("button", { name: "应用组合", exact: true }).click();
await expect(picker).toContainText("EQUITY / CHN / TOP2000U / Delay 1");
await dialog.getByLabel("集合名称", { exact: true }).fill("组合组件新建验收");
const response = page.waitForResponse(
(r) =>
r.url().endsWith("/data-preparations") && r.request().method() === "POST",
);
await dialog.getByRole("button", { name: "confirm", exact: true }).click();
expect((await (await response).json()).scope).toMatchObject({
region: "CHN",
universe: "TOP2000U",
delay: 1,
});
});