feat: add versioned research templates and alpha variants
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
import { ResearchSelect } from "./ResearchSelect";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
TextArea,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { api, post, formatTime } from "../api";
|
||||
import type { PageContext, UIAction } from "../ai/types";
|
||||
import { initialSettings, type SimulationSettings } from "../backtests/types";
|
||||
import {
|
||||
blankTemplate,
|
||||
type Asset,
|
||||
type Experiment,
|
||||
type InputSnapshot,
|
||||
type Template,
|
||||
} from "./workspaceTypes";
|
||||
import { TemplateEditor } from "./TemplateEditor";
|
||||
import { ExperimentView } from "./ExperimentView";
|
||||
import { ComparisonPanel } from "./ComparisonPanel";
|
||||
import "./workspace.css";
|
||||
|
||||
type ImportResult = {
|
||||
templates: Template[];
|
||||
digest: string;
|
||||
conflicts: unknown[];
|
||||
errors: unknown[];
|
||||
differences?: unknown[];
|
||||
};
|
||||
export function ResearchWorkspace({
|
||||
page,
|
||||
active,
|
||||
action,
|
||||
onAction,
|
||||
onContext,
|
||||
}: {
|
||||
page: "templates" | "variants";
|
||||
active: boolean;
|
||||
action: UIAction | null;
|
||||
onAction: (action: UIAction) => void;
|
||||
onContext: (context: PageContext) => void;
|
||||
}) {
|
||||
const processedAction = useRef<number | null>(null);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [inputs, setInputs] = useState<InputSnapshot[]>([]);
|
||||
const [inputIds, setInputIds] = useState<string[]>([]);
|
||||
const [template, setTemplate] = useState<Template>(blankTemplate);
|
||||
const [asset, setAsset] = useState<Asset | null>(null);
|
||||
const [hypothesis, setHypothesis] = useState("");
|
||||
const [parent, setParent] = useState("");
|
||||
const [method, setMethod] = useState("structure");
|
||||
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
|
||||
const [neutralizations, setNeutralizations] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState("all");
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [history, setHistory] = useState<
|
||||
{ id: string; name: string; created_at: string; total: number }[]
|
||||
>([]);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [assetPage, setAssetPage] = useState(0);
|
||||
const [historyPage, setHistoryPage] = useState(0);
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [historyTotal, setHistoryTotal] = useState(0);
|
||||
const refreshSequence = useRef(0);
|
||||
const [importText, setImportText] = useState("");
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
|
||||
const dirty =
|
||||
!asset || JSON.stringify(template) !== JSON.stringify(asset.content);
|
||||
async function task(label: string, action: () => Promise<void>) {
|
||||
setBusy(label);
|
||||
setError("");
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
const sequence = ++refreshSequence.current;
|
||||
const [nextAssets, nextInputs, nextHistory] = await Promise.all([
|
||||
api<{ items: Asset[]; total: number }>(
|
||||
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
|
||||
),
|
||||
api<{ items: InputSnapshot[] }>("/research/inputs"),
|
||||
api<{ items: typeof history; total: number }>(
|
||||
`/research/experiments?limit=25&offset=${historyPage * 25}`,
|
||||
),
|
||||
]);
|
||||
if (sequence !== refreshSequence.current) return;
|
||||
setAssetTotal(nextAssets.total);
|
||||
setHistoryTotal(nextHistory.total);
|
||||
setAssets(nextAssets.items);
|
||||
setInputs(nextInputs.items);
|
||||
setHistory(nextHistory.items);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (active) void task("读取研究素材", refresh);
|
||||
}, [active, search, assetPage, historyPage]);
|
||||
useEffect(() => {
|
||||
if (active)
|
||||
onContext({
|
||||
page,
|
||||
research_asset_id: asset?.id,
|
||||
research_experiment_id: experiment?.id,
|
||||
alpha_id: parent.split(/[,,\s]+/)[0] || undefined,
|
||||
template_input_id: inputIds.length === 1 ? inputIds[0] : undefined,
|
||||
});
|
||||
}, [active, page, asset?.id, experiment?.id, parent, inputIds, onContext]);
|
||||
useEffect(() => {
|
||||
if (!active || !action || processedAction.current === action.nonce) return;
|
||||
processedAction.current = action.nonce;
|
||||
if (action.type === "open_experiment")
|
||||
void task("读取实验", async () =>
|
||||
setExperiment(
|
||||
await api(`/research/experiments/${action.experiment_id}`),
|
||||
),
|
||||
);
|
||||
if (action.type === "open_template")
|
||||
void task("读取模板", async () => {
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
});
|
||||
if (action.type === "open_variant") {
|
||||
setParent(action.alpha_id);
|
||||
setMethod("structure");
|
||||
}
|
||||
}, [active, action]);
|
||||
function selectInputs(ids: string[]) {
|
||||
setInputIds(ids);
|
||||
const first = inputs.find((item) => item.id === ids[0]);
|
||||
if (first)
|
||||
setSettings((old) => ({
|
||||
...old,
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}));
|
||||
}
|
||||
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}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
kind: "template",
|
||||
version: asset.version,
|
||||
content: template,
|
||||
}),
|
||||
})
|
||||
: await post<Asset>("/research/assets", {
|
||||
kind: "template",
|
||||
content: template,
|
||||
});
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
await refresh();
|
||||
Toast.success(`已保存模板 v${next.version}`);
|
||||
}
|
||||
async function generate() {
|
||||
const next = await post<Asset>("/research/generate", {
|
||||
name: template.name,
|
||||
hypothesis,
|
||||
input_ids: inputIds,
|
||||
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
method: page === "variants" ? "structure" : "template",
|
||||
});
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
await refresh();
|
||||
}
|
||||
async function expand() {
|
||||
if (dirty) throw new Error("请先保存模板版本,再生成可追溯候选");
|
||||
const next = await post<Experiment>("/research/experiments", {
|
||||
asset_id: asset!.id,
|
||||
version: asset!.version,
|
||||
input_ids: inputIds,
|
||||
hypothesis,
|
||||
settings,
|
||||
mode,
|
||||
limit,
|
||||
seed,
|
||||
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
});
|
||||
setExperiment(next);
|
||||
await refresh();
|
||||
}
|
||||
const combination = Object.values(template.variables)
|
||||
.reduce(
|
||||
(size, variable) =>
|
||||
size * BigInt(new Set(variable.values.map(String)).size),
|
||||
1n,
|
||||
)
|
||||
.toString();
|
||||
return (
|
||||
<div className="research-workspace">
|
||||
<header className="research-page-heading">
|
||||
<div>
|
||||
<h2>{page === "templates" ? "模板工坊" : "Alpha 变体"}</h2>
|
||||
<p>
|
||||
{page === "templates"
|
||||
? "从固定输入生成候选,保存每一次模板版本与研究依据。"
|
||||
: "围绕已有 Alpha 研究结构、字段与市场设置的变化。"}
|
||||
</p>
|
||||
</div>
|
||||
<Button loading={!!busy} onClick={() => void task("刷新", refresh)}>
|
||||
刷新素材
|
||||
</Button>
|
||||
</header>
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
<div className="research-layout">
|
||||
<aside className="research-library">
|
||||
<div className="research-section-heading">
|
||||
<h3>模板库</h3>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setAsset(null);
|
||||
setTemplate(blankTemplate());
|
||||
}}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
aria-label="搜索模板"
|
||||
placeholder="搜索名称"
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
setSearch(value);
|
||||
setAssetPage(0);
|
||||
}}
|
||||
/>
|
||||
{assets.map((item) => (
|
||||
<button
|
||||
className={`research-library-item ${asset?.id === item.id ? "selected" : ""}`}
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
void task("读取模板", async () => {
|
||||
const next = await api<Asset>(`/research/assets/${item.id}`);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
})
|
||||
}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>v{item.version}</span>
|
||||
</button>
|
||||
))}
|
||||
{!assets.length && <p>还没有模板。新建或导入一个模板开始研究。</p>}
|
||||
<div className="research-toolbar">
|
||||
<small>共 {assetTotal} 个模板</small>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!assetPage}
|
||||
onClick={() => setAssetPage(assetPage - 1)}
|
||||
>
|
||||
上一页模板
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={(assetPage + 1) * 25 >= assetTotal}
|
||||
onClick={() => setAssetPage(assetPage + 1)}
|
||||
>
|
||||
下一页模板
|
||||
</Button>
|
||||
</div>
|
||||
<h3>生成记录</h3>
|
||||
{history.map((item) => (
|
||||
<button
|
||||
className="research-library-item"
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
void task("读取实验", async () =>
|
||||
setExperiment(await api(`/research/experiments/${item.id}`)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>
|
||||
{item.total} 个候选 · {formatTime(item.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="research-toolbar">
|
||||
<small>共 {historyTotal} 次生成</small>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!historyPage}
|
||||
onClick={() => setHistoryPage(historyPage - 1)}
|
||||
>
|
||||
上一页记录
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={(historyPage + 1) * 25 >= historyTotal}
|
||||
onClick={() => setHistoryPage(historyPage + 1)}
|
||||
>
|
||||
下一页记录
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="research-main">
|
||||
<section className="research-card">
|
||||
<h3>研究输入与假设</h3>
|
||||
{page === "variants" && (
|
||||
<>
|
||||
<div className="research-methods">
|
||||
<Button
|
||||
theme={method === "structure" ? "solid" : "light"}
|
||||
onClick={() => setMethod("structure")}
|
||||
>
|
||||
结构与字段变体
|
||||
</Button>
|
||||
<Button
|
||||
theme={method === "settings" ? "solid" : "light"}
|
||||
onClick={() => setMethod("settings")}
|
||||
>
|
||||
市场与设置变体
|
||||
</Button>
|
||||
</div>
|
||||
<p>
|
||||
{method === "structure"
|
||||
? "神笔马良:保留种子与改动依据,研究新的表达式。"
|
||||
: "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"}
|
||||
</p>
|
||||
<label>
|
||||
种子 Alpha
|
||||
<Input
|
||||
aria-label="种子 Alpha"
|
||||
value={parent}
|
||||
onChange={setParent}
|
||||
placeholder="已同步的 Alpha ID"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
固定研究输入
|
||||
<ResearchSelect
|
||||
multiple
|
||||
filter
|
||||
label="固定研究输入"
|
||||
value={inputIds}
|
||||
optionList={inputs.map((input) => ({
|
||||
value: input.id,
|
||||
label: `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id.slice(0, 8)}`,
|
||||
}))}
|
||||
onChange={(value) => selectInputs(value as string[])}
|
||||
/>
|
||||
</label>
|
||||
<p className="research-hint">
|
||||
在数据目录中保存字段选择。跨数据集分别关联输入,跨市场使用目标范围的独立输入。
|
||||
</p>
|
||||
<label>
|
||||
本次研究假设
|
||||
<TextArea
|
||||
aria-label="本次研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
autosize={{ minRows: 2, maxRows: 5 }}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
{page !== "variants" || method === "structure" ? (
|
||||
<section className="research-card">
|
||||
<div className="research-section-heading">
|
||||
<h3>
|
||||
编辑与展开{asset && ` · v${asset.version}`}
|
||||
{dirty && " · 未保存"}
|
||||
</h3>
|
||||
<Button
|
||||
disabled={!inputIds.length || !hypothesis || !!busy}
|
||||
loading={busy === "AI 生成"}
|
||||
onClick={() => void task("AI 生成", generate)}
|
||||
>
|
||||
AI 生成模板
|
||||
</Button>
|
||||
</div>
|
||||
<TemplateEditor
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
inputs={selectedInputs}
|
||||
/>
|
||||
{asset && (
|
||||
<div className="research-toolbar">
|
||||
<span>版本记录</span>
|
||||
<ResearchSelect
|
||||
label="模板历史版本"
|
||||
value={asset.version}
|
||||
optionList={Array.from(
|
||||
{
|
||||
length:
|
||||
assets.find((item) => item.id === asset.id)
|
||||
?.version || asset.version,
|
||||
},
|
||||
(_, i) => ({ value: i + 1, label: `v${i + 1}` }),
|
||||
)}
|
||||
onChange={(version) =>
|
||||
void task("读取历史版本", async () => {
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${asset.id}?version=${version}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAsset(null);
|
||||
setTemplate({
|
||||
...template,
|
||||
name: `${template.name}(副本)`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
另存新模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
disabled={!!busy}
|
||||
onClick={() => void task("保存模板", save)}
|
||||
>
|
||||
保存模板版本
|
||||
</Button>
|
||||
<span>组合规模:{combination}</span>
|
||||
</div>
|
||||
<div className="research-form-grid">
|
||||
<label>
|
||||
展开方式
|
||||
<ResearchSelect
|
||||
label="展开方式"
|
||||
value={mode}
|
||||
optionList={[
|
||||
{ value: "all", label: "全组合" },
|
||||
{ value: "random", label: "可复现随机采样" },
|
||||
]}
|
||||
onChange={(value) => setMode(value as string)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
候选数量上限
|
||||
<InputNumber
|
||||
aria-label="候选数量上限"
|
||||
value={limit}
|
||||
min={1}
|
||||
max={10000}
|
||||
onChange={(value) => setLimit(Number(value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
随机种子
|
||||
<InputNumber
|
||||
aria-label="随机种子"
|
||||
value={seed}
|
||||
onChange={(value) => setSeed(Number(value))}
|
||||
/>
|
||||
</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>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={dirty || !inputIds.length || !hypothesis || !!busy}
|
||||
onClick={() => void task("生成候选", expand)}
|
||||
>
|
||||
保存候选研究记录
|
||||
</Button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="research-card">
|
||||
<h3>目标范围检查</h3>
|
||||
<p>
|
||||
保留种子表达式和模拟参数,按各目标输入的市场、股票池与延迟生成设置变体。局部变量不会当作字段;无法核实的字段不会被跳过。
|
||||
</p>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!parent || !inputIds.length || !!busy}
|
||||
onClick={() =>
|
||||
void task("检查设置变体", async () => {
|
||||
setExperiment(
|
||||
await post("/research/variants/settings", {
|
||||
alpha_id: parent,
|
||||
input_ids: inputIds,
|
||||
...(hypothesis ? { hypothesis } : {}),
|
||||
}),
|
||||
);
|
||||
await refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
生成设置变体
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
{experiment && (
|
||||
<ExperimentView
|
||||
key={experiment.id}
|
||||
experiment={experiment}
|
||||
onAction={onAction}
|
||||
/>
|
||||
)}
|
||||
{page === "templates" && (
|
||||
<details className="research-card">
|
||||
<summary>导入旧模板</summary>
|
||||
<p>
|
||||
粘贴 cnhk 模板 JSON
|
||||
或模板数组。先预览转换结果,同名模板不会自动覆盖。
|
||||
</p>
|
||||
<TextArea
|
||||
aria-label="导入模板 JSON"
|
||||
value={importText}
|
||||
onChange={(text) => {
|
||||
setImportText(text);
|
||||
setImportResult(null);
|
||||
}}
|
||||
autosize={{ minRows: 4, maxRows: 12 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!!busy || !importText}
|
||||
onClick={() =>
|
||||
void task("预览导入", async () => {
|
||||
const data = JSON.parse(importText);
|
||||
setImportResult(
|
||||
await post("/research/templates/import-preview", {
|
||||
templates: Array.isArray(data) ? data : [data],
|
||||
}),
|
||||
);
|
||||
})
|
||||
}
|
||||
>
|
||||
预览导入差异
|
||||
</Button>
|
||||
{importResult && (
|
||||
<>
|
||||
<pre>{JSON.stringify(importResult, null, 2)}</pre>
|
||||
<Button
|
||||
disabled={
|
||||
!!busy ||
|
||||
!!importResult.errors?.length ||
|
||||
!!importResult.conflicts?.length
|
||||
}
|
||||
onClick={() =>
|
||||
void task("导入模板", async () => {
|
||||
await post("/research/templates/import", {
|
||||
templates: importResult.templates,
|
||||
digest: importResult.digest,
|
||||
});
|
||||
setImportResult(null);
|
||||
setImportText("");
|
||||
await refresh();
|
||||
Toast.success("导入完成");
|
||||
})
|
||||
}
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
{page === "variants" && (
|
||||
<ComparisonPanel key={parent} baseline={parent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user