feat: add initial frontend setup with styles, types, and testing framework

- Created a global CSS file for styling the frontend with responsive design.
- Introduced TypeScript types for various entities including Research, Alpha, and Account.
- Implemented Playwright tests for account management and data synchronization workflows.
- Configured TypeScript with strict settings and included necessary libraries.
- Set up Vite as the build tool with React plugin and API proxy configuration.
- Added a Python script to initialize environment variables securely.
This commit is contained in:
yuxuanhui
2026-09-07 14:54:20 +08:00
commit 3cd280d068
53 changed files with 10888 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Input,
Select,
Spin,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import {
IconRefresh,
IconLink,
IconShield,
IconUser,
} from "@douyinfe/semi-icons";
import { api, displayValue, formatTime, patch, post } from "../api";
import type { Account } from "../types";
const connectionLabels: Record<string, string> = {
connected: "已连接",
disconnected: "未连接",
expired: "会话待恢复",
connecting: "连接中",
verification_required: "需要人工验证",
error: "连接异常",
};
export function AccountPage({
account,
onChange,
onTask,
}: {
account: Account | null;
onChange: () => void;
onTask: () => void;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState("");
const [preferences, setPreferences] = useState({
display_name: "研究员",
theme: "light",
timezone: "Asia/Shanghai",
page_size: 25,
});
useEffect(() => {
if (account) setEmail(account.email ?? "");
}, [account?.email]);
useEffect(() => {
if (account)
setPreferences({
display_name: account.display_name,
theme: account.theme,
timezone: account.timezone,
page_size: account.page_size,
});
}, [
account?.display_name,
account?.theme,
account?.timezone,
account?.page_size,
]);
if (!account) return <Spin size="large" />;
async function action(key: string, fn: () => Promise<unknown>, task = false) {
setBusy(key);
try {
await fn();
if (task) onTask();
else {
onChange();
Toast.success("已保存");
}
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
const profile = account.profile;
const profileRows = [
["用户 ID", account.wq_user_id],
["昵称", profile.name ?? profile.username ?? profile.firstName],
["平台邮箱", profile.email ?? account.email],
["身份", profile.role ?? profile.roles ?? profile.type],
["权限", profile.permissions],
[
"资料同步时间",
account.last_synced_at
? formatTime(account.last_synced_at, account.timezone)
: null,
],
];
return (
<>
<div className="page-heading">
<div>
<div className="eyebrow">PERSONAL WORKSPACE</div>
<h1>个人信息</h1>
<p>管理账户连接和工作空间偏好。</p>
</div>
<Tag
color={account.connection_status === "connected" ? "green" : "grey"}
size="large"
>
{connectionLabels[account.connection_status] ??
account.connection_status}
</Tag>
</div>
{account.connection_error && (
<Banner
type={
account.connection_status === "verification_required"
? "warning"
: "danger"
}
description={account.connection_error}
/>
)}
{account.verification_url && (
<div className="verification-callout">
<strong>请完成 WorldQuant 人工验证</strong>
<p>在新窗口完成平台验证后,回到这里继续。验证链接只属于当前会话。</p>
<div className="inline-actions">
<a href={account.verification_url} target="_blank" rel="noreferrer">
<Button theme="solid">打开验证页面</Button>
</a>
<Button
loading={busy === "verify"}
onClick={() =>
void action("verify", () => post("/account/verify"), true)
}
>
已完成,继续验证
</Button>
</div>
</div>
)}
<div className="account-grid">
<section className="panel">
<div className="panel-title">
<IconLink />
<div>
<h2>WorldQuant 连接</h2>
<p>连接你的个人 BRAIN 账户</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void action("save", async () => {
await api("/account/credentials", {
method: "PUT",
body: JSON.stringify({ email, password }),
});
setPassword("");
});
}}
>
<label>
WorldQuant 邮箱
<Input
aria-label="WorldQuant 邮箱"
value={email}
onChange={setEmail}
placeholder="name@example.com"
autoComplete="off"
/>
</label>
<label>
WorldQuant 密码
<Input
aria-label="WorldQuant 密码"
mode="password"
value={password}
onChange={setPassword}
placeholder={
account.configured
? "已保存 · 输入新密码可更新配置"
: "输入你的平台密码"
}
autoComplete="new-password"
/>
</label>
<div className="credential-note">
<IconShield />
密码加密存储,仅用于服务端连接平台。
</div>
<div className="inline-actions">
<Button
htmlType="submit"
disabled={!email || !password || Boolean(busy)}
loading={busy === "save"}
>
保存凭据
</Button>
<Button
theme="solid"
disabled={!account.configured || Boolean(busy)}
loading={busy === "connect"}
onClick={() =>
void action("connect", () => post("/account/connect"), true)
}
>
{account.connection_status === "connected"
? "重新连接"
: "连接 WorldQuant"}
</Button>
<Button
theme="borderless"
disabled={
Boolean(busy) || account.connection_status === "disconnected"
}
onClick={() =>
void action("disconnect", () => post("/account/disconnect"))
}
>
断开连接
</Button>
</div>
</form>
</section>
<section className="panel">
<div className="panel-title">
<IconUser />
<div>
<h2>平台个人资料</h2>
<p>展示最近一次成功获取的信息</p>
</div>
<Button
theme="borderless"
icon={<IconRefresh />}
aria-label="刷新个人资料"
disabled={!account.configured || Boolean(busy)}
onClick={() =>
void action("profile", () => post("/account/refresh"), true)
}
/>
</div>
<dl className="profile-grid">
{profileRows.map(([key, value]) => (
<div key={String(key)}>
<dt>{String(key)}</dt>
<dd>{displayValue(value)}</dd>
</div>
))}
</dl>
</section>
<section className="panel full-width">
<div className="panel-title">
<div>
<h2>工作空间偏好</h2>
<p>只影响你的本地研究界面</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void action("preferences", () =>
patch("/account/preferences", preferences),
);
}}
>
<div className="preferences-grid">
<label>
显示名称
<Input
aria-label="显示名称"
value={preferences.display_name}
onChange={(display_name) =>
setPreferences({ ...preferences, display_name })
}
/>
</label>
<label>
主题
<Select
aria-label="主题"
value={preferences.theme}
optionList={[
{ value: "light", label: "浅色" },
{ value: "dark", label: "深色" },
]}
onChange={(theme) =>
setPreferences({ ...preferences, theme: String(theme) })
}
/>
</label>
<label>
时区
<Input
aria-label="时区"
value={preferences.timezone}
onChange={(timezone) =>
setPreferences({ ...preferences, timezone })
}
/>
</label>
<label>
每页数量
<Select
aria-label="每页数量"
value={preferences.page_size}
optionList={[25, 50, 100].map((value) => ({
value,
label: `${value} 条`,
}))}
onChange={(page_size) =>
setPreferences({
...preferences,
page_size: Number(page_size),
})
}
/>
</label>
</div>
<Button
htmlType="submit"
theme="solid"
loading={busy === "preferences"}
disabled={Boolean(busy)}
>
保存偏好
</Button>
</form>
</section>
</div>
</>
);
}
+781
View File
@@ -0,0 +1,781 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Banner,
Button,
Checkbox,
CheckboxGroup,
Empty,
Input,
Modal,
Popover,
Select,
Table,
Tag,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import {
IconSearch,
IconRefresh,
IconDownload,
IconPlus,
IconSetting,
IconStar,
} from "@douyinfe/semi-icons";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import {
api,
formatNumber,
formatTime,
patch,
post,
queryString,
stateLabels,
stateOptions,
} from "../api";
import type { Account, Alpha, AlphaPage as Page, Facets } from "../types";
import { AlphaDetail } from "../components/AlphaDetail";
const metricLabels = {
sharpe: "Sharpe",
fitness: "Fitness",
returns: "Returns",
turnover: "Turnover",
margin: "Margin",
drawdown: "Drawdown",
};
const initialColumns = [
"name",
"expression",
"region",
"status",
"sharpe",
"fitness",
"turnover",
"research",
];
const columnLabels: Record<string, string> = {
name: "Alpha",
expression: "表达式",
region: "地区 / Universe",
status: "平台状态",
sharpe: "Sharpe",
fitness: "Fitness",
returns: "Returns",
turnover: "Turnover",
margin: "Margin",
drawdown: "Drawdown",
research: "研究状态",
tags: "本地标签",
language: "语言",
created: "创建时间",
};
export function AlphaPage({
account,
taskPanelOpen,
version,
onTask,
onAccount,
}: {
account: Account | null;
taskPanelOpen: boolean;
version: string;
onTask: () => void;
onAccount: () => void;
}) {
const [data, setData] = useState<Page>({
items: [],
total: 0,
limit: 25,
offset: 0,
});
const [facets, setFacets] = useState<Facets>({});
const [draft, setDraft] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>({});
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState("date_created");
const [direction, setDirection] = useState("desc");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
try {
const stored: unknown = JSON.parse(
localStorage.getItem("alpha-columns") || "null",
);
return Array.isArray(stored)
? [
...new Set([
"name",
...stored.filter(
(v): v is string => typeof v === "string" && v in columnLabels,
),
]),
]
: initialColumns;
} catch {
return initialColumns;
}
});
const [detailId, setDetailId] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [idText, setIdText] = useState("");
const [busy, setBusy] = useState("");
const [bulkOpen, setBulkOpen] = useState(false);
const [bulk, setBulk] = useState({
add_tags: "",
remove_tags: "",
state: "",
});
const [localVersion, setLocalVersion] = useState(0);
useEffect(() => {
if (account) {
setPageSize(account.page_size);
setPage(1);
}
}, [account?.page_size]);
const params = useMemo(
() => ({
...filters,
sort,
direction,
limit: pageSize,
offset: (page - 1) * pageSize,
}),
[filters, sort, direction, pageSize, page],
);
const query = queryString(params);
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
Promise.all([
api<Page>(`/alphas?${query}`, { signal: controller.signal }),
api<Facets>("/alphas/facets", { signal: controller.signal }),
])
.then(([results, nextFacets]) => {
if (!controller.signal.aborted) {
setData(results);
setFacets(nextFacets);
}
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [query, version, localVersion]);
useEffect(() => {
setSelected([]);
}, [query]);
function updateDraft(key: string, value: unknown) {
setDraft((previous) => ({
...previous,
[key]: value === undefined || value === null ? "" : String(value),
}));
}
function applyFilters() {
setFilters({ ...draft });
setPage(1);
}
function clearFilters() {
setDraft({});
setFilters({});
setPage(1);
}
async function newTask(kind: string, ids: string[] = []) {
setBusy(kind);
try {
await post("/sync-jobs", { kind, alpha_ids: ids });
setImporting(false);
setIdText("");
onTask();
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
async function bulkSave() {
setBusy("bulk");
const split = (value: string) =>
value
.split(/[,,]/)
.map((t) => t.trim())
.filter(Boolean);
try {
await patch("/alphas/research/bulk", {
alpha_ids: selected,
add_tags: split(bulk.add_tags),
remove_tags: split(bulk.remove_tags),
state: bulk.state || null,
});
setBulkOpen(false);
setBulk({ add_tags: "", remove_tags: "", state: "" });
refresh();
Toast.success("批量研究记录已更新");
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
const columnDefinitions: ColumnProps<Alpha>[] = [
{
key: "name",
title: "Alpha",
dataIndex: "name",
fixed: "left",
width: 190,
render: (_, row) => (
<button className="alpha-link" onClick={() => setDetailId(row!.id)}>
<span>
{row!.research.favorite && <IconStar className="favorite-icon" />}
{row!.name || row!.id}
</span>
<small>
{row!.name ? row!.id : (row!.alpha_type ?? "未提供类型")}
</small>
</button>
),
},
{
key: "expression",
title: "表达式",
width: 260,
render: (_, row) => (
<code className="expression-cell" title={row!.expression_preview}>
{row!.expression_preview || "—"}
</code>
),
},
{
key: "region",
title: "地区 / Universe",
width: 155,
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.region ?? "—"}</span>
<small>{row!.universe ?? "—"}</small>
</div>
),
},
{
key: "status",
title: "平台状态",
width: 150,
render: (_, row) => (
<div className="stacked-cell">
<span>{row!.status ?? "未提供"}</span>
<small>
{row!.stage ?? "—"}
{row!.hidden ? " · 已隐藏" : ""}
</small>
</div>
),
},
...Object.entries(metricLabels).map(
([key, title]): ColumnProps<Alpha> => ({
key,
title,
width: 112,
align: "right",
render: (_, row) => (
<span className="numeric">
{formatNumber(row![key as keyof Alpha], key === "margin" ? 6 : 3)}
</span>
),
}),
),
{
key: "research",
fixed: "right",
title: "研究状态",
width: 115,
render: (_, row) => (
<Tag
color={
row!.research.state === "candidate"
? "green"
: row!.research.state === "optimizing"
? "blue"
: "grey"
}
>
{stateLabels[row!.research.state]}
</Tag>
),
},
{
key: "tags",
title: "本地标签",
width: 180,
render: (_, row) => (
<div className="inline-tags">
{row!.research.tags.map((t) => (
<Tag key={t}>{t}</Tag>
))}
</div>
),
},
{ key: "language", title: "语言", width: 125, dataIndex: "language" },
{
key: "created",
title: "创建时间",
width: 165,
render: (_, row) => formatTime(row!.date_created, account?.timezone),
},
];
const columns = columnDefinitions.filter((column) =>
visibleColumns.includes(String(column.key)),
);
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const options = (key: string) =>
(Array.isArray(facets[key]) ? (facets[key] as string[]) : []).map(
(value) => ({ value, label: value }),
);
const connected =
account?.connection_status === "connected" ||
account?.connection_status === "expired";
return (
<>
<div className="page-heading">
<div>
<div className="eyebrow">ALPHA LIBRARY</div>
<h1>Alpha 管理</h1>
<p>集中查看平台信号,记录每一步研究判断。</p>
</div>
<div className="inline-actions">
<Button icon={<IconPlus />} onClick={() => setImporting(true)}>
导入 Alpha ID
</Button>
<Button
theme="solid"
icon={<IconRefresh />}
loading={busy === "full_sync"}
disabled={!connected}
onClick={() => void newTask("full_sync")}
>
同步平台数据
</Button>
</div>
</div>
<div className="library-stats">
<div>
<span>已同步 Alpha</span>
<strong>{Number(facets.total ?? 0).toLocaleString()}</strong>
</div>
<div>
<span>我的收藏</span>
<strong>{Number(facets.favorites ?? 0).toLocaleString()}</strong>
</div>
<div>
<span>覆盖地区</span>
<strong>
{Array.isArray(facets.region) ? facets.region.length : 0}
</strong>
</div>
<div className="last-sync">
<span>最近数据更新</span>
<strong>
{formatTime(
typeof facets.last_sync === "string" ? facets.last_sync : null,
account?.timezone,
)}
</strong>
</div>
</div>
{!connected && (
<Banner
type="info"
description={
<span>
连接 WorldQuant 后即可同步平台数据。
<button className="text-link" onClick={onAccount}>
前往个人信息
</button>
</span>
}
/>
)}
{error && <Banner type="danger" description={error} />}
<section className="library-panel">
<form
className="filter-panel"
onSubmit={(e) => {
e.preventDefault();
applyFilters();
}}
>
<div className="primary-filters">
<Input
aria-label="搜索 Alpha"
prefix={<IconSearch />}
placeholder="搜索 ID、名称或表达式"
value={draft.q ?? ""}
onChange={(v) => updateDraft("q", v)}
showClear
/>
<Select
aria-label="地区"
placeholder="全部地区"
showClear
value={draft.region || undefined}
optionList={options("region")}
onChange={(v) => updateDraft("region", v)}
/>
<Select
aria-label="平台状态"
placeholder="全部平台状态"
showClear
value={draft.status || undefined}
optionList={options("status")}
onChange={(v) => updateDraft("status", v)}
/>
<Select
aria-label="研究状态筛选"
placeholder="全部研究状态"
showClear
value={draft.research_state || undefined}
optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)}
/>
<Button htmlType="submit" theme="solid">
查询
</Button>
<Button theme="borderless" onClick={clearFilters}>
重置
</Button>
</div>
<details className="advanced-filters">
<summary>
更多筛选{" "}
{activeFilterCount ? `· ${activeFilterCount} 项已应用` : ""}
</summary>
<div className="advanced-grid">
{[
["universe", "Universe"],
["alpha_type", "Alpha 类型"],
["language", "语言"],
["stage", "IS / OS"],
].map(([key, label]) => (
<label key={key}>
{label}
<Select
showClear
aria-label={label}
value={draft[key] || undefined}
optionList={options(key)}
placeholder="全部"
onChange={(v) => updateDraft(key, v)}
/>
</label>
))}
<label>
平台隐藏状态
<Select
showClear
value={draft.hidden || undefined}
optionList={[
{ value: "false", label: "可见" },
{ value: "true", label: "隐藏" },
]}
placeholder="全部"
onChange={(v) => updateDraft("hidden", v)}
/>
</label>
<label>
本地标签
<Select
showClear
filter
value={draft.tag || undefined}
optionList={options("tags")}
placeholder="全部标签"
onChange={(v) => updateDraft("tag", v)}
/>
</label>
<label>
创建时间起(UTC)
<Input
type="date"
value={draft.created_from?.slice(0, 10) || ""}
onChange={(v) =>
updateDraft("created_from", v ? `${v}T00:00:00Z` : "")
}
/>
</label>
<label>
创建时间止(UTC)
<Input
type="date"
value={draft.created_to?.slice(0, 10) || ""}
onChange={(v) =>
updateDraft("created_to", v ? `${v}T23:59:59.999999Z` : "")
}
/>
</label>
{Object.entries(metricLabels).map(([key, label]) => (
<label key={key}>
{label}
<div className="range-inputs">
<Input
type="number"
aria-label={`${label} 最小值`}
placeholder="最小值"
value={draft[`${key}_min`] || ""}
onChange={(v) => updateDraft(`${key}_min`, v)}
/>
<span>—</span>
<Input
type="number"
aria-label={`${label} 最大值`}
placeholder="最大值"
value={draft[`${key}_max`] || ""}
onChange={(v) => updateDraft(`${key}_max`, v)}
/>
</div>
</label>
))}
<Checkbox
checked={draft.favorite === "true"}
onChange={(e) =>
updateDraft("favorite", e.target.checked ? "true" : "")
}
>
只看收藏
</Checkbox>
</div>
<p className="muted">
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
</p>
</details>
</form>
<div className="table-toolbar">
<div>
<strong>全部 Alpha</strong>
<span className="count-pill">{data.total.toLocaleString()}</span>
{selected.length > 0 && (
<span className="selection-text">
已选择 {selected.length} 条
</span>
)}
</div>
<div className="inline-actions">
{selected.length > 0 && (
<>
<Button size="small" onClick={() => setBulkOpen(true)}>
批量编辑
</Button>
<Button
size="small"
disabled={!connected}
loading={busy === "alpha_refresh"}
onClick={() => void newTask("alpha_refresh", selected)}
>
刷新选中
</Button>
</>
)}
<Select
aria-label="排序字段"
size="small"
value={sort}
optionList={[
{ value: "date_created", label: "创建时间" },
{ value: "date_submitted", label: "提交时间" },
{ value: "synced_at", label: "同步时间" },
...Object.entries(metricLabels).map(([value, label]) => ({
value,
label,
})),
{ value: "name", label: "名称" },
]}
onChange={(v) => {
setSort(String(v));
setPage(1);
}}
/>
<Select
aria-label="排序方向"
size="small"
value={direction}
optionList={[
{ value: "desc", label: "降序" },
{ value: "asc", label: "升序" },
]}
onChange={(v) => {
setDirection(String(v));
setPage(1);
}}
/>
<a href={`/api/v1/alphas/export?${query}`}>
<Button icon={<IconDownload />} size="small">
导出
</Button>
</a>
<Popover
trigger="click"
position="bottomRight"
content={
<div className="columns-picker">
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={visibleColumns}
options={Object.entries(columnLabels).map(
([value, label]) => ({
value,
label,
disabled: value === "name",
}),
)}
onChange={(values) => {
const next = values.map(String);
setVisibleColumns(next);
localStorage.setItem(
"alpha-columns",
JSON.stringify(next),
);
}}
/>
</div>
}
>
<Button
aria-label="显示列设置"
icon={<IconSetting />}
size="small"
/>
</Popover>
</div>
</div>
<Table<Alpha>
columns={columns}
dataSource={data.items}
rowKey="id"
loading={loading}
size="middle"
scroll={{
y: "max(260px, calc(100vh - 670px))",
x: columns.reduce((sum, c) => sum + Number(c.width || 120), 0) + 60,
}}
rowSelection={{
selectedRowKeys: selected,
onChange: (keys) => setSelected((keys ?? []).map(String)),
fixed: true,
}}
pagination={{
currentPage: page,
pageSize,
total: data.total,
showSizeChanger: true,
pageSizeOpts: [25, 50, 100],
onPageChange: (next) => setPage(next),
onPageSizeChange: (size) => {
setPageSize(size);
setPage(1);
},
}}
empty={
<Empty
className="alpha-empty"
image={<div className="empty-alpha">α</div>}
title={
activeFilterCount
? "没有符合条件的 Alpha"
: "从第一个 Alpha 开始"
}
description={
activeFilterCount
? "调整筛选条件,探索更多信号。"
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
}
/>
}
/>
</section>
<p className="page-footnote">
平台数据与本地研究记录独立保存 · 记录不因再次同步而覆盖
</p>
<Modal
title="导入 Alpha ID"
visible={importing}
onCancel={() => setImporting(false)}
confirmLoading={busy === "alpha_refresh"}
okText="开始导入"
onOk={() => {
const ids = [...new Set(idText.split(/[\s,,]+/).filter(Boolean))];
if (!ids.length || ids.length > 100) {
Toast.warning("每次请输入 1–100 个 Alpha ID");
return;
}
void newTask("alpha_refresh", ids);
}}
>
<p>
读取指定 Alpha 的平台详情。每次最多 100 个,使用空格、逗号或换行分隔。
</p>
<TextArea
aria-label="Alpha IDs"
value={idText}
onChange={setIdText}
rows={6}
placeholder="例如:aB12cD34"
/>
</Modal>
<Modal
title={`批量编辑 ${selected.length} 条研究记录`}
visible={bulkOpen}
onCancel={() => setBulkOpen(false)}
confirmLoading={busy === "bulk"}
onOk={() => void bulkSave()}
okText="保存修改"
>
<label>
添加标签
<Input
aria-label="批量添加标签"
placeholder="用逗号分隔"
value={bulk.add_tags}
onChange={(add_tags) => setBulk({ ...bulk, add_tags })}
/>
</label>
<label>
移除标签
<Input
aria-label="批量移除标签"
value={bulk.remove_tags}
onChange={(remove_tags) => setBulk({ ...bulk, remove_tags })}
/>
</label>
<label>
研究状态
<Select
showClear
placeholder="保持现有状态"
value={bulk.state || undefined}
optionList={stateOptions}
onChange={(state) =>
setBulk({ ...bulk, state: state ? String(state) : "" })
}
/>
</label>
</Modal>
<AlphaDetail
taskPanelOpen={taskPanelOpen}
id={detailId}
version={`${version}:${localVersion}`}
timezone={account?.timezone}
onClose={() => setDetailId(null)}
onSaved={refresh}
onTask={onTask}
/>
</>
);
}