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
+60 -97
View File
@@ -1,3 +1,6 @@
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions";
import { settingsErrors } from "../settings/model";
import {
ResearchDataInput,
researchSelection,
@@ -104,7 +107,11 @@ export function BacktestPage({
const [text, setText] = useState("");
const [mode, setMode] = useState("lines");
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 [excluded, setExcluded] = useState<string[]>([]);
const [referenceUrls, setReferenceUrls] = useState<Record<string, string>>(
@@ -250,12 +257,13 @@ export function BacktestPage({
setText("");
setSettings(initialSettings);
setMode("lines");
setAdvanced("");
setPreview(null);
setEditor(true);
setRunId("");
}
function input() {
if (options.loading || options.error)
throw new Error("请等待参数选项加载完成,或重试同步合法设置");
const candidates: Candidate[] =
mode === "json"
? JSON.parse(text)
@@ -266,13 +274,20 @@ export function BacktestPage({
.map((expression, i) => ({
client_item_id: `item-${i + 1}`,
expression,
settings: {
...settings,
...(advanced.trim() ? JSON.parse(advanced) : {}),
},
settings: { ...settings },
}));
if (!Array.isArray(candidates) || !candidates.length)
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) };
}
async function loadDraft(id: string) {
@@ -488,7 +503,7 @@ export function BacktestPage({
<SideSheet
title={preview ? "确认回测输入" : "准备候选"}
visible={editor && drawerVisible}
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
width={`min(max(640px, 60vw), calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }}
onCancel={() => setEditor(false)}
@@ -614,12 +629,24 @@ export function BacktestPage({
) : (
<>
<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
theme="solid"
loading={busy}
disabled={
options.loading ||
!!options.error ||
(mode === "lines" && !settingsValid)
}
onClick={() => void perform(prepare)}
>
预览回测
@@ -683,94 +710,30 @@ export function BacktestPage({
]}
/>
</label>
{mode === "lines" && (
<>
<div className="backtest-form-grid">
<label>
Region
<Input
aria-label="Region"
placeholder="例如 USA"
value={settings.region}
onChange={(region) =>
setSettings({ ...settings, region })
}
/>
</label>
<label>
Universe
<Input
aria-label="Universe"
placeholder="例如 TOP3000"
value={settings.universe}
onChange={(universe) =>
setSettings({ ...settings, universe })
}
/>
</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>
<details>
<summary>其他参数(JSON 覆盖,预览展示最终值)</summary>
<TextArea
aria-label="其他参数"
value={advanced}
onChange={setAdvanced}
placeholder={'{"nanHandling":"ON"}'}
rows={3}
/>
</details>
</>
{mode === "lines" ? (
<SimulationSettingsEditor
value={settings}
onChange={setSettings}
{...options}
fetchedAt={options.fetchedAt}
onRefresh={() => void options.refresh()}
fixedScopes={fixedScopes}
disabled={busy}
onValidityChange={setSettingsValid}
/>
) : (
<div className="settings-actions">
<Button
loading={options.loading}
onClick={() => void options.refresh()}
>
同步合法设置
</Button>
<span>逐项校验完整参数;预览展示最终值。</span>
{options.error && (
<Banner type="warning" description={options.error} />
)}
</div>
)}
<label>
{mode === "lines" ? "表达式" : "候选 JSON"}
@@ -797,7 +760,7 @@ export function BacktestPage({
<SideSheet
title={run?.name || "回测详情"}
visible={!!runId && drawerVisible}
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
width={`min(max(640px, 60vw), calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }}
onCancel={() => {
+1
View File
@@ -28,6 +28,7 @@ export const initialSettings: SimulationSettings = {
language: "FASTEXPR",
visualization: false,
maxTrade: "OFF",
maxPosition: "OFF",
};
export type Candidate = {
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);
}
}
+193 -119
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 { useEffect, useState } from "react";
import {
@@ -76,6 +89,19 @@ export function DatasetPage({
[q, setQ] = useState(""),
[category, setCategory] = 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"),
[direction, setDirection] = useState("asc"),
[page, setPage] = useState(1),
@@ -223,6 +249,65 @@ export function DatasetPage({
setNoteVersion(n.version);
Toast.success("备注已保存");
}
const columns: ColumnProps<Entry>[] = [
{
title: "数据集",
width: 280,
render: (_, r) => (
<Button
theme="borderless"
title={r.name || r.id}
onClick={() => void work(() => view(r))}
>
{r.name || r.id}
</Button>
),
},
{ title: "分类", dataIndex: "category", render: displayValue },
{
title: "字段数",
render: (_, r) => r.complete_count ?? displayValue(r.field_count),
},
{ title: "价值评分", dataIndex: "value_score", render: displayValue },
{
title: "同步状态",
render: (_, r) => (
<Tag color={r.collection_version ? "green" : "grey"}>
{r.collection_version ? "已同步字段" : "待同步字段"}
</Tag>
),
},
{
title: "操作",
width: 140,
render: (_, r) => (
<div className="workspace-cell-actions">
<CatalogIconAction
label="查看"
icon={<IconEyeOpened aria-hidden="true" />}
disabled={busy}
onClick={() => void work(() => view(r))}
/>
<CatalogIconAction
label="同步"
icon={<IconDownload aria-hidden="true" />}
disabled={busy}
onClick={() => void work(() => sync(r.id))}
/>
<CatalogIconAction
label="使用"
icon={<IconPlus aria-hidden="true" />}
disabled={busy || !r.collection_version}
title={!r.collection_version ? "请先同步全部字段" : "使用"}
onClick={() => void work(() => use(r))}
/>
</div>
),
},
];
const visibleColumns = columns.filter((_, index) =>
columnSettings.visible.includes(columnSettings.choices[index].key),
);
return (
<section className="preparation-page">
<ScopeControls
@@ -243,69 +328,113 @@ export function DatasetPage({
}
/>
)}
<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={[
<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={(v) => {
setSort(String(v));
],
onChange: (value, dir) => {
setSort(value);
setDirection(dir);
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>
},
}}
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"
@@ -313,66 +442,11 @@ export function DatasetPage({
dataSource={data.items}
pagination={false}
loading={loading}
scroll={{ x: 1180, y: "100%" }}
columns={[
{
title: "数据集",
width: 280,
render: (_, r) => (
<Button
theme="borderless"
title={r.name || r.id}
onClick={() => void work(() => view(r))}
>
{r.name || r.id}
</Button>
),
},
{ title: "分类", dataIndex: "category", render: displayValue },
{
title: "字段数",
render: (_, r) => r.complete_count ?? displayValue(r.field_count),
},
{ title: "价值评分", dataIndex: "value_score", render: displayValue },
{
title: "同步状态",
render: (_, r) => (
<Tag color={r.collection_version ? "green" : "grey"}>
{r.collection_version ? "已同步字段" : "待同步字段"}
</Tag>
),
},
{
title: "操作",
width: 240,
render: (_, r) => (
<div className="workspace-cell-actions">
<Button
theme="borderless"
disabled={busy}
onClick={() => void work(() => view(r))}
>
查看
</Button>
<Button
theme="borderless"
disabled={busy}
onClick={() => void work(() => sync(r.id))}
>
同步
</Button>
<Button
theme="borderless"
disabled={busy || !r.collection_version}
title={!r.collection_version ? "请先同步全部字段" : undefined}
onClick={() => void work(() => use(r))}
>
使用
</Button>
</div>
),
},
]}
scroll={{
x: visibleColumns.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
y: "100%",
}}
columns={visibleColumns}
/>
<div className="table-pagination workspace-table-footer">
<span>
+215 -109
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 { useEffect, useState } from "react";
import {
Banner,
Checkbox,
Button,
Input,
Modal,
@@ -39,6 +54,21 @@ export function DataPreparationPage({ active }: { active: boolean }) {
const [selected, setSelected] = useState<Record<string, Preparation>>({}),
[scope, setScope] = useState(defaultScope),
[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),
[creating, setCreating] = useState(false),
[name, setName] = useState(""),
@@ -61,7 +91,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
if (!active) return;
const c = new AbortController();
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 },
)
.then((r) => {
@@ -72,7 +102,17 @@ export function DataPreparationPage({ active }: { active: boolean }) {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [active, q, page, pageSize, rev, scopeFilter, scopeKey(scope)]);
}, [
active,
q,
page,
pageSize,
rev,
sort,
direction,
scopeFilter,
scopeKey(filterScope),
]);
useEffect(() => {
if (!detail) return;
const c = new AbortController();
@@ -119,63 +159,181 @@ export function DataPreparationPage({ active }: { active: boolean }) {
),
);
}, [active]);
const columns: ColumnProps<Preparation>[] = [
{
title: "集合名称",
width: 260,
render: (_, r) => (
<Button theme="borderless" title={r.name} onClick={() => open(r)}>
{r.name}
</Button>
),
},
{
title: "范围",
width: 240,
render: (_, r) => (
<span title={scopeLabel(r.scope)}>{scopeLabel(r.scope)}</span>
),
},
{ title: "字段数", dataIndex: "field_count", width: 100 },
{ title: "数据集数", dataIndex: "dataset_count", width: 100 },
{
title: "更新时间",
width: 180,
render: (_, r) => formatTime(r.updated_at),
},
{
title: "操作",
width: 140,
render: (_, r) => (
<div className="workspace-cell-actions">
<CatalogIconAction
label="查看"
icon={<IconEyeOpened aria-hidden="true" />}
onClick={() => open(r)}
/>
<CatalogIconAction
label="复制"
icon={<IconCopy aria-hidden="true" />}
disabled={busy}
onClick={() =>
void work(async () => {
const copy = await post<Preparation>(
`/data-preparations/${r.id}/copy`,
{ version: r.version },
);
open(copy);
})
}
/>
<CatalogIconAction
label="删除"
icon={<IconDelete aria-hidden="true" />}
type="danger"
onClick={() => setDeleting([r])}
/>
</div>
),
},
];
const visibleColumns = columns.filter((_, i) =>
columnSettings.visible.includes(columnSettings.choices[i].key),
);
return (
<section className="preparation-page">
{error && <Banner type="danger" description={error} />}
<div className="preparation-tools">
<Input
aria-label="搜索准备集合"
placeholder="搜索名称或备注"
value={q}
onChange={(v) => {
setQ(v);
<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);
}}
/>
<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({});
}}
/>
)}
},
}}
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: 1140, y: "100%" }}
scroll={{
x: visibleColumns.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
y: "100%",
}}
rowSelection={{
selectedRowKeys: Object.keys(selected),
onChange: (keys) =>
@@ -188,64 +346,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
return n;
}),
}}
columns={[
{
title: "集合名称",
width: 260,
render: (_, r) => (
<Button theme="borderless" title={r.name} onClick={() => open(r)}>
{r.name}
</Button>
),
},
{
title: "范围",
width: 240,
render: (_, r) => (
<span title={scopeLabel(r.scope)}>{scopeLabel(r.scope)}</span>
),
},
{ title: "字段数", dataIndex: "field_count", width: 100 },
{ title: "数据集数", dataIndex: "dataset_count", width: 100 },
{
title: "更新时间",
width: 180,
render: (_, r) => formatTime(r.updated_at),
},
{
title: "操作",
width: 200,
render: (_, r) => (
<div className="workspace-cell-actions">
<Button theme="borderless" onClick={() => open(r)}>
查看
</Button>
<Button
theme="borderless"
disabled={busy}
onClick={() =>
void work(async () => {
const copy = await post<Preparation>(
`/data-preparations/${r.id}/copy`,
{ version: r.version },
);
open(copy);
})
}
>
复制
</Button>
<Button
theme="borderless"
type="danger"
onClick={() => setDeleting([r])}
>
删除
</Button>
</div>
),
},
]}
columns={visibleColumns}
/>
<footer className="table-pagination workspace-table-footer">
<span>共 {data.total} 个集合</span>
@@ -268,7 +369,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
closeOnEsc
onCancel={() => setCreating(false)}
confirmLoading={busy}
okButtonProps={{ disabled: !name.trim() || busy }}
okButtonProps={{ disabled: !name.trim() || busy || !scopeValid }}
onOk={() =>
work(async () => {
const row = await post<Preparation>("/data-preparations", {
@@ -283,7 +384,12 @@ export function DataPreparationPage({ active }: { active: boolean }) {
>
{error && <Banner type="danger" description={error} />}
<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} />
</Modal>
<Modal
+210 -153
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 {
Banner,
@@ -13,7 +19,7 @@ import { api, patch, post, queryString } from "../api";
import { ResearchSelect } from "../research/ResearchSelect";
import { ScopeControls } from "./ScopeControls";
import { DataPreparationPicker } from "./DataPreparationPicker";
import { FieldTable } from "./FieldTable";
import { FieldTable, fieldColumnChoices } from "./FieldTable";
import {
defaultScope,
fieldReference,
@@ -118,8 +124,13 @@ export function FieldBrowser({
[queriedScope, setQueriedScope] = useState<string | null>(null);
const [page, setPage] = useState(1),
[size, setSize] = useState(25),
[refresh, setRefresh] = useState(0),
[advanced, setAdvanced] = useState(false);
[refresh, setRefresh] = useState(0);
const [sort, setSort] = useState("id"),
[direction, setDirection] = useState("asc");
const columnSettings = useCatalogColumns(
`catalog-fields-${source}-columns`,
fieldColumnChoices,
);
const [data, setData] = useState<Page<FieldRecord>>({
items: [],
total: 0,
@@ -160,7 +171,7 @@ export function FieldBrowser({
setLoading(true);
setError("");
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 },
)
.then((r) => {
@@ -181,7 +192,19 @@ export function FieldBrowser({
if (!c.signal.aborted) setLoading(false);
});
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) =>
setDraft((old) => ({ ...old, [k]: v }));
return (
@@ -195,170 +218,204 @@ export function FieldBrowser({
revision={revision}
/>
)}
<div className="preparation-tools">
<Input
aria-label="搜索字段"
placeholder="字段 ID、名称或描述"
value={draft.q ?? ""}
onChange={(v) => update("q", v)}
/>
<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);
setFilters({
...draft,
synced_from: draft.synced_from
? new Date(draft.synced_from).toISOString()
: "",
synced_to: draft.synced_to
? new Date(draft.synced_to).toISOString()
: "",
});
setPage(1);
setRefresh((v) => v + 1);
}}
>
查询
</Button>
<Button onClick={() => setAdvanced((v) => !v)}>更多筛选</Button>
<Button
onClick={() => {
setDraft({});
setFilters({});
setQueriedScope(null);
setSelected({});
setLoading(false);
setError("");
if (source === "worldquant")
setData({ items: [], total: 0, limit: size, offset: 0 });
setPage(1);
}}
>
重置
</Button>
</div>
{advanced && (
<div className="preparation-filters">
{source === "local" &&
["category", "subcategory"].map((k, i) => (
<label key={k}>
{i ? "子分类" : "分类"}
<CatalogTableToolbar
label="字段目录"
active={active && !adding}
columns={columnSettings}
filterCount={Object.values(filters).filter(Boolean).length}
canApply={source === "local" || (hasCriteria && !loading)}
onApply={() => {
setQueriedScope(key);
setFilters({
...draft,
synced_from: draft.synced_from
? new Date(draft.synced_from).toISOString()
: "",
synced_to: draft.synced_to
? new Date(draft.synced_to).toISOString()
: "",
});
setPage(1);
setRefresh((v) => v + 1);
setSelected({});
}}
onReset={() => {
setDraft({});
setFilters({});
setQueriedScope(null);
setSelected({});
setLoading(false);
setError("");
if (source === "worldquant")
setData({ items: [], total: 0, limit: size, offset: 0 });
setPage(1);
}}
filters={
<>
<div className="catalog-filter-grid">
<label className="catalog-filter-wide">
搜索
<Input
aria-label={i ? "字段子分类" : "字段分类"}
value={draft[k] ?? ""}
onChange={(v) => update(k, v)}
aria-label="搜索字段"
placeholder="字段 ID、名称或描述"
value={draft.q ?? ""}
onChange={(v) => update("q", v)}
showClear
/>
</label>
))}
{[
["coverage", "覆盖率(0–1)"],
["user_count", "用户数"],
["alpha_count", "Alpha 数"],
].flatMap(([k, label]) =>
["min", "max"].map((suffix) => (
<label key={k + suffix}>
{label}{" "}
{source === "worldquant"
? suffix === "min"
? ">"
: "<"
: suffix === "min"
? "≥"
: "≤"}
<label>
数据集
<Input
type="number"
aria-label={`${label}${suffix === "min" ? "下限" : "上限"}`}
value={draft[k + "_" + suffix] ?? ""}
onChange={(v) => update(k + "_" + suffix, v)}
aria-label="字段数据集"
placeholder="数据集 ID"
value={draft.dataset_id ?? ""}
onChange={(v) => update("dataset_id", v)}
/>
</label>
)),
)}
{source === "local" && (
<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>
<details>
<summary>更多筛选</summary>
<div className="catalog-filter-grid">
{source === "local" &&
["category", "subcategory"].map((k, i) => (
<label key={k}>
{i ? "子分类" : "分类"}
<Input
aria-label={i ? "字段子分类" : "字段分类"}
value={draft[k] ?? ""}
onChange={(v) => update(k, v)}
/>
</label>
))}
{[
["coverage", "覆盖率(0–1)"],
["user_count", "用户数"],
["alpha_count", "Alpha 数"],
].flatMap(([k, label]) =>
["min", "max"].map((suffix) => (
<label key={k + suffix}>
{label}{" "}
{source === "worldquant"
? suffix === "min"
? ">"
: "<"
: suffix === "min"
? "≥"
: "≤"}
<Input
type="number"
aria-label={`${label}${suffix === "min" ? "下限" : "上限"}`}
value={draft[k + "_" + suffix] ?? ""}
onChange={(v) => update(k + "_" + suffix, v)}
/>
</label>
)),
)}
{source === "local" && (
<>
<label>
同步起始时间
<input
aria-label="同步起始时间"
type="datetime-local"
value={draft.synced_from ?? ""}
onChange={(e) => update("synced_from", e.target.value)}
/>
</label>
<label>
同步结束时间
<input
aria-label="同步结束时间"
type="datetime-local"
value={draft.synced_to ?? ""}
onChange={(e) => update("synced_to", e.target.value)}
/>
</label>
</>
)}
</div>
</details>
</>
}
sort={
source === "local"
? {
value: sort,
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
theme="solid"
icon={<IconPlus aria-hidden="true" />}
disabled={!Object.keys(selected).length || loading || !!error}
onClick={() =>
onAdd ? onAdd(Object.values(selected)) : setAdding(true)
}
>
加入数据准备
</Button>
</>
}
selection={
Object.keys(selected).length ? (
<>
<label>
同步起始时间
<input
type="datetime-local"
value={draft.synced_from ?? ""}
onChange={(e) => update("synced_from", e.target.value)}
/>
</label>
<label>
同步结束时间
<input
type="datetime-local"
value={draft.synced_to ?? ""}
onChange={(e) => update("synced_to", e.target.value)}
/>
</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))}
<span>
已选 {Object.keys(selected).length} 个字段 · 表头选择当前页
</span>
<CatalogIconAction
label="清空选择"
icon={<IconClose aria-hidden="true" />}
onClick={() => setSelected({})}
/>
</>
)}
</div>
)}
) : undefined
}
/>
{error && <Banner type="danger" description={error} />}
{source === "worldquant" && !canRead && (
<p className="preparation-query-hint">
请输入关键词、数据集或其他筛选条件,然后点击查询。
</p>
)}
<div className="preparation-tools">
<Button
theme="solid"
disabled={!Object.keys(selected).length || loading || !!error}
onClick={() =>
onAdd ? onAdd(Object.values(selected)) : setAdding(true)
}
>
加入数据准备
</Button>
<span>已选 {Object.keys(selected).length} 个字段 · 表头选择当前页</span>
{!!Object.keys(selected).length && (
<Button onClick={() => setSelected({})}>清空选择</Button>
)}
</div>
<FieldTable
visibleColumns={columnSettings.visible}
fill={!fixedScope}
items={data.items}
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 { displayValue, formatTime } from "../api";
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({
items,
loading = false,
@@ -10,10 +20,12 @@ export function FieldTable({
selected,
onSelect,
onOpen,
visibleColumns,
}: {
items: FieldRecord[];
loading?: boolean;
fill?: boolean;
visibleColumns?: string[];
onOpen?: (field: FieldRecord) => void;
selected?: string[];
onSelect?: (ids: string[]) => void;
@@ -78,6 +90,10 @@ export function FieldTable({
),
},
];
const shown = columns.filter(
(_, index) =>
!visibleColumns || visibleColumns.includes(fieldColumnChoices[index].key),
);
return (
<Table<FieldRecord>
fill={fill}
@@ -85,10 +101,13 @@ export function FieldTable({
className="preparation-table"
rowKey={(r) => `${r?.dataset_id}|${r?.id}`}
dataSource={items}
columns={columns}
columns={shown}
loading={loading}
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={
onSelect
? {
+2 -132
View File
@@ -1,132 +1,2 @@
import { useEffect, useState } from "react";
import { Banner, Button } from "@douyinfe/semi-ui-19";
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>
</>
}
/>
)}
</>
);
}
// Keep directory callers on one implementation while preserving their source/revision contract.
export { ScopePicker as ScopeControls } from "../settings/ScopePicker";
+24 -57
View File
@@ -1,3 +1,6 @@
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions";
import { initialSettings } from "../backtests/types";
import {
ResearchDataInput,
researchSelection,
@@ -40,21 +43,9 @@ export function FlowLaunchForm({
const [seed, setSeed] = useState(0);
const [confirmation, setConfirmation] = useState<FlowLaunch | null>(null);
const [busy, setBusy] = useState(false);
const [settings, setSettings] = useState<SimulationSettings>({
instrumentType: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
decay: 0,
neutralization: "INDUSTRY",
truncation: 0.08,
pasteurization: "ON",
unitHandling: "VERIFY",
nanHandling: "OFF",
language: "FASTEXPR",
visualization: false,
maxTrade: "OFF",
});
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
const options = useSettingsOptions();
const [settingsValid, setSettingsValid] = useState(false);
useEffect(() => {
const c = new AbortController();
Promise.all([
@@ -169,47 +160,17 @@ export function FlowLaunchForm({
onChange={(v) => setTemplate(v ? String(v) : undefined)}
/>
</label>
<p>
{settings.region} / {settings.universe} / D{settings.delay} · REGULAR /
FASTEXPR / EQUITY
</p>
<div className="research-form-grid">
<label>
中性化
<Input
aria-label="自动研究中性化"
value={settings.neutralization}
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>
<SimulationSettingsEditor
value={settings}
onChange={setSettings}
{...options}
onRefresh={() => void options.refresh()}
fixedScopes={inputs
.filter((r) => ids.includes(r.id))
.map((r) => r.scope)}
disabled={busy}
onValidityChange={setSettingsValid}
/>
<h4>本次授权预算</h4>
<div className="research-form-grid">
{(
@@ -263,7 +224,13 @@ export function FlowLaunchForm({
/>
<Button
theme="solid"
disabled={!ids.length || !hypothesis.trim() || !name.trim()}
disabled={
!ids.length ||
!hypothesis.trim() ||
!name.trim() ||
!settingsValid ||
busy
}
onClick={preview}
>
核对并启动研究
+20 -103
View File
@@ -1,3 +1,5 @@
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
import { useSettingsOptions } from "../settings/useSettingsOptions";
import {
ResearchDataInput,
researchSelection,
@@ -67,7 +69,8 @@ export function ResearchWorkspace({
const [parent, setParent] = useState("");
const [method, setMethod] = useState("structure");
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 [limit, setLimit] = useState(100);
const [seed, setSeed] = useState(0);
@@ -188,28 +191,6 @@ export function ResearchWorkspace({
setMethod("structure");
}
}, [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() {
const next = asset
? await api<Asset>(`/research/assets/${asset.id}`, {
@@ -759,88 +740,24 @@ export function ResearchWorkspace({
/>
</label>
</div>
<div className="research-section-heading">
<h4>模拟设置</h4>
<Button
disabled={!inputIds.length || !!busy}
onClick={() => void task("同步合法设置", refreshSettings)}
>
同步合法设置
</Button>
</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>
<SimulationSettingsEditor
value={settings}
onChange={setSettings}
{...options}
onRefresh={() => void options.refresh()}
fixedScopes={selectedInputs.map((r) => r.scope)}
disabled={!!busy}
onValidityChange={setSettingsValid}
/>
<Button
theme="solid"
disabled={dirty || !inputIds.length || !hypothesis || !!busy}
disabled={
dirty ||
!inputIds.length ||
!hypothesis ||
!!busy ||
!settingsValid
}
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),
};
}