feat: unify scope settings and catalog table interactions
Deploy production / deploy (push) Successful in 54s
Deploy production / deploy (push) Successful in 54s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user