228 lines
7.0 KiB
TypeScript
228 lines
7.0 KiB
TypeScript
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,
|
|
inline = false,
|
|
active = true,
|
|
revision = "",
|
|
options,
|
|
readOnly = false,
|
|
disabled = false,
|
|
onValidityChange,
|
|
}: {
|
|
value: Scope;
|
|
onChange: (scope: Scope) => void;
|
|
local?: boolean;
|
|
inline?: 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${inline ? " scope-picker-inline" : ""}`}
|
|
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>
|
|
);
|
|
}
|