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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user